250 lines
7.3 KiB
Plaintext
250 lines
7.3 KiB
Plaintext
---
|
|
// AsciiShaderBackground.astro
|
|
interface Props {
|
|
className?: string;
|
|
}
|
|
|
|
const { className = "" } = Astro.props;
|
|
---
|
|
|
|
<div class={`ascii-shader-container ${className}`}>
|
|
<canvas id="ascii-shader-canvas"></canvas>
|
|
<div class="grid-overlay"></div>
|
|
</div>
|
|
|
|
<style>
|
|
.ascii-shader-container {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100vw;
|
|
height: 100vh;
|
|
z-index: -1;
|
|
overflow: hidden;
|
|
background-color: oklch(0.145 0.01 148);
|
|
pointer-events: none;
|
|
}
|
|
|
|
#ascii-shader-canvas {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
will-change: transform;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.grid-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-size: 40px 40px;
|
|
background-image:
|
|
linear-gradient(to right, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px),
|
|
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.3) 1px, transparent 1px);
|
|
pointer-events: none;
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
// OKLCH Palette definition
|
|
interface OKLCHColor {
|
|
l: number;
|
|
c: number;
|
|
h: number;
|
|
}
|
|
|
|
// Palette defining color stops based on scroll progress
|
|
const PALETTE = {
|
|
yellow: { l: 0.88, c: 0.18, h: 96 },
|
|
green: { l: 0.76, c: 0.15, h: 155 },
|
|
blue: { l: 0.68, c: 0.18, h: 255 },
|
|
purple: { l: 0.64, c: 0.18, h: 302 },
|
|
pink: { l: 0.72, c: 0.20, h: 345 }
|
|
};
|
|
|
|
// Helper for shortest angle interpolation in OKLCH hue space
|
|
function interpolateHue(h1: number, h2: number, t: number): number {
|
|
let diff = (h2 - h1) % 360;
|
|
if (diff > 180) diff -= 360;
|
|
if (diff < -180) diff += 360;
|
|
return (h1 + diff * t + 360) % 360;
|
|
}
|
|
|
|
// Interpolate two OKLCH colors
|
|
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
|
|
return {
|
|
l: c1.l + (c2.l - c1.l) * t,
|
|
c: c1.c + (c2.c - c1.c) * t,
|
|
h: interpolateHue(c1.h, c2.h, t)
|
|
};
|
|
}
|
|
|
|
// Calculate dynamic colors based on scroll progress (0.0 to 1.0)
|
|
function getScrollColors(progress: number): { primary: OKLCHColor; secondary: OKLCHColor } {
|
|
if (progress < 0.33) {
|
|
// Hero to stage 1: Full spectrum (Pink -> Yellow)
|
|
const t = progress / 0.33;
|
|
return {
|
|
primary: lerpOKLCH(PALETTE.pink, PALETTE.yellow, t),
|
|
secondary: lerpOKLCH(PALETTE.purple, PALETTE.green, t)
|
|
};
|
|
} else if (progress < 0.66) {
|
|
// Stage 1 to stage 2: Yellow/Cream to Green
|
|
const t = (progress - 0.33) / 0.33;
|
|
return {
|
|
primary: lerpOKLCH(PALETTE.yellow, PALETTE.green, t),
|
|
secondary: lerpOKLCH(PALETTE.green, PALETTE.blue, t)
|
|
};
|
|
} else {
|
|
// Stage 2 to end: Green/Blue focus
|
|
const t = (progress - 0.66) / 0.34;
|
|
return {
|
|
primary: lerpOKLCH(PALETTE.green, PALETTE.blue, t),
|
|
secondary: lerpOKLCH(PALETTE.blue, PALETTE.purple, t)
|
|
};
|
|
}
|
|
}
|
|
|
|
class AsciiShaderEngine {
|
|
private canvas: HTMLCanvasElement;
|
|
private ctx: CanvasRenderingContext2D;
|
|
private mouse = { x: 0, y: 0, targetX: 0, targetY: 0 };
|
|
private scrollProgress = 0;
|
|
private targetScrollProgress = 0;
|
|
private chars = ['/', '\\', '-', '|', '+', '='];
|
|
private fontSize = 16;
|
|
private cols = 0;
|
|
private rows = 0;
|
|
private animationFrameId = 0;
|
|
|
|
constructor(canvas: HTMLCanvasElement) {
|
|
this.canvas = canvas;
|
|
const context = canvas.getContext('2d');
|
|
if (!context) throw new Error('Could not get 2D context');
|
|
this.ctx = context;
|
|
|
|
this.init();
|
|
}
|
|
|
|
private init(): void {
|
|
this.resize();
|
|
this.bindEvents();
|
|
this.render();
|
|
}
|
|
|
|
private resize = (): void => {
|
|
const width = window.innerWidth;
|
|
const height = window.innerHeight;
|
|
|
|
const dpr = window.devicePixelRatio || 1;
|
|
this.canvas.width = width * dpr;
|
|
this.canvas.height = height * dpr;
|
|
|
|
this.ctx.scale(dpr, dpr);
|
|
|
|
this.cols = Math.ceil(width / this.fontSize);
|
|
this.rows = Math.ceil(height / this.fontSize);
|
|
|
|
if (this.mouse.x === 0 && this.mouse.y === 0) {
|
|
this.mouse.x = width / 2;
|
|
this.mouse.y = height / 2;
|
|
this.mouse.targetX = width / 2;
|
|
this.mouse.targetY = height / 2;
|
|
}
|
|
};
|
|
|
|
private bindEvents(): void {
|
|
window.addEventListener('resize', this.resize);
|
|
|
|
window.addEventListener('pointermove', (e: PointerEvent) => {
|
|
this.mouse.targetX = e.clientX;
|
|
this.mouse.targetY = e.clientY;
|
|
});
|
|
|
|
window.addEventListener('scroll', () => {
|
|
const maxScroll = Math.max(
|
|
document.body.scrollHeight - window.innerHeight,
|
|
1
|
|
);
|
|
this.targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1);
|
|
}, { passive: true });
|
|
}
|
|
|
|
private render = (time: number = 0): void => {
|
|
// Dampened mouse easing (lerp factor 0.05)
|
|
this.mouse.x += (this.mouse.targetX - this.mouse.x) * 0.05;
|
|
this.mouse.y += (this.mouse.targetY - this.mouse.y) * 0.05;
|
|
|
|
// Dampened scroll progress easing
|
|
this.scrollProgress += (this.targetScrollProgress - this.scrollProgress) * 0.05;
|
|
|
|
// Clear canvas
|
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
|
|
// Get current OKLCH interpolated colors
|
|
const { primary, secondary } = getScrollColors(this.scrollProgress);
|
|
|
|
this.ctx.font = `${this.fontSize}px monospace`;
|
|
this.ctx.textAlign = 'center';
|
|
this.ctx.textBaseline = 'middle';
|
|
|
|
const timeSec = time * 0.001;
|
|
|
|
for (let r = 0; r < this.rows; r++) {
|
|
for (let c = 0; c < this.cols; c++) {
|
|
const x = c * this.fontSize + this.fontSize / 2;
|
|
const y = r * this.fontSize + this.fontSize / 2;
|
|
|
|
// Vector field calculations based on distance to cursor & wave function
|
|
const dx = x - this.mouse.x;
|
|
const dy = y - this.mouse.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Dynamic angle field
|
|
const angle = Math.atan2(dy, dx) + Math.sin(dist * 0.01 - timeSec) * 0.5;
|
|
const charIndex = Math.floor(Math.abs(Math.sin(angle + dist * 0.005)) * this.chars.length) % this.chars.length;
|
|
const char = this.chars[charIndex];
|
|
|
|
// Color blend based on vector field intensity
|
|
const blendFactor = Math.min(Math.max(1 - dist / 400, 0), 1);
|
|
const currentColor = lerpOKLCH(secondary, primary, blendFactor);
|
|
|
|
// Render character with OKLCH styling
|
|
this.ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)})`;
|
|
this.ctx.fillText(char, x, y);
|
|
}
|
|
}
|
|
|
|
this.animationFrameId = requestAnimationFrame(this.render);
|
|
};
|
|
|
|
public destroy(): void {
|
|
window.removeEventListener('resize', this.resize);
|
|
cancelAnimationFrame(this.animationFrameId);
|
|
}
|
|
}
|
|
|
|
// Lifecycle handler for Astro / SPA transitions
|
|
function initShader() {
|
|
const canvas = document.getElementById('ascii-shader-canvas') as HTMLCanvasElement;
|
|
if (canvas) {
|
|
new AsciiShaderEngine(canvas);
|
|
}
|
|
}
|
|
|
|
// Initial load
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initShader);
|
|
} else {
|
|
initShader();
|
|
}
|
|
|
|
// Astro page swap support
|
|
document.addEventListener('astro:page-load', initShader);
|
|
</script>
|