page-background.ts
1 import { AstroError } from "astro/errors"; 2 3 interface LetterPosition { 4 x: number; 5 y: number; 6 letter: string; 7 } 8 9 interface LetterInstance extends LetterPosition { 10 timestamp: number; 11 fadeout: number; 12 } 13 14 /** 15 * PageBackground class 16 */ 17 class PageBackground { 18 private LETTER_FADE_DURATION: [number, number] = [2, 7]; // Seconds 19 20 private baseCanvas: HTMLCanvasElement; 21 private overlayCanvas: HTMLCanvasElement; 22 23 private baseCtx: CanvasRenderingContext2D; 24 private overlayCtx: CanvasRenderingContext2D; 25 26 private width: number = window.innerWidth; 27 private height: number = window.innerHeight; 28 29 private letterPositions: LetterPosition[] = []; 30 private letterInstances: LetterInstance[] = []; 31 32 private primaryRgb: string; 33 34 /** 35 * Initializes the background on the page. 36 * @param baseCanvas - The base canvas element. Used for static letters. 37 * @param overlayCanvas - The overlay canvas element. Used for animated letters. 38 */ 39 constructor(baseCanvas: HTMLCanvasElement, overlayCanvas: HTMLCanvasElement) { 40 // Get 2D context for both canvases 41 const baseCtx = baseCanvas.getContext("2d"); 42 const overlayCtx = overlayCanvas.getContext("2d"); 43 44 // If either context is null, throw an error 45 if (!baseCtx || !overlayCtx) { 46 throw new AstroError("Unable to get 2D context."); 47 } 48 49 this.baseCanvas = baseCanvas; 50 this.overlayCanvas = overlayCanvas; 51 this.baseCtx = baseCtx; 52 this.overlayCtx = overlayCtx; 53 54 baseCanvas.width = this.width; 55 baseCanvas.height = this.height; 56 57 overlayCanvas.width = this.width; 58 overlayCanvas.height = this.height; 59 60 // Set the primary color to the first color in the theme 61 this.primaryRgb = window 62 .getComputedStyle(document.documentElement) 63 .getPropertyValue("--primary-rgb") 64 .trim(); 65 66 this.initBackground(); 67 68 requestAnimationFrame(this.redrawBackground); 69 } 70 71 /** 72 * Sets up the background canvases. The text is decided based on the title of the page. 73 */ 74 private initBackground = () => { 75 let text: string = 76 document.title.toLowerCase().split(" | ")[0].replace(/\s/g, "_") || 77 "kshell"; 78 79 // Add additional underscore to separate words 80 if (text.includes("_")) { 81 text += "_"; 82 } 83 84 // Letters are 17px wide and 35px tall 85 const letters = Math.ceil(this.width / 17); 86 const lines = Math.ceil(this.height / 35); 87 88 // Loop through the canvas and draw the text 89 this.baseCtx.font = "28px JetBrains Mono"; 90 this.baseCtx.textAlign = "start"; 91 this.baseCtx.textBaseline = "top"; 92 this.baseCtx.fillStyle = "rgba(255, 255, 255, 0.01)"; 93 94 for (let i = 0; i < lines; i++) { 95 for (let j = 0; j < letters; j++) { 96 this.baseCtx.fillText(text[j % text.length], j * 17, i * 35); 97 this.letterPositions.push({ 98 x: j * 17, 99 y: i * 35, 100 letter: text[j % text.length], 101 }); 102 } 103 } 104 105 // Randomly select 75% of the letters to animate 106 const randomLetters = this.getRandomAmountFromArray<LetterPosition>( 107 this.letterPositions, 108 Number.parseInt((lines * 0.75).toFixed(), 10), 109 ); 110 111 this.overlayCtx.font = "bold 28px JetBrains Mono"; 112 this.overlayCtx.textAlign = "start"; 113 this.overlayCtx.textBaseline = "top"; 114 this.overlayCtx.fillStyle = `rgba(${this.primaryRgb}, 0)`; 115 this.overlayCtx.shadowBlur = 16; 116 this.overlayCtx.shadowColor = `rgba(${this.primaryRgb}, 0)`; 117 118 // Draw the letters on the overlay canvas 119 for (const letter of randomLetters) { 120 this.overlayCtx.fillText(letter.letter, letter.x, letter.y); 121 122 // Some number between LETTER_FADE_DURATION[0] and LETTER_FADE_DURATION[1] (in seconds) 123 const animLength = 124 this.LETTER_FADE_DURATION[0] + 125 Math.random() * 126 (this.LETTER_FADE_DURATION[1] - this.LETTER_FADE_DURATION[0]); 127 128 this.letterInstances.push({ 129 x: letter.x, 130 y: letter.y, 131 letter: letter.letter, 132 timestamp: Date.now(), 133 fadeout: Date.now() + animLength * 1000, 134 }); 135 } 136 137 // Make the base canvas visible 138 this.baseCanvas.style.opacity = "1"; 139 }; 140 141 /** 142 * Simple sine easing function. Used for fading in and out letters. 143 * @param timestamp - The current timestamp. 144 * @param start - The start timestamp of a letter. 145 * @param end - The end timestamp of a letter. 146 */ 147 private easeInOutSine = (timestamp: number, start: number, end: number) => { 148 const totalDuration = end - start; 149 150 // If the current timestamp is before the start, return 0 151 if (timestamp < start) { 152 return 0; 153 } 154 155 // If the current timestamp is after the end, return 0 156 if (timestamp > end) { 157 const elapsedAfterEnd = timestamp - end; 158 const progressAfterEnd = elapsedAfterEnd / (totalDuration / 2); 159 160 return Math.sin(progressAfterEnd * Math.PI); 161 } 162 163 const progress = (timestamp - start) / totalDuration; 164 165 return Math.max(0, 0.5 - 0.5 * Math.cos(progress * Math.PI)); 166 }; 167 168 /** 169 * Grabs n random elements from an array. 170 * @param arr - The array to grab elements from. 171 * @param n - The number of elements to grab. 172 * @returns - An array of n elements. 173 */ 174 private getRandomAmountFromArray = <T>(arr: Array<T>, n = 20): Array<T> => { 175 let len = arr.length; 176 177 // Initialize arrays beforehand 178 const result = new Array(n); 179 const taken = new Array(len); 180 181 if (n > len) { 182 throw new AstroError( 183 "getRandomAmountFromArray: more elements taken than available", 184 ); 185 } 186 187 while (n--) { 188 const x = Math.floor(Math.random() * len); 189 result[n] = arr[x in taken ? taken[x] : x]; 190 taken[x] = --len in taken ? taken[len] : len; 191 } 192 193 return result; 194 }; 195 196 /** 197 * Redraws the overlay canvas and animates the letters. 198 */ 199 private redrawBackground = () => { 200 // Clear the overlay canvas 201 this.overlayCtx.clearRect( 202 0, 203 0, 204 this.overlayCanvas.width, 205 this.overlayCanvas.height, 206 ); 207 208 this.overlayCtx.font = "bold 28px JetBrains Mono"; 209 this.overlayCtx.textAlign = "start"; 210 this.overlayCtx.textBaseline = "top"; 211 this.overlayCtx.shadowBlur = 16; 212 213 for (const letter of this.letterInstances) { 214 if (letter.fadeout > Date.now()) continue; 215 216 const alpha = this.easeInOutSine( 217 Date.now(), 218 letter.timestamp, 219 letter.fadeout, 220 ); 221 222 if (alpha <= 0 && Date.now() > letter.fadeout) { 223 this.letterInstances.splice(this.letterInstances.indexOf(letter), 1); 224 const randomLetter = this.getRandomAmountFromArray<LetterPosition>( 225 this.letterPositions, 226 1, 227 ); 228 229 this.letterInstances.push({ 230 x: randomLetter[0].x, 231 y: randomLetter[0].y, 232 letter: randomLetter[0].letter, 233 timestamp: Date.now(), 234 fadeout: 235 Date.now() + 236 (this.LETTER_FADE_DURATION[0] + 237 Math.random() * 238 (this.LETTER_FADE_DURATION[1] - this.LETTER_FADE_DURATION[0])) * 239 1000, 240 }); 241 } 242 243 this.overlayCtx.fillStyle = `rgba(${this.primaryRgb}, ${alpha})`; 244 this.overlayCtx.shadowColor = `rgba(${this.primaryRgb}, ${alpha})`; 245 this.overlayCtx.fillText(letter.letter, letter.x, letter.y); 246 } 247 248 requestAnimationFrame(this.redrawBackground); 249 }; 250 251 /** 252 * Resizes the background canvases. 253 */ 254 public resizeBackground = () => { 255 this.width = window.innerWidth; 256 this.height = window.innerHeight; 257 258 this.baseCanvas.width = this.width; 259 this.baseCanvas.height = this.height; 260 261 this.overlayCanvas.width = this.width; 262 this.overlayCanvas.height = this.height; 263 264 this.baseCtx.clearRect(0, 0, this.baseCanvas.width, this.baseCanvas.height); 265 this.overlayCtx.clearRect( 266 0, 267 0, 268 this.overlayCanvas.width, 269 this.overlayCanvas.height, 270 ); 271 272 this.letterInstances = []; 273 this.letterPositions = []; 274 275 this.initBackground(); 276 }; 277 } 278 279 /** 280 * Loads the JetBrains Mono font. We have to do this asynchronously because the font is not preloaded. 281 */ 282 async function loadFont() { 283 const font = new FontFace( 284 "JetBrains Mono", 285 "url(/fonts/jetbrains-mono.woff2)", 286 ); 287 288 await font.load(); 289 290 document.fonts.add(font); 291 } 292 293 /** 294 * First loads the JetBrains Mono font, then initializes the background. 295 */ 296 async function initializeBackground() { 297 await loadFont(); 298 299 const canvas = document.getElementById("bg-canvas") as HTMLCanvasElement; 300 const overlayCanvas = document.getElementById( 301 "overlay-canvas", 302 ) as HTMLCanvasElement; 303 304 const background = new PageBackground(canvas, overlayCanvas); 305 306 window.addEventListener("resize", () => { 307 background.resizeBackground(); 308 }); 309 } 310 311 initializeBackground();