/ src / scripts / page-background.ts
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();