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>
260 lines
10 KiB
React
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" />;
|
|
}
|