Files
lanspread/design/design_reference/logo/pixel-live.jsx
T
2026-06-20 19:49:42 +02:00

177 lines
6.7 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// pixel-live.jsx — the LIVE animated pixel "S" for SoftLAN.
// At rest it's the baseline concept-5 S. On hover (and at random idle moments)
// it "comes alive": slithers like the game Snake across the grid then reforms,
// or glitches with an RGB datamosh. Exposes play(trick) via ref.
//
// Grid geometry matches the baseline MarkPixel exactly so the swap is seamless.
const { useState, useEffect, useRef, useImperativeHandle, forwardRef } = React;
// ── shared grid geometry (identical to baseline MarkPixel) ──
const LCELL = 17, LOFF = 7.5, LGAP = 2.4, LR = 3;
const LSIZE = LCELL - LGAP; // 14.6
// the "S" as an ORDERED walk (tail → head). Each step is grid-adjacent.
// head = (0,4) bottom-left, tail = (4,0) top-right.
const SORD = [
[4,0],[3,0],[2,0],[1,0],[0,0], // top bar (R→L)
[0,1], // left drop
[0,2],[1,2],[2,2],[3,2],[4,2], // middle bar (L→R)
[4,3], // right drop
[4,4],[3,4],[2,4],[1,4],[0,4], // bottom bar (R→L)
];
// boustrophedon sweep of the whole 5×5 board, (0,4) → (4,0) — the Snake "playfield".
const BOUS = (() => {
const b = [];
for (let col = 0; col < 5; col++) {
const rows = col % 2 === 0 ? [4,3,2,1,0] : [0,1,2,3,4];
rows.forEach(r => b.push([col, r]));
}
return b; // 25 cells, b[0]=(0,4), b[24]=(4,0)
})();
// full head path P: lay the S, sweep the board, re-lay the S.
const PATH = (() => {
const p = SORD.map(c => c.slice()); // 0..16 (full S)
for (let i = 1; i < BOUS.length; i++) p.push(BOUS[i].slice()); // 17..40
for (let i = 1; i < SORD.length; i++) p.push(SORD[i].slice()); // 41..56
return p; // length 57
})();
const I0 = 16, I1 = PATH.length - 1; // animate head index 16 → 56
// snake length schedule: full S → short snake → full S
function lenAt(i) {
if (i <= 16) return 17;
if (i <= 26) return Math.round(17 + (7 - 17) * ((i - 16) / 10));
if (i <= 40) return 7;
if (i <= 56) return Math.round(7 + (17 - 7) * ((i - 40) / 16));
return 17;
}
// hex → rgb mixed toward white(t>0)/black(t<0)
function lshade(hex, t) {
let h = (hex || '#000').replace('#', '');
if (h.length === 3) h = h.split('').map(x => x + x).join('');
let r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16);
const tgt = t >= 0 ? 255 : 0, a = Math.min(1, Math.abs(t));
r = Math.round(r+(tgt-r)*a); g = Math.round(g+(tgt-g)*a); b = Math.round(b+(tgt-b)*a);
return `rgb(${r},${g},${b})`;
}
function px(col, row) { return { x: LOFF + col * LCELL + LGAP / 2, y: LOFF + row * LCELL + LGAP / 2 }; }
function Cell({ col, row, fill, opacity = 1, dx = 0, dy = 0, w = LSIZE, h = LSIZE, rx = LR }) {
const { x, y } = px(col, row);
return <rect x={x + dx} y={y + dy} width={w} height={h} rx={rx} fill={fill} opacity={opacity} />;
}
// ── renderers ──
function renderStatic(accent) {
return SORD.map(([c, r], i) => <Cell key={i} col={c} row={r} fill={accent} />);
}
function renderSnake(p, accent) {
const i = Math.min(I1, Math.max(I0, I0 + Math.round(p * (I1 - I0))));
const L = lenAt(i);
const start = Math.max(0, i - L + 1);
const body = PATH.slice(start, i + 1);
const n = body.length;
return body.map((cell, j) => {
const t = n > 1 ? j / (n - 1) : 1; // 0 tail → 1 head
const fill = lshade(accent, (t - 0.45) * 0.5); // head brighter, tail darker
const opacity = 0.42 + 0.58 * t;
const isHead = j === n - 1;
const grow = isHead ? 1.06 : 1;
const s = LSIZE * grow, d = (LSIZE - s) / 2;
return <Cell key={j} col={cell[0]} row={cell[1]} fill={fill} opacity={opacity} dx={d} dy={d} w={s} h={s} />;
});
}
const GLX = [11, -7, 13, -5, 9]; // per-row horizontal kick
function renderGlitch(p, accent) {
const burst = Math.max(0, 1 - p) * (0.45 + 0.55 * Math.abs(Math.sin(p * 26)));
return SORD.map(([c, r], i) => {
const dx = GLX[r] * burst;
const drop = burst > 0.55 && Math.sin(i * 12.9 + p * 50) > 0.82; // occasional dropout
const flick = 0.7 + 0.3 * Math.abs(Math.sin(p * 70 + i));
return <Cell key={i} col={c} row={r} fill={accent} opacity={drop ? 0.12 : flick} dx={dx} />;
});
}
function renderRGB(p, accent) {
const burst = Math.pow(Math.max(0, 1 - p), 1.25);
const jit = Math.sin(p * 52) * 0.5 + Math.sin(p * 31) * 0.5;
const off = (4.5 + 2.5 * jit) * burst;
const voff = 1.6 * jit * burst;
const layers = [
{ fill: '#ff2d55', dx: -off, dy: -voff, op: 0.8 },
{ fill: '#21e3a8', dx: off, dy: voff, op: 0.8 },
{ fill: accent, dx: 0, dy: 0, op: 1 },
];
return layers.flatMap((ly, li) =>
SORD.map(([c, r], i) => (
<g key={li + '-' + i} style={{ mixBlendMode: li < 2 ? 'screen' : 'normal' }}>
<Cell col={c} row={r} fill={ly.fill} opacity={ly.op} dx={ly.dx} dy={ly.dy} />
</g>
))
);
}
const RENDER = { snake: renderSnake, glitch: renderGlitch, rgb: renderRGB };
const TRICK_DUR = { snake: 2600, glitch: 1100, rgb: 1350 };
const POOL = ['snake', 'snake', 'snake', 'rgb', 'glitch']; // snake-weighted
function randomTrick() { return POOL[Math.floor(Math.random() * POOL.length)]; }
const LiveLogo = forwardRef(function LiveLogo(
{ accent = '#3b82f6', size = 140, idleAuto = true, idleMin = 5000, idleMax = 11000 }, ref
) {
const [anim, setAnim] = useState(null); // {trick, p} | null
const rafRef = useRef(0);
const playingRef = useRef(false);
const play = (trick) => {
if (playingRef.current) return;
playingRef.current = true;
const dur = TRICK_DUR[trick] || 2000;
const t0 = performance.now();
const step = (now) => {
const p = (now - t0) / dur;
if (p >= 1) { setAnim(null); playingRef.current = false; return; }
setAnim({ trick, p });
rafRef.current = requestAnimationFrame(step);
};
setAnim({ trick, p: 0 });
rafRef.current = requestAnimationFrame(step);
};
useImperativeHandle(ref, () => ({ play, isPlaying: () => playingRef.current }));
useEffect(() => {
if (!idleAuto) return;
let timer;
const schedule = () => {
timer = setTimeout(() => {
if (!document.hidden && !playingRef.current) play(randomTrick());
schedule();
}, idleMin + Math.random() * (idleMax - idleMin));
};
schedule();
return () => clearTimeout(timer);
}, [idleAuto, idleMin, idleMax]);
useEffect(() => () => cancelAnimationFrame(rafRef.current), []);
const content = anim ? RENDER[anim.trick](anim.p, accent) : renderStatic(accent);
return (
<svg viewBox="0 0 100 100" width={size} height={size} fill="none"
style={{ cursor: 'pointer', overflow: 'visible' }}
onMouseEnter={() => play(randomTrick())}
onClick={() => play('snake')}>
{content}
</svg>
);
});
Object.assign(window, { LiveLogo, renderSnake, renderGlitch, renderRGB, renderStatic });