diff --git a/package.json b/package.json index 77bd03c..30b44d5 100644 --- a/package.json +++ b/package.json @@ -39,11 +39,13 @@ "sharp": "^0.34.3", "swiper": "^11.2.10", "tailwind-merge": "^3.3.0", - "tailwindcss": "^4.1.8" + "tailwindcss": "^4.1.8", + "three": "^0.180.0" }, "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748", "devDependencies": { "@eslint/js": "9.35.0", + "@types/three": "^0.180.0", "@typescript-eslint/eslint-plugin": "8.37.0", "@typescript-eslint/parser": "8.43.0", "astro-eslint-parser": "1.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c588876..dd3e719 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,10 +86,16 @@ importers: tailwindcss: specifier: ^4.1.8 version: 4.1.8 + three: + specifier: ^0.180.0 + version: 0.180.0 devDependencies: '@eslint/js': specifier: 9.35.0 version: 9.35.0 + '@types/three': + specifier: ^0.180.0 + version: 0.180.0 '@typescript-eslint/eslint-plugin': specifier: 8.37.0 version: 8.37.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.35.0(jiti@2.4.2))(typescript@5.8.3) @@ -261,6 +267,9 @@ packages: resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==} engines: {node: '>=14'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@emnapi/runtime@1.4.5': resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==} @@ -942,6 +951,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -995,12 +1007,21 @@ packages: '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.180.0': + resolution: {integrity: sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==} + '@types/ungap__structured-clone@1.2.0': resolution: {integrity: sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/eslint-plugin@8.37.0': resolution: {integrity: sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1099,6 +1120,9 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@webgpu/types@0.1.65': + resolution: {integrity: sha512-cYrHab4d6wuVvDW5tdsfI6/o6vcLMDe6w2Citd1oS51Xxu2ycLCnVo4fqwujfKWijrZMInTJIKcXxteoy21nVA==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1551,6 +1575,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1956,6 +1983,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@0.22.0: + resolution: {integrity: sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -2471,6 +2501,9 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + three@0.180.0: + resolution: {integrity: sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==} + tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} @@ -3008,6 +3041,8 @@ snapshots: '@ctrl/tinycolor@4.1.0': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@emnapi/runtime@1.4.5': dependencies: tslib: 2.8.1 @@ -3553,6 +3588,8 @@ snapshots: tailwindcss: 4.1.8 vite: 6.3.6(@types/node@22.15.29)(jiti@2.4.2)(lightningcss@1.30.1) + '@tweenjs/tween.js@23.1.3': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.27.4 @@ -3618,10 +3655,24 @@ snapshots: dependencies: '@types/node': 22.15.29 + '@types/stats.js@0.17.4': {} + + '@types/three@0.180.0': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.65 + fflate: 0.8.2 + meshoptimizer: 0.22.0 + '@types/ungap__structured-clone@1.2.0': {} '@types/unist@3.0.3': {} + '@types/webxr@0.5.24': {} + '@typescript-eslint/eslint-plugin@8.37.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.35.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 @@ -3770,6 +3821,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@webgpu/types@0.1.65': {} + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -4323,6 +4376,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -4824,6 +4879,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@0.22.0: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.1.0 @@ -5522,6 +5579,8 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + three@0.180.0: {} + tiny-inflate@1.0.3: {} tinyexec@0.3.2: {} diff --git a/src/components/ASCIIText.tsx b/src/components/ASCIIText.tsx new file mode 100644 index 0000000..0448538 --- /dev/null +++ b/src/components/ASCIIText.tsx @@ -0,0 +1,669 @@ +// Component ported and enhanced from https://codepen.io/JuanFuentes/pen/eYEeoyE + +import { useRef, useEffect } from 'react' +import * as THREE from 'three' + +const vertexShader = ` +varying vec2 vUv; +uniform float uTime; +uniform float mouse; +uniform float uEnableWaves; + +void main() { + vUv = uv; + float time = uTime * 5.; + + float waveFactor = uEnableWaves; + + vec3 transformed = position; + + transformed.x += sin(time + position.y) * 0.5 * waveFactor; + transformed.y += cos(time + position.z) * 0.15 * waveFactor; + transformed.z += sin(time + position.x) * waveFactor; + + gl_Position = projectionMatrix * modelViewMatrix * vec4(transformed, 1.0); +} +` + +const fragmentShader = ` +varying vec2 vUv; +uniform float mouse; +uniform float uTime; +uniform sampler2D uTexture; + +void main() { + float time = uTime; + vec2 pos = vUv; + + float move = sin(time + mouse) * 0.01; + float r = texture2D(uTexture, pos + cos(time * 2. - time + pos.x) * .01).r; + float g = texture2D(uTexture, pos + tan(time * .5 + pos.x - time) * .01).g; + float b = texture2D(uTexture, pos - cos(time * 2. + time + pos.y) * .01).b; + float a = texture2D(uTexture, pos).a; + gl_FragColor = vec4(r, g, b, a); +} +` + +function map( + n: number, + start: number, + stop: number, + start2: number, + stop2: number, +) { + return ((n - start) / (stop - start)) * (stop2 - start2) + start2 +} + +const PX_RATIO = typeof window !== 'undefined' ? window.devicePixelRatio : 1 + +interface AsciiFilterOptions { + fontSize?: number + fontFamily?: string + charset?: string + invert?: boolean +} + +class AsciiFilter { + renderer: THREE.WebGLRenderer + domElement: HTMLDivElement + pre: HTMLPreElement + canvas: HTMLCanvasElement + context: CanvasRenderingContext2D | null + deg: number + invert: boolean + fontSize: number + fontFamily: string + charset: string + width: number = 0 + height: number = 0 + center: { x: number; y: number } = { x: 0, y: 0 } + mouse: { x: number; y: number } = { x: 0, y: 0 } + cols: number = 0 + rows: number = 0 + + constructor( + renderer: THREE.WebGLRenderer, + { fontSize, fontFamily, charset, invert }: AsciiFilterOptions = {}, + ) { + this.renderer = renderer + this.domElement = document.createElement('div') + this.domElement.style.position = 'absolute' + this.domElement.style.top = '0' + this.domElement.style.left = '0' + this.domElement.style.width = '100%' + this.domElement.style.height = '100%' + + this.pre = document.createElement('pre') + this.domElement.appendChild(this.pre) + + this.canvas = document.createElement('canvas') + this.context = this.canvas.getContext('2d') + this.domElement.appendChild(this.canvas) + + this.deg = 0 + this.invert = invert ?? true + this.fontSize = fontSize ?? 12 + this.fontFamily = fontFamily ?? "'Courier New', monospace" + this.charset = + charset ?? + ' .\'`^",:;Il!i~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$' + + if (this.context) { + this.context.imageSmoothingEnabled = false + } + + this.onMouseMove = this.onMouseMove.bind(this) + document.addEventListener('mousemove', this.onMouseMove) + } + + setSize(width: number, height: number) { + this.width = width + this.height = height + this.renderer.setSize(width, height) + this.reset() + + this.center = { x: width / 2, y: height / 2 } + this.mouse = { x: this.center.x, y: this.center.y } + } + + reset() { + if (this.context) { + this.context.font = `${this.fontSize}px ${this.fontFamily}` + const charWidth = this.context.measureText('A').width + + this.cols = Math.floor( + this.width / (this.fontSize * (charWidth / this.fontSize)), + ) + this.rows = Math.floor(this.height / this.fontSize) + + this.canvas.width = this.cols + this.canvas.height = this.rows + this.pre.style.fontFamily = this.fontFamily + this.pre.style.fontSize = `${this.fontSize}px` + this.pre.style.margin = '0' + this.pre.style.padding = '0' + this.pre.style.lineHeight = '1em' + this.pre.style.position = 'absolute' + this.pre.style.left = '50%' + this.pre.style.top = '50%' + this.pre.style.transform = 'translate(-50%, -50%)' + this.pre.style.zIndex = '9' + this.pre.style.backgroundAttachment = 'fixed' + this.pre.style.mixBlendMode = 'difference' + } + } + + render(scene: THREE.Scene, camera: THREE.Camera) { + this.renderer.render(scene, camera) + + const w = this.canvas.width + const h = this.canvas.height + if (this.context) { + this.context.clearRect(0, 0, w, h) + if (this.context && w && h) { + this.context.drawImage(this.renderer.domElement, 0, 0, w, h) + } + + this.asciify(this.context, w, h) + this.hue() + } + } + + onMouseMove(e: MouseEvent) { + this.mouse = { x: e.clientX * PX_RATIO, y: e.clientY * PX_RATIO } + } + + get dx() { + return this.mouse.x - this.center.x + } + + get dy() { + return this.mouse.y - this.center.y + } + + hue() { + const deg = (Math.atan2(this.dy, this.dx) * 180) / Math.PI + this.deg += (deg - this.deg) * 0.075 + this.domElement.style.filter = `hue-rotate(${this.deg.toFixed(1)}deg)` + } + + asciify(ctx: CanvasRenderingContext2D, w: number, h: number) { + if (w && h) { + const imgData = ctx.getImageData(0, 0, w, h).data + let str = '' + for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const i = x * 4 + y * 4 * w + const [r, g, b, a] = [ + imgData[i], + imgData[i + 1], + imgData[i + 2], + imgData[i + 3], + ] + + if (a === 0) { + str += ' ' + continue + } + + let gray = (0.3 * r + 0.6 * g + 0.1 * b) / 255 + let idx = Math.floor((1 - gray) * (this.charset.length - 1)) + if (this.invert) idx = this.charset.length - idx - 1 + str += this.charset[idx] + } + str += '\n' + } + this.pre.innerHTML = str + } + } + + dispose() { + document.removeEventListener('mousemove', this.onMouseMove) + } +} + +interface CanvasTxtOptions { + fontSize?: number + fontFamily?: string + color?: string +} + +class CanvasTxt { + canvas: HTMLCanvasElement + context: CanvasRenderingContext2D | null + txt: string + fontSize: number + fontFamily: string + color: string + font: string + + constructor( + txt: string, + { + fontSize = 200, + fontFamily = 'Arial', + color = '#fdf9f3', + }: CanvasTxtOptions = {}, + ) { + this.canvas = document.createElement('canvas') + this.context = this.canvas.getContext('2d') + this.txt = txt + this.fontSize = fontSize + this.fontFamily = fontFamily + this.color = color + + this.font = `600 ${this.fontSize}px ${this.fontFamily}` + } + + resize() { + if (this.context) { + this.context.font = this.font + const metrics = this.context.measureText(this.txt) + + const textWidth = Math.ceil(metrics.width) + 20 + const textHeight = + Math.ceil( + metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent, + ) + 20 + + this.canvas.width = textWidth + this.canvas.height = textHeight + } + } + + render() { + if (this.context) { + this.context.clearRect(0, 0, this.canvas.width, this.canvas.height) + this.context.fillStyle = this.color + this.context.font = this.font + + const metrics = this.context.measureText(this.txt) + const yPos = 10 + metrics.actualBoundingBoxAscent + + this.context.fillText(this.txt, 10, yPos) + } + } + + get width() { + return this.canvas.width + } + + get height() { + return this.canvas.height + } + + get texture() { + return this.canvas + } +} + +interface CanvAsciiOptions { + text: string + asciiFontSize: number + textFontSize: number + textColor: string + planeBaseHeight: number + enableWaves: boolean +} + +class CanvAscii { + textString: string + asciiFontSize: number + textFontSize: number + textColor: string + planeBaseHeight: number + container: HTMLElement + width: number + height: number + enableWaves: boolean + camera: THREE.PerspectiveCamera + scene: THREE.Scene + mouse: { x: number; y: number } + textCanvas!: CanvasTxt + texture!: THREE.CanvasTexture + geometry!: THREE.PlaneGeometry + material!: THREE.ShaderMaterial + mesh!: THREE.Mesh + renderer!: THREE.WebGLRenderer + filter!: AsciiFilter + center!: { x: number; y: number } + animationFrameId: number = 0 + + constructor( + { + text, + asciiFontSize, + textFontSize, + textColor, + planeBaseHeight, + enableWaves, + }: CanvAsciiOptions, + containerElem: HTMLElement, + width: number, + height: number, + ) { + this.textString = text + this.asciiFontSize = asciiFontSize + this.textFontSize = textFontSize + this.textColor = textColor + this.planeBaseHeight = planeBaseHeight + this.container = containerElem + this.width = width + this.height = height + this.enableWaves = enableWaves + + this.camera = new THREE.PerspectiveCamera( + 45, + this.width / this.height, + 1, + 1000, + ) + this.camera.position.z = 30 + + this.scene = new THREE.Scene() + this.mouse = { x: 0, y: 0 } + + this.onMouseMove = this.onMouseMove.bind(this) + + this.setMesh() + this.setRenderer() + } + + setMesh() { + this.textCanvas = new CanvasTxt(this.textString, { + fontSize: this.textFontSize, + fontFamily: 'IBM Plex Mono', + color: this.textColor, + }) + this.textCanvas.resize() + this.textCanvas.render() + + this.texture = new THREE.CanvasTexture(this.textCanvas.texture) + this.texture.minFilter = THREE.NearestFilter + + const textAspect = this.textCanvas.width / this.textCanvas.height + const baseH = this.planeBaseHeight + const planeW = baseH * textAspect + const planeH = baseH + + this.geometry = new THREE.PlaneGeometry(planeW, planeH, 36, 36) + this.material = new THREE.ShaderMaterial({ + vertexShader, + fragmentShader, + transparent: true, + uniforms: { + uTime: { value: 0 }, + mouse: { value: 1.0 }, + uTexture: { value: this.texture }, + uEnableWaves: { value: this.enableWaves ? 1.0 : 0.0 }, + }, + }) + + this.mesh = new THREE.Mesh(this.geometry, this.material) + this.scene.add(this.mesh) + } + + setRenderer() { + this.renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }) + this.renderer.setPixelRatio(1) + this.renderer.setClearColor(0x000000, 0) + + this.filter = new AsciiFilter(this.renderer, { + fontFamily: 'IBM Plex Mono', + fontSize: this.asciiFontSize, + invert: true, + }) + + this.container.appendChild(this.filter.domElement) + this.setSize(this.width, this.height) + + this.container.addEventListener('mousemove', this.onMouseMove) + this.container.addEventListener('touchmove', this.onMouseMove) + } + + setSize(w: number, h: number) { + this.width = w + this.height = h + + this.camera.aspect = w / h + this.camera.updateProjectionMatrix() + + this.filter.setSize(w, h) + + this.center = { x: w / 2, y: h / 2 } + } + + load() { + this.animate() + } + + onMouseMove(evt: MouseEvent | TouchEvent) { + const e = (evt as TouchEvent).touches + ? (evt as TouchEvent).touches[0] + : (evt as MouseEvent) + const bounds = this.container.getBoundingClientRect() + const x = e.clientX - bounds.left + const y = e.clientY - bounds.top + this.mouse = { x, y } + } + + animate() { + const animateFrame = () => { + this.animationFrameId = requestAnimationFrame(animateFrame) + this.render() + } + animateFrame() + } + + render() { + const time = new Date().getTime() * 0.001 + + this.textCanvas.render() + this.texture.needsUpdate = true + ;(this.mesh.material as THREE.ShaderMaterial).uniforms.uTime.value = + Math.sin(time) + + this.updateRotation() + this.filter.render(this.scene, this.camera) + } + + updateRotation() { + const x = map(this.mouse.y, 0, this.height, 0.5, -0.5) + const y = map(this.mouse.x, 0, this.width, -0.5, 0.5) + + this.mesh.rotation.x += (x - this.mesh.rotation.x) * 0.05 + this.mesh.rotation.y += (y - this.mesh.rotation.y) * 0.05 + } + + clear() { + this.scene.traverse((object) => { + const obj = object as unknown as THREE.Mesh + if (!obj.isMesh) return + ;[obj.material].flat().forEach((material) => { + material.dispose() + Object.keys(material).forEach((key) => { + const matProp = material[key as keyof typeof material] + if ( + matProp && + typeof matProp === 'object' && + 'dispose' in matProp && + typeof matProp.dispose === 'function' + ) { + matProp.dispose() + } + }) + }) + obj.geometry.dispose() + }) + this.scene.clear() + } + + dispose() { + cancelAnimationFrame(this.animationFrameId) + this.filter.dispose() + this.container.removeChild(this.filter.domElement) + this.container.removeEventListener('mousemove', this.onMouseMove) + this.container.removeEventListener('touchmove', this.onMouseMove) + this.clear() + this.renderer.dispose() + } +} + +interface ASCIITextProps { + text?: string + asciiFontSize?: number + textFontSize?: number + textColor?: string + planeBaseHeight?: number + enableWaves?: boolean +} + +export default function ASCIIText({ + text = 'David!', + asciiFontSize = 8, + textFontSize = 200, + textColor = '#fdf9f3', + planeBaseHeight = 8, + enableWaves = true, +}: ASCIITextProps) { + const containerRef = useRef(null) + const asciiRef = useRef(null) + + useEffect(() => { + if (!containerRef.current) return + + const { width, height } = containerRef.current.getBoundingClientRect() + + if (width === 0 || height === 0) { + const observer = new IntersectionObserver( + ([entry]) => { + if ( + entry.isIntersecting && + entry.boundingClientRect.width > 0 && + entry.boundingClientRect.height > 0 + ) { + const { width: w, height: h } = entry.boundingClientRect + + asciiRef.current = new CanvAscii( + { + text, + asciiFontSize, + textFontSize, + textColor, + planeBaseHeight, + enableWaves, + }, + containerRef.current!, + w, + h, + ) + asciiRef.current.load() + + observer.disconnect() + } + }, + { threshold: 0.1 }, + ) + + observer.observe(containerRef.current) + + return () => { + observer.disconnect() + if (asciiRef.current) { + asciiRef.current.dispose() + } + } + } + + asciiRef.current = new CanvAscii( + { + text, + asciiFontSize, + textFontSize, + textColor, + planeBaseHeight, + enableWaves, + }, + containerRef.current, + width, + height, + ) + asciiRef.current.load() + + const ro = new ResizeObserver((entries) => { + if (!entries[0] || !asciiRef.current) return + const { width: w, height: h } = entries[0].contentRect + if (w > 0 && h > 0) { + asciiRef.current.setSize(w, h) + } + }) + ro.observe(containerRef.current) + + return () => { + ro.disconnect() + if (asciiRef.current) { + asciiRef.current.dispose() + } + } + }, [ + text, + asciiFontSize, + textFontSize, + textColor, + planeBaseHeight, + enableWaves, + ]) + + return ( +
+ +
+ ) +} diff --git a/src/pages/404.astro b/src/pages/404.astro new file mode 100644 index 0000000..336a403 --- /dev/null +++ b/src/pages/404.astro @@ -0,0 +1,65 @@ +--- +import Layout from '../Layout.astro' +import Footer from '@components/Footer.astro' +import ASCIIText from '@components/ASCIIText' + +const pageTitle = '404: Page Not Found! - Node.js Design Patterns' +const pageDescription = + "Page not found. Let's get you back on track to explore Node.js Design Patterns." +--- + + +
+ +
+ +
+ + +
+
+ +

+ Page Not Found +

+ +

+ The page you're looking for doesn't exist. Let's get you back to + exploring Node.js patterns. +

+ + + +
+
+
+