Files
p42.f-w/frontend/src/components/BlackwallField.jsx
T
guyg1474andClaude Opus 5.5 dd58b3d1f8 P42 Dashboard: erster Stand
FastAPI-Backend und React-Frontend mit Konten, Sendungsverfolgung,
Projekt-Artikeln, Vorrat & Einkauf mit Rezeptabgleich, Reise-Weltkarte,
Kanban-Projekten mit Priorität, Server-Monitoring und den Designs
Matrix und Blackwall. Betrieb per Docker hinter nginx (siehe DEPLOY.md).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:08:11 +02:00

260 lines
10 KiB
React

import { useEffect, useRef, useState } from 'react';
import { isCalm, onMotionChange } from '../motion.js';
// Hintergrund "Blackwall" nach Einzelbildern aus dem Referenzvideo (Cyberpunk 2077):
// - Wand-Korridor: senkrechte Lichtlinien, die perspektivisch auf einen Fluchtpunkt am Horizont zulaufen
// (am Rand hoch, zur Mitte flach); Daten fließen sichtbar an ihnen herab
// - Boden: Feld aus Lichtpunkten in Perspektive, das langsam auf den Betrachter zufließt
// - Horizont: gleißendes Band mit wanderndem Hotspot, davor eine zerfaserte Skyline aus Blöcken
// - Zeichenregen nur an den Rändern, seltener Glitch (Zeilenversatz, kein Helligkeitsblitz)
// Farben: Pink-Magenta mit vereinzelten violett-blauen Linien. Ohne Animation: ein Standbild.
const FPS = 24;
const GLITCH_EVERY = [4500, 9000];
const GLITCH_FOR = 160;
const GLYPHS = '0123456789ABCDEF#$%&*+<>/\\|';
const PINK = [255, 32, 92];
const HOT = [255, 110, 160];
const VIOLET = [140, 110, 255];
const hash = (n) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x);
};
const rgba = ([r, g, b], a) => `rgba(${r},${g},${b},${Math.max(0, Math.min(1, a))})`;
export default function BlackwallField() {
const ref = useRef(null);
const [calm, setCalm] = useState(isCalm);
useEffect(() => onMotionChange(() => setCalm(isCalm())), []);
useEffect(() => {
const canvas = ref.current;
const ctx = canvas.getContext('2d');
let w = 0;
let h = 0;
let dpr = 1;
let lines = [];
let blocks = [];
let columns = [];
let frame = 0;
let last = 0;
let nextGlitch = performance.now() + 3500;
let glitchUntil = 0;
function build() {
const cx = w / 2;
// Wandlinien: dicht (alle ~3 px), Höhe nach Perspektive
lines = Array.from({ length: Math.round(w / 3) }, (_, i) => {
const x = hash(i + 1) * w;
const persp = Math.min(1, Math.abs(x - cx) / (w / 2)); // 0 Mitte … 1 Rand
const tint = hash(i + 7);
return {
x,
width: hash(i + 2) > 0.9 ? 2 : 1,
up: h * (0.03 + 0.46 * persp ** 1.25) * (0.55 + 0.45 * hash(i + 3)), // über dem Horizont
down: h * (0.01 + 0.16 * persp ** 1.4) * (0.4 + 0.6 * hash(i + 4)), // unter dem Horizont
alpha: 0.08 + hash(i + 5) ** 2 * 0.55,
flow: 30 + hash(i + 6) * 90, // px/s Datenfluss
phase: hash(i + 8) * 10,
color: tint > 0.92 ? VIOLET : tint > 0.78 ? HOT : PINK,
};
});
// Zerfaserte Skyline am Horizont, zur Mitte gehäuft
blocks = Array.from({ length: Math.round(w / 12) }, (_, i) => {
const x = w * (0.5 + (hash(i + 101) - 0.5) * (0.4 + hash(i + 107) * 0.95));
return {
x,
bw: 8 + hash(i + 103) ** 2 * w * 0.08,
bh: 6 + hash(i + 104) ** 1.5 * h * 0.12,
lift: hash(i + 102) * 0.85, // wie weit der Block über den Horizont ragt
alpha: 0.05 + hash(i + 105) * 0.26,
drift: (hash(i + 106) - 0.5) * 5,
};
});
// Zeichenspalten nur an den Rändern (nur auf breiten Bildschirmen)
columns = [];
if (w > 700) {
const step = 18;
for (let x = 6; x < w; x += step) {
if (x > w * 0.14 && x < w * 0.86) continue;
const i = columns.length;
if (hash(i + 301) < 0.45) continue;
columns.push({ x, speed: 40 + hash(i + 302) * 90, offset: hash(i + 303) * h * 2, len: 8 + Math.floor(hash(i + 304) * 14), seed: i });
}
}
}
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
w = window.innerWidth;
h = window.innerHeight;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
build();
draw(performance.now());
}
function draw(now) {
const t = calm ? 7 : now / 1000;
const cy = h * 0.52; // Horizont
const cx = w * 0.5;
const hotX = cx + Math.sin(t * 0.13) * w * 0.16; // wandernder Lichtfleck
const breath = 0.86 + 0.1 * Math.sin(t * 0.7) + 0.04 * Math.sin(t * 1.9 + 1.3);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = '#040002';
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = 'lighter';
// 1) Dunst um den Horizont
let g = ctx.createLinearGradient(0, cy - h * 0.4, 0, cy + h * 0.3);
g.addColorStop(0, rgba(PINK, 0));
g.addColorStop(0.58, rgba(PINK, 0.26 * breath));
g.addColorStop(1, rgba(PINK, 0));
ctx.fillStyle = g;
ctx.fillRect(0, cy - h * 0.4, w, h * 0.7);
// 2) Boden: Lichtpunkte in Perspektive, fließen langsam heran
const rows = 26;
const flow = (t * 0.08) % 1;
for (let r = 0; r < rows; r++) {
const d = (r + flow) / rows; // 0 Horizont … 1 vorne
const y = cy + (h - cy) * d ** 2.1;
const spacing = 5 + 46 * d ** 1.6;
const size = 0.8 + 2.4 * d;
const a = (0.06 + 0.4 * d) * breath;
ctx.fillStyle = rgba(PINK, a);
const startK = -Math.ceil(cx / spacing);
for (let k = startK; k * spacing + cx < w; k++) {
const x = cx + k * spacing + ((r % 2) * spacing) / 2;
const twinkle = hash(k * 13.1 + r * 7.7);
if (twinkle < 0.18) continue;
ctx.fillRect(x, y, size, size);
}
}
// 3) Wandlinien mit herabfließenden Daten
for (const l of lines) {
const top = cy - l.up;
const len = l.up + l.down;
const shimmer = 0.7 + 0.3 * Math.sin(t * 1.3 + l.phase);
ctx.fillStyle = rgba(l.color, l.alpha * 0.55 * shimmer);
ctx.fillRect(l.x, top, l.width, len);
// heller Datenpaket-Abschnitt, der nach unten wandert
const seg = Math.max(6, len * 0.22);
const pos = ((t * l.flow + l.phase * 100) % (len + seg)) - seg;
ctx.fillStyle = rgba(l.color === PINK ? HOT : l.color, l.alpha * 1.4);
ctx.fillRect(l.x, top + Math.max(0, pos), l.width, Math.min(seg, len - Math.max(0, pos)));
}
// 4) Zerfaserte Skyline (Blöcke ragen aus dem Band, tropfen nach unten)
for (const b of blocks) {
const x = (b.x + t * b.drift + w) % (w + b.bw) - b.bw * 0.5;
const dist = Math.min(1, Math.abs(x + b.bw / 2 - hotX) / (w * 0.5));
const hot = (1 - dist) ** 2;
const a = b.alpha * (0.4 + 1 * hot) * breath;
const y = cy - b.bh * b.lift;
const bg = ctx.createLinearGradient(0, y, 0, y + b.bh);
bg.addColorStop(0, rgba(PINK, a * 0.25));
bg.addColorStop(0.6, rgba([255, 60 + 140 * hot, 120 + 100 * hot], a));
bg.addColorStop(1, rgba(PINK, a * 0.1));
ctx.fillStyle = bg;
ctx.fillRect(x, y, b.bw, b.bh);
}
// 5) Lichtband: Bloom, Band, gleißender Kern; Hotspot wandert
ctx.save();
ctx.translate(hotX, cy);
ctx.scale(1, 0.15);
let rg = ctx.createRadialGradient(0, 0, 0, 0, 0, w * 0.5);
rg.addColorStop(0, rgba(HOT, 0.6 * breath));
rg.addColorStop(0.45, rgba(PINK, 0.22 * breath));
rg.addColorStop(1, rgba(PINK, 0));
ctx.fillStyle = rg;
ctx.fillRect(-w * 1.5, -h * 4, w * 3, h * 8);
ctx.restore();
g = ctx.createLinearGradient(0, cy - 10, 0, cy + 10);
g.addColorStop(0, rgba(PINK, 0));
g.addColorStop(0.5, rgba(PINK, 0.55 * breath));
g.addColorStop(1, rgba(PINK, 0));
ctx.fillStyle = g;
ctx.fillRect(0, cy - 10, w, 20);
ctx.save();
ctx.translate(hotX, cy);
ctx.scale(1, 0.006);
rg = ctx.createRadialGradient(0, 0, 0, 0, 0, w * 0.55);
rg.addColorStop(0, rgba([255, 245, 250], 1 * breath));
rg.addColorStop(0.4, rgba(HOT, 0.8 * breath));
rg.addColorStop(1, rgba(PINK, 0.15 * breath));
ctx.fillStyle = rg;
ctx.fillRect(-w * 1.5, -h * 100, w * 3, h * 200);
ctx.restore();
// 6) Zeichenregen an den Rändern
if (columns.length) {
ctx.font = '13px ui-monospace, monospace';
ctx.textBaseline = 'top';
for (const c of columns) {
const head = ((c.offset + t * c.speed) % (h + c.len * 16)) - c.len * 16;
for (let k = 0; k < c.len; k++) {
const y = head - k * 16;
if (y < -16 || y > h) continue;
const ch = GLYPHS[Math.floor(hash(c.seed * 31 + k * 7 + Math.floor(t * 3) * (k === 0 ? 1 : 0)) * GLYPHS.length)];
ctx.fillStyle = k === 0 ? 'rgba(255,200,220,0.55)' : rgba(PINK, 0.32 * (1 - k / c.len));
ctx.fillText(ch, c.x, y);
}
}
}
// 7) Oben und unten ins Schwarz (dort stehen Titel und Fußzeile)
ctx.globalCompositeOperation = 'source-over';
g = ctx.createLinearGradient(0, 0, 0, h);
g.addColorStop(0, 'rgba(4,0,2,0.95)');
g.addColorStop(0.22, 'rgba(4,0,2,0.5)');
g.addColorStop(0.42, 'rgba(4,0,2,0.04)');
g.addColorStop(0.62, 'rgba(4,0,2,0.04)');
g.addColorStop(0.85, 'rgba(4,0,2,0.5)');
g.addColorStop(1, 'rgba(4,0,2,0.95)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, w, h);
// 8) Glitch: Streifen seitlich versetzt
if (!calm && now < glitchUntil) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
for (let b = 0; b < 5; b++) {
const sy = Math.floor((0.25 + hash(now * 0.0007 + b) * 0.5) * canvas.height);
const sh = Math.floor((4 + hash(b + now * 0.0013) * 30) * dpr);
const shift = Math.floor((hash(b * 3 + now * 0.0021) - 0.5) * 120 * dpr);
ctx.drawImage(canvas, 0, sy, canvas.width, sh, shift, sy, canvas.width, sh);
}
}
}
function loop(now) {
frame = requestAnimationFrame(loop);
if (document.hidden || now - last < 1000 / FPS) return;
last = now;
if (now > nextGlitch) {
glitchUntil = now + GLITCH_FOR;
nextGlitch = now + GLITCH_EVERY[0] + Math.random() * (GLITCH_EVERY[1] - GLITCH_EVERY[0]);
}
draw(now);
}
resize();
window.addEventListener('resize', resize);
if (!calm) frame = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener('resize', resize);
};
}, [calm]);
return <canvas ref={ref} className="theme-bg blackwall-field" aria-hidden="true" />;
}