Files
Void-Homelab/public/views/cards/blackflame.js
root 600057582e feat(sacred-valley): hybrid free/snap canvas + blank & blackflame cards (2.8.0)
Replace masonry grid with an absolute-positioned 12-col canvas: drag to
move, corner to resize, per-card free/overlap toggle (Alt = no-snap).
Geometry persisted (migration 027: dashboard_layout.geom + extras).
Two new addable decorative cards: blank spacer + animated blackflame
(canvas particle flame). Old layout auto-migrates by flow-placement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 22:33:45 +10:00

115 lines
4.5 KiB
JavaScript

// Decorative blackflame card — the animated centrepiece (dark heart, crimson
// licks, rising embers). Canvas particle flame ported from the approved mock.
// Instanceable via the factory; stop() tears down the rAF loop + observer.
import { el } from '../../dom.js';
const TAU = Math.PI * 2;
function startFlame(canvas) {
const ctx = canvas.getContext('2d');
let W = 0, H = 0, DPR = 1, raf = 0, t = 0;
function resize() {
DPR = Math.min(2, window.devicePixelRatio || 1);
const r = canvas.getBoundingClientRect();
W = r.width; H = r.height;
canvas.width = Math.max(1, Math.round(W * DPR));
canvas.height = Math.max(1, Math.round(H * DPR));
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
}
const tongues = Array.from({ length: 5 }, (_, i) => ({
phase: Math.random() * TAU, speed: 0.6 + Math.random() * 0.5,
x: 0.5 + (i - 2) * 0.085, sway: 0.03 + Math.random() * 0.04, w: 0.34 - Math.abs(i - 2) * 0.05
}));
const spawn = () => ({
x: 0.5 + (Math.random() - 0.5) * 0.5, y: 1 + Math.random() * 0.2,
vy: 0.0016 + Math.random() * 0.0030, vx: (Math.random() - 0.5) * 0.0012,
r: 0.6 + Math.random() * 1.8, life: 0, max: 120 + Math.random() * 120, hot: Math.random() < 0.5
});
const embers = Array.from({ length: 46 }, spawn);
function blob(x, y, r, c0, c1) {
if (r <= 0) return;
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0, c0); g.addColorStop(1, c1);
ctx.fillStyle = g; ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fill();
}
function frame() {
t += 0.016;
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, W, H);
ctx.globalCompositeOperation = 'lighter';
const baseY = H * 0.92;
for (const tg of tongues) {
const cx = W * (tg.x + Math.sin(t * tg.speed + tg.phase) * tg.sway);
const height = H * (0.62 + 0.08 * Math.sin(t * 1.7 + tg.phase));
const steps = 26;
for (let s = 0; s < steps; s++) {
const f = s / steps;
const y = baseY - f * height;
const flick = Math.sin(t * 3.2 + tg.phase + f * 7) * W * 0.012 * (0.4 + f);
const x = cx + flick + Math.sin(t * tg.speed * 1.3 + f * 4) * W * tg.sway * 0.6;
const r = W * tg.w * (1 - f * 0.78) * (0.9 + 0.1 * Math.sin(t * 5 + f * 9));
const a = (1 - f) * 0.5;
if (f < 0.18) blob(x, y, r * 1.1, `rgba(60,12,6,${a * 0.7})`, 'rgba(60,12,6,0)');
else if (f < 0.62) blob(x, y, r, `rgba(255,79,46,${a * 0.5})`, 'rgba(122,39,22,0)');
else blob(x, y, r * 0.8, `rgba(255,150,90,${a * 0.5})`, 'rgba(255,79,46,0)');
}
}
for (const e of embers) {
e.life++; e.y -= e.vy; e.x += e.vx + Math.sin(t * 2 + e.y * 8) * 0.0004;
if (e.y < 0.18 || e.life > e.max) Object.assign(e, spawn());
const px = e.x * W, py = e.y * H, fade = 1 - e.life / e.max;
const col = e.hot ? `rgba(255,170,110,${fade * 0.9})` : `rgba(255,79,46,${fade * 0.8})`;
ctx.shadowBlur = 8; ctx.shadowColor = '#ff4f2e';
blob(px, py, e.r * 1.6, col, 'rgba(255,79,46,0)');
}
ctx.shadowBlur = 0;
// carve the dark heart back in (the "black" of black-flame)
ctx.globalCompositeOperation = 'source-over';
const cx = W * 0.5, cy = H * 0.46, cr = W * 0.20;
const core = ctx.createRadialGradient(cx, cy, 0, cx, cy, cr);
core.addColorStop(0, 'rgba(4,4,8,0.96)');
core.addColorStop(0.55, 'rgba(6,5,10,0.7)');
core.addColorStop(1, 'rgba(8,7,12,0)');
ctx.fillStyle = core; ctx.beginPath(); ctx.arc(cx, cy, cr, 0, TAU); ctx.fill();
ctx.globalCompositeOperation = 'lighter';
blob(W * 0.5, H * 0.95, W * 0.4, 'rgba(255,79,46,0.10)', 'rgba(255,79,46,0)');
raf = requestAnimationFrame(frame);
}
resize();
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null;
ro?.observe(canvas);
raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); ro?.disconnect(); };
}
export function blackflameCard(id) {
let teardown = null;
return {
id,
type: 'blackflame',
title: '',
decorative: true,
size: 'l',
mount(body) {
const canvas = el('canvas', { class: 'sv-flame-canvas' });
const crest = el('div', { class: 'sv-flame-crest' });
const label = el('div', { class: 'sv-flame-label' }, 'The Void');
body.append(canvas, crest, label);
// defer one frame so the canvas has a measured size
requestAnimationFrame(() => { teardown = startFlame(canvas); });
},
start() {},
stop() { teardown && teardown(); teardown = null; }
};
}