diff --git a/eslint.config.js b/eslint.config.js index 9f258cd..213419e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -22,6 +22,9 @@ module.exports = TypeScriptESLint.config( ], processor: AngularESLint.processInlineTemplates, rules: { + '@angular-eslint/use-component-view-encapsulation': 'off', + '@angular-eslint/use-injectable-provided-in': 'off', + '@angular-eslint/no-host-metadata-property': 'off', curly: 'error', 'max-depth': [ 'error', @@ -234,7 +237,8 @@ module.exports = TypeScriptESLint.config( files: ['**/*.html'], extends: [...AngularESLint.configs.templateAll], rules: { - '@angular-eslint/template/i18n': 'off' + '@angular-eslint/template/i18n': 'off', + '@angular-eslint/template/prefer-self-closing-tags': 'off' } } ); diff --git a/package-lock.json b/package-lock.json index 5584492..990e1a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@angular/ssr": "^18.0.0", "express": "^4.0.0", "rxjs": "~7.8.0", + "three": "^0.165.0", "tslib": "^2.0.0", "zone.js": "~0.14.0" }, @@ -29,6 +30,7 @@ "@angular/compiler-cli": "^18.0.0", "@types/express": "^4.0.0", "@types/node": "^20.0.0", + "@types/three": "^0.165.0", "angular-eslint": "18.0.0", "autoprefixer": "^10.0.0", "eslint": "^9.0.0", @@ -4798,6 +4800,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.2", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.2.tgz", + "integrity": "sha512-kMCNaZCJugWI86xiEHaY338CU5JpD0B97p1j1IKNn/Zto8PgACjQx0UxbHjmOcLl/dDOBnItwD07KmCs75pxtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -5031,6 +5040,34 @@ "@types/node": "*" } }, + "node_modules/@types/stats.js": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz", + "integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.165.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.165.0.tgz", + "integrity": "sha512-AJK8JZAFNBF0kBXiAIl5pggYlzAGGA8geVYQXAcPCEDRbyA+oEjkpUBcJJrtNz6IiALwzGexFJGZG2yV3WsYBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "~23.1.1", + "@types/stats.js": "*", + "@types/webxr": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.18.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.17.tgz", + "integrity": "sha512-JYcclaQIlisHRXM9dMF7SeVvQ54kcYc7QK1eKCExCTLKWnZDxP4cp/rXH4Uoa1j5+5oQJ0Cc2sZC/PWiiG4q2g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.5.10", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", @@ -8508,6 +8545,13 @@ "node": ">=0.8.0" } }, + "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/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -10924,6 +10968,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", @@ -14838,6 +14889,12 @@ "tslib": "^2" } }, + "node_modules/three": { + "version": "0.165.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.165.0.tgz", + "integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==", + "license": "MIT" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", diff --git a/package.json b/package.json index 4ec614d..5ca047e 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@angular/ssr": "^18.0.0", "express": "^4.0.0", "rxjs": "~7.8.0", + "three": "^0.165.0", "tslib": "^2.0.0", "zone.js": "~0.14.0" }, @@ -37,6 +38,7 @@ "@angular/compiler-cli": "^18.0.0", "@types/express": "^4.0.0", "@types/node": "^20.0.0", + "@types/three": "^0.165.0", "angular-eslint": "18.0.0", "autoprefixer": "^10.0.0", "eslint": "^9.0.0", diff --git a/src/app/app.component.html b/src/app/app.component.html index 67e7bd4..53209cc 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1 +1,5 @@ - +
+ + + +
diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 18d438c..e77fb7d 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,11 +1,13 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ViewEncapsulation } from '@angular/core'; import { RouterOutlet } from '@angular/router'; +import { BackgroundComponent } from './background/background.component'; @Component({ selector: 'app-root', standalone: true, - imports: [RouterOutlet], + imports: [RouterOutlet, BackgroundComponent], templateUrl: './app.component.html', + encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush }) export class AppComponent { diff --git a/src/app/background/background.component.html b/src/app/background/background.component.html new file mode 100644 index 0000000..685d96e --- /dev/null +++ b/src/app/background/background.component.html @@ -0,0 +1,15 @@ +
+
+ +
+ +
+ +
+
diff --git a/src/app/background/background.component.ts b/src/app/background/background.component.ts new file mode 100644 index 0000000..0f0dc42 --- /dev/null +++ b/src/app/background/background.component.ts @@ -0,0 +1,54 @@ +import { + afterRender, + ChangeDetectionStrategy, + Component, + HostListener, + ViewChild, + ViewEncapsulation, + type ElementRef +} from '@angular/core'; +import type { Dimensions } from '../declarations/dimensions.interface'; +import { BackgroundService } from './background.service'; + +@Component({ + selector: 'app-background', + standalone: true, + imports: [], + templateUrl: './background.component.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + viewProviders: [BackgroundService] +}) +export class BackgroundComponent { + @ViewChild('canvas_container', { static: true }) + public canvasContainerRef?: ElementRef; + + @ViewChild('canvas', { static: true }) + public canvasRef?: ElementRef; + + private get desiredCanvasDimensions(): Dimensions { + if (this.canvasContainerRef === undefined) { + throw new Error('Canvas container element not found'); + } + + return { + widthPx: this.canvasContainerRef.nativeElement.clientWidth, + heightPx: this.canvasContainerRef.nativeElement.clientHeight + }; + } + + constructor(private readonly backgroundService: BackgroundService) { + afterRender(() => { + if (this.canvasRef === undefined) { + throw new Error(' is not found'); + } + + this.backgroundService.attachCanvas(this.canvasRef.nativeElement); + }); + } + + @HostListener('window:resize') + public onResize(): void { + this.backgroundService.setContainerSize(this.desiredCanvasDimensions); + } +} diff --git a/src/app/background/background.service.ts b/src/app/background/background.service.ts new file mode 100644 index 0000000..9d412a8 --- /dev/null +++ b/src/app/background/background.service.ts @@ -0,0 +1,23 @@ +import { Injectable, type OnDestroy } from '@angular/core'; +import type { Dimensions } from '../declarations/dimensions.interface'; +import { SceneContentManager } from './scene/scene-content-manager'; + +@Injectable() +export class BackgroundService implements OnDestroy { + private readonly sceneContentManager: SceneContentManager = new SceneContentManager(); + + public attachCanvas(canvas: HTMLCanvasElement): void { + if (typeof window === 'undefined') { + return; + } + this.sceneContentManager.initialize(canvas); + } + + public setContainerSize(dimensions: Dimensions): void { + this.sceneContentManager.setContainerSize(dimensions); + } + + public ngOnDestroy(): void { + this.sceneContentManager.destroy(); + } +} diff --git a/src/app/background/scene/grid-plane.ts b/src/app/background/scene/grid-plane.ts new file mode 100644 index 0000000..3a8e106 --- /dev/null +++ b/src/app/background/scene/grid-plane.ts @@ -0,0 +1,19 @@ +import { Mesh, PlaneGeometry } from 'three'; +import type { Dimensions } from '../../declarations/dimensions.interface'; +import { GridMaterial } from './resourses/grid.material'; + +export class GridPlane extends Mesh { + constructor(dimensions: Dimensions) { + const material: GridMaterial = new GridMaterial(dimensions); + const geometry: PlaneGeometry = new PlaneGeometry(10, 10); + super(geometry, material); + this.rotation.x = -Math.PI / 3; + this.translateZ(1); + } + + public dispose(): void { + this.removeFromParent(); + this.geometry.dispose(); + this.material.dispose(); + } +} diff --git a/src/app/background/scene/render-loop.ts b/src/app/background/scene/render-loop.ts new file mode 100644 index 0000000..b66db9a --- /dev/null +++ b/src/app/background/scene/render-loop.ts @@ -0,0 +1,77 @@ +import { type Camera, Color, type Scene, WebGLRenderer } from 'three'; +import type { Dimensions } from '../../declarations/dimensions.interface'; + +class NotInitializedError extends Error { + constructor() { + super('WebGLRenderer is not initialized'); + } +} + +class NotDestroyedError extends Error { + constructor() { + super('WebGLRenderer is not destroyed'); + } +} + +export class RenderLoop { + private webGLRenderer: WebGLRenderer | null = null; + + constructor( + private readonly scene: Scene, + private readonly camera: Camera + ) {} + + public initialize(canvas: HTMLCanvasElement): void { + if (this.webGLRenderer !== null) { + throw new NotDestroyedError(); + } + + const webGLRenderer: WebGLRenderer = new WebGLRenderer({ + canvas, + antialias: true + }); + webGLRenderer.setClearColor(new Color(0x000000), 0); + this.webGLRenderer = webGLRenderer; + } + + public destroy(): void { + if (this.webGLRenderer === null) { + return; + } + + this.webGLRenderer.dispose(); + this.webGLRenderer = null; + } + + public setDimensions({ widthPx, heightPx }: Dimensions): void { + if (this.webGLRenderer === null) { + throw new NotInitializedError(); + } + + this.webGLRenderer.setSize(widthPx, heightPx); + } + + public start(onRender?: (timeSinceLastFrameMs?: DOMHighResTimeStamp) => void): void { + if (this.webGLRenderer === null) { + throw new NotInitializedError(); + } + + const webGLRenderer: WebGLRenderer = this.webGLRenderer; + webGLRenderer.setAnimationLoop((timeSinceLastFrameMs: DOMHighResTimeStamp) => { + webGLRenderer.render(this.scene, this.camera); + + if (typeof onRender !== 'function') { + return; + } + onRender(timeSinceLastFrameMs); + }); + } + + public stop(): void { + if (this.webGLRenderer === null) { + throw new NotInitializedError(); + } + + this.webGLRenderer.setAnimationLoop(null); + } +} diff --git a/src/app/background/scene/resourses/grid.material.ts b/src/app/background/scene/resourses/grid.material.ts new file mode 100644 index 0000000..696602e --- /dev/null +++ b/src/app/background/scene/resourses/grid.material.ts @@ -0,0 +1,67 @@ +import { Color, ShaderMaterial, Vector2, type IUniform } from 'three'; +import type { Dimensions } from '../../../declarations/dimensions.interface'; + +const vertexShader: string = ` +varying vec2 vUv; +void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); +} +`; +const fragmentShader: string = ` +uniform float shift; +uniform vec2 dimensions; +uniform float cellSize; +uniform float lineThickness; +uniform vec3 lineColor; +varying vec2 vUv; + +float grid(vec2 uv) { + vec2 scale = dimensions / cellSize; + uv *= scale; + uv.y += shift * scale.y; + + vec2 grid = fract(uv); + float xLine = min(smoothstep(0.0, lineThickness, grid.x), smoothstep(1.0, 1.0 - lineThickness, grid.x)); + float yLine = min(smoothstep(0.0, lineThickness, grid.y), smoothstep(1.0, 1.0 - lineThickness, grid.y)); + + return min(xLine, yLine); +} + +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord.xy / dimensions; + + float g = grid(uv); + fragColor.rgb = mix(lineColor, vec3(0.0), g); + fragColor.a = 1.0; +} + +void main() { + mainImage(gl_FragColor, vUv * dimensions); +} +`; +export class GridMaterial extends ShaderMaterial { + constructor({ widthPx, heightPx }: Dimensions) { + super({ + vertexShader, + fragmentShader, + uniforms: { + shift: { value: 0.0 }, + dimensions: { + value: new Vector2(widthPx, heightPx) + }, + cellSize: { value: 50.0 }, + lineThickness: { value: 0.01 }, + lineColor: { value: new Color(0xff5050) } + } + }); + } + + public nextFrame(): void { + const targetUniform: IUniform = this.uniforms['shift']; + if (typeof targetUniform.value !== 'number') { + throw new Error('Invalid uniform value'); + } + targetUniform.value += 0.000075; + } +} diff --git a/src/app/background/scene/scene-content-manager.ts b/src/app/background/scene/scene-content-manager.ts new file mode 100644 index 0000000..36c1965 --- /dev/null +++ b/src/app/background/scene/scene-content-manager.ts @@ -0,0 +1,62 @@ +import { AmbientLight, PerspectiveCamera, Scene } from 'three'; +import type { Dimensions } from '../../declarations/dimensions.interface'; +import { GridPlane } from './grid-plane'; +import { RenderLoop } from './render-loop'; + +export class SceneContentManager { + private readonly camera: PerspectiveCamera = new PerspectiveCamera(); + private readonly scene: Scene = new Scene(); + private gridPlane: GridPlane | null = null; + + private readonly renderLoop: RenderLoop = new RenderLoop(this.scene, this.camera); + + private isInitialized: boolean = false; + + constructor() { + this.scene.add(this.camera); + this.camera.position.z = 3; + + const light: AmbientLight = new AmbientLight(0xffffff, 1); + this.scene.add(light); + } + + public setContainerSize(dimensions: Dimensions): void { + const { widthPx, heightPx }: Dimensions = dimensions; + this.renderLoop.setDimensions(dimensions); + + this.camera.aspect = widthPx / heightPx; + this.camera.updateProjectionMatrix(); + } + + public initialize(canvas: HTMLCanvasElement): void { + if (this.isInitialized) { + return; + } + + this.renderLoop.initialize(canvas); + const canvasDimensions: Dimensions = { + widthPx: canvas.clientWidth, + heightPx: canvas.clientHeight + }; + this.setContainerSize(canvasDimensions); + + const gridPlane: GridPlane = new GridPlane(canvasDimensions); + + if (this.gridPlane !== null) { + this.gridPlane.dispose(); + } + this.gridPlane = gridPlane; + this.scene.add(gridPlane); + + this.renderLoop.start(() => { + gridPlane.material.nextFrame(); + }); + + this.isInitialized = true; + } + + public destroy(): void { + this.gridPlane?.dispose(); + this.renderLoop.destroy(); + } +} diff --git a/src/app/declarations/dimensions.interface.ts b/src/app/declarations/dimensions.interface.ts new file mode 100644 index 0000000..5b238cb --- /dev/null +++ b/src/app/declarations/dimensions.interface.ts @@ -0,0 +1,4 @@ +export interface Dimensions { + widthPx: number; + heightPx: number; +} diff --git a/src/index.html b/src/index.html index 14fdfcc..51d6298 100644 --- a/src/index.html +++ b/src/index.html @@ -1,5 +1,8 @@ - + Frontend @@ -16,7 +19,7 @@ href="favicon.ico" /> - - + +