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>
115 lines
4.5 KiB
JavaScript
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; }
|
|
};
|
|
}
|