feat(ui): add reactive oklch ascii shader background
This commit is contained in:
191
shop/components/AsciiShaderBackground.tsx
Normal file
191
shop/components/AsciiShaderBackground.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
|
||||
interface OKLCHColor {
|
||||
l: number
|
||||
c: number
|
||||
h: number
|
||||
}
|
||||
|
||||
// Monochromes Farbkonzept im OKLCH-Raum
|
||||
const BASE_BG = { l: 0.145, c: 0.01, h: 148 }
|
||||
const BASE_DIM = { l: 0.50, c: 0.01, h: 148 }
|
||||
const HIGHLIGHT_BRIGHT = { l: 0.95, c: 0.01, h: 148 }
|
||||
|
||||
function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t
|
||||
}
|
||||
|
||||
function lerpOKLCH(c1: OKLCHColor, c2: OKLCHColor, t: number): OKLCHColor {
|
||||
return {
|
||||
l: lerp(c1.l, c2.l, t),
|
||||
c: lerp(c1.c, c2.c, t),
|
||||
h: lerp(c1.h, c2.h, t)
|
||||
}
|
||||
}
|
||||
|
||||
export function AsciiShaderBackground({ className = '' }: { className?: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
let animationFrameId: number
|
||||
|
||||
// Minimalistisches, ruhiges ASCII-Array
|
||||
const chars = ['•', '∘', '+', '▪', '▫', ' ']
|
||||
const fontSize = 18
|
||||
let cols = 0
|
||||
let rows = 0
|
||||
|
||||
const mouse = { x: 0, y: 0, targetX: 0, targetY: 0 }
|
||||
let scrollProgress = 0
|
||||
let targetScrollProgress = 0
|
||||
|
||||
const resize = () => {
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
cols = Math.ceil(width / fontSize)
|
||||
rows = Math.ceil(height / fontSize)
|
||||
|
||||
if (mouse.x === 0 && mouse.y === 0) {
|
||||
mouse.x = width / 2
|
||||
mouse.y = height / 2
|
||||
mouse.targetX = width / 2
|
||||
mouse.targetY = height / 2
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
mouse.targetX = e.clientX
|
||||
mouse.targetY = e.clientY
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
const maxScroll = Math.max(
|
||||
document.body.scrollHeight - window.innerHeight,
|
||||
1
|
||||
)
|
||||
targetScrollProgress = Math.min(Math.max(window.scrollY / maxScroll, 0), 1)
|
||||
}
|
||||
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
|
||||
const render = (time: number) => {
|
||||
mouse.x += (mouse.targetX - mouse.x) * 0.05
|
||||
mouse.y += (mouse.targetY - mouse.y) * 0.05
|
||||
scrollProgress += (targetScrollProgress - scrollProgress) * 0.05
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Beim Scrollen sofort ausblenden (vollständig weg ab 150px Scroll-Tiefe)
|
||||
const scrollFade = Math.max(1 - (window.scrollY / 150), 0)
|
||||
if (scrollFade <= 0) {
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
return
|
||||
}
|
||||
|
||||
// Scroll-gesteuerter Dimmer
|
||||
const scrollDimFactor = (1 - scrollProgress * 0.4) * scrollFade
|
||||
const currentDimColor = {
|
||||
...BASE_DIM,
|
||||
l: BASE_DIM.l * scrollDimFactor
|
||||
}
|
||||
|
||||
ctx.font = `${fontSize}px monospace`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
const timeSec = time * 0.001
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const x = c * fontSize + fontSize / 2
|
||||
const y = r * fontSize + fontSize / 2
|
||||
|
||||
// Abstandsvektor zur gedämpften Mausposition
|
||||
const dx = x - mouse.x
|
||||
const dy = y - mouse.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
// Maus-Störung (Vektorfeld-Ablenkung): Phase & Amplitude lokal beeinflussen
|
||||
const mouseInfluence = Math.exp(-dist / 250)
|
||||
const mouseDistortion = (Math.atan2(dy, dx) + (dx + dy) * 0.003) * mouseInfluence * 3.0
|
||||
|
||||
// Diagonale 2D-Strömung / Wellen-Synthese aus Sinus & Kosinus
|
||||
const waveX = Math.sin(c * 0.08 + timeSec * 0.8 + mouseDistortion)
|
||||
const waveY = Math.cos(r * 0.08 + timeSec * 0.6 + mouseDistortion)
|
||||
const flowValue = Math.sin(waveX + waveY + (c + r) * 0.04 + timeSec * 0.4)
|
||||
|
||||
// Zeichenauswahl basierend auf Strömungsfeld
|
||||
const normalizedFlow = (flowValue + 1) * 0.5 // Range 0..1
|
||||
const charIndex = Math.floor(normalizedFlow * chars.length) % chars.length
|
||||
const char = chars[charIndex]
|
||||
|
||||
// Leerzeichen für ruhiges Raster überspringen
|
||||
if (char === ' ') continue
|
||||
|
||||
// Helligkeits-Highlighting durch Maus-Störung und Wellenkamm
|
||||
const blendFactor = Math.min(Math.max(mouseInfluence * 0.85 + normalizedFlow * 0.15, 0), 1)
|
||||
const currentColor = lerpOKLCH(currentDimColor, HIGHLIGHT_BRIGHT, blendFactor)
|
||||
|
||||
// Stärkerer Kontrast: min 0.35, max 0.95 Deckkraft
|
||||
const alpha = (0.35 + blendFactor * 0.6) * scrollDimFactor
|
||||
|
||||
ctx.fillStyle = `oklch(${currentColor.l.toFixed(3)} ${currentColor.c.toFixed(3)} ${currentColor.h.toFixed(1)} / ${alpha.toFixed(2)})`
|
||||
ctx.fillText(char, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(render)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute top-0 left-0 right-0 h-[500px] z-0 overflow-hidden bg-[oklch(0.145_0.01_148)] pointer-events-none ${className}`}
|
||||
style={{
|
||||
maskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)',
|
||||
WebkitMaskImage: 'linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,1) 250px, rgba(0,0,0,0) 500px)'
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 w-full h-full block will-change-transform pointer-events-none"
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 w-full h-full pointer-events-none opacity-60"
|
||||
style={{
|
||||
backgroundSize: '40px 40px',
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, oklch(0.31 0.012 148 / 0.4) 1px, transparent 1px)
|
||||
`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user