Artist Map v2 — Phase C: ripple-bloom reveal

Islands now bloom in like drops on water instead of a flat fade:
  - Each island reveals in turn (staggered by island order); within an island,
    bubbles fade + scale (0.55→1, ease-out) outward from the centre by radial
    distance — a drop-in-water bloom. Genre titles fade in just after.
  - A hue-tinted water ripple ring expands from each island centre as it blooms
    (_artMapDrawRipples — reused by click ripples in Phase E).
  - During the reveal the static buffer is bypassed so EVERY bubble can animate
    (live layer, cap 2200); when the bloom ends it bakes into the buffer once and
    steady-state returns to the cheap two-layer path.
  - aAlpha folds into the global draw-alpha multiplier so fades compose cleanly.
64 JS integrity tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-03 09:52:38 -07:00
parent f311ff0440
commit 9cce7810ea

View file

@ -5585,13 +5585,16 @@ function _artMapDrawLiveLayer(ctx) {
ctx.save();
ctx.translate(ox, oy);
ctx.scale(z, z);
// The live layer fades in together with the far field during the reveal.
_artMap._drawAlphaMul = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha;
_artMap._drawAlphaMul = 1;
// During the reveal the buffer is bypassed, so the live layer draws EVERY
// bubble (and the genre titles) so they can all bloom. Otherwise it draws
// only the big/near ones; the rest live in the static buffer.
const revealing = _artMap._revealing;
let drawn = 0;
const CAP = 600;
const CAP = revealing ? 2200 : 600;
for (const n of placed) {
if (!_artMapIsLiveSize(n)) continue;
if (!revealing && !_artMapIsLiveSize(n)) continue;
if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue;
// Viewport cull (screen space)
const sx = ox + n.x * z, sy = oy + n.y * z;
@ -5606,11 +5609,14 @@ function _artMapDrawLiveLayer(ctx) {
}
// Draw one live bubble with its animation transform. aScale scales about the
// node centre; opacity fades it. Reuses the shared node painter so the live
// bubble is pixel-identical to its baked form once settled.
// node centre; aAlpha fades it (folded into the global draw-alpha multiplier).
// Reuses the shared node painter so the bubble is identical to its baked form
// once settled.
function _artMapDrawLiveNode(ctx, n) {
const sc = n.aScale == null ? 1 : n.aScale;
if (sc <= 0.001) return;
const baseMul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul;
_artMap._drawAlphaMul = baseMul * (n.aAlpha == null ? 1 : n.aAlpha);
if (sc !== 1) {
ctx.save();
ctx.translate(n.x, n.y);
@ -5621,6 +5627,7 @@ function _artMapDrawLiveNode(ctx, n) {
} else {
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
}
_artMap._drawAlphaMul = baseMul;
ctx.globalAlpha = 1;
}
@ -5648,29 +5655,99 @@ function _artMapStartLoop() {
}
// Advance every active animation by absolute time t (ms). Returns true while
// anything is still moving. Phase A: a clean global fade-in (ease-out-cubic).
// The bubbly per-island ripple bloom is layered on in Phase C.
// anything is still moving. Phase C: each bubble scales+fades in (ease-out)
// once past its staggered start, and a water ripple expands from each island.
function _artMapStepAnimations(t) {
let active = false;
if (_artMap._fieldAlpha < 1) {
const FIELD_MS = 600;
const p = Math.min(1, (t - _artMap._revealT0) / FIELD_MS);
_artMap._fieldAlpha = 1 - Math.pow(1 - p, 3); // ease-out-cubic
if (p < 1) active = true;
const placed = _artMap.placed;
if (placed) {
for (const n of placed) {
if (n.aScale == null || n.aScale >= 1) continue;
if (t < n._revealAt) { active = true; continue; }
const p = Math.min(1, (t - n._revealAt) / (n._revealDur || 480));
const e = 1 - Math.pow(1 - p, 3); // ease-out-cubic
if (p >= 1) { n.aScale = 1; n.aAlpha = 1; }
else { n.aScale = 0.55 + 0.45 * e; n.aAlpha = e; active = true; }
}
}
const rip = _artMap._ripples;
if (rip && rip.length) {
let anyAlive = false;
for (const r of rip) if (t < r.t0 + r.dur) anyAlive = true;
if (anyAlive) active = true; else _artMap._ripples = [];
}
// When the bloom finishes, leave reveal mode and bake everything into the
// static buffer (one rebuild) so steady-state goes back to the cheap path.
if (!active && _artMap._revealing) {
_artMap._revealing = false;
_artMap.dirty = true;
return true; // one more frame to do the rebuild + final blit
}
return active;
}
// Kick off the reveal: a clean fade-in of the whole map (far field via the
// buffer-blit alpha, live bubbles via _drawAlphaMul — they ramp together).
// Kick off the ripple-bloom reveal. Each island blooms in turn (staggered by
// island order); within an island, bubbles fade+scale outward from the centre
// like a drop hitting water, and a ripple ring expands from each island centre.
// During the reveal the whole map renders on the live layer (the static buffer
// is bypassed) so every bubble can animate; it bakes into the buffer at the end.
function _artMapBeginReveal() {
_artMap._revealT0 = performance.now();
_artMap._fieldAlpha = 0;
const t0 = performance.now();
_artMap._revealT0 = t0;
_artMap._revealing = true;
_artMap._fieldAlpha = 1; // buffer is bypassed while revealing; live layer draws all
const islands = _artMap._islands || [];
const islByName = {};
islands.forEach((isl, i) => { isl._order = i; islByName[isl.name] = isl; });
const ISL_STAGGER = 145, RADIAL_MS = 430, NODE_DUR = 470;
for (const n of _artMap.placed) {
n.aScale = 0; n.aAlpha = 0;
const isl = islByName[n._island] || (n._isLabel ? islByName[n.name] : null);
const order = isl ? isl._order : 0;
let radial = 0;
if (isl && isl.r > 0) radial = Math.min(1, Math.hypot(n.x - isl.cx, n.y - isl.cy) / isl.r);
n._revealAt = t0 + order * ISL_STAGGER + radial * RADIAL_MS + (n._isLabel ? 90 : 0);
n._revealDur = NODE_DUR;
}
_artMap._ripples = islands.map(isl => ({
cx: isl.cx, cy: isl.cy, hue: isl.hue,
maxR: isl.r * 1.45, t0: t0 + isl._order * ISL_STAGGER, dur: 1150,
}));
_artMapStartLoop();
}
// Draw the expanding water-ripple rings (reveal + click). Cheap stroked arcs,
// hue-tinted, fading as they grow. Drawn in world space with a screen-constant
// line width.
function _artMapDrawRipples(ctx) {
const rip = _artMap._ripples;
if (!rip || !rip.length) return;
const t = performance.now();
const z = _artMap.zoom;
ctx.save();
ctx.translate(_artMap.offsetX, _artMap.offsetY);
ctx.scale(z, z);
for (const r of rip) {
const p = (t - r.t0) / r.dur;
if (p < 0 || p > 1) continue;
const radius = r.maxR * (0.08 + 0.92 * (1 - Math.pow(1 - p, 2)));
const alpha = (1 - p) * 0.55;
ctx.beginPath();
ctx.arc(r.cx, r.cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = `hsla(${r.hue},85%,72%,${alpha})`;
ctx.lineWidth = (1.5 + 6 * (1 - p)) / z; // ~constant on screen
ctx.stroke();
}
ctx.restore();
}
function _artMapRender() {
// v2 perf: coalesce every render request into a single rAF, so a burst of
// mousemove/pan/animation calls never draws more than once per frame.
@ -5704,38 +5781,39 @@ function _artMapDraw() {
ctx.fillStyle = _artMap._bgGrad;
ctx.fillRect(0, 0, w, h);
if (_artMap.dirty || !_artMap.offscreen) {
const _rt = _artMap._perf ? performance.now() : 0;
_artMapRebuildBuffer();
if (_artMap._perf) _artMap._rebuildMs = performance.now() - _rt;
}
if (!_artMap.offscreen) { if (_artMap._perf) _artMapDrawPerf(ctx, _t0); return; }
const oc = _artMap.offscreen;
const s = _artMap._bufferScale;
const mx = _artMap._bufferMinX;
const my = _artMap._bufferMinY;
const z = _artMap.zoom;
// Blit offscreen buffer: the buffer was drawn with scale(s) + translate(-minX,-minY)
// So buffer pixel (bx,by) corresponds to world (bx/s + minX, by/s + minY)
// Screen position of world (wx,wy) = offsetX + wx*zoom, offsetY + wy*zoom
// Therefore buffer origin on screen = offsetX + minX*zoom, offsetY + minY*zoom
// And buffer is drawn at size (bufferWidth * zoom/s, bufferHeight * zoom/s)
const fieldAlpha = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha;
if (fieldAlpha < 0.999) ctx.globalAlpha = fieldAlpha;
ctx.drawImage(oc,
_artMap.offsetX + mx * z,
_artMap.offsetY + my * z,
oc.width * z / s,
oc.height * z / s
);
ctx.globalAlpha = 1;
// While the ripple-bloom reveal is running, bypass the static buffer
// entirely and let the live layer draw every bubble (so each can animate).
// The buffer is (re)built once when the reveal ends.
if (!_artMap._revealing) {
if (_artMap.dirty || !_artMap.offscreen) {
const _rt = _artMap._perf ? performance.now() : 0;
_artMapRebuildBuffer();
if (_artMap._perf) _artMap._rebuildMs = performance.now() - _rt;
}
if (_artMap.offscreen) {
const oc = _artMap.offscreen;
const s = _artMap._bufferScale;
const mx = _artMap._bufferMinX;
const my = _artMap._bufferMinY;
// Blit offscreen buffer (built with scale(s) + translate(-minX,-minY)).
const fieldAlpha = _artMap._fieldAlpha == null ? 1 : _artMap._fieldAlpha;
if (fieldAlpha < 0.999) ctx.globalAlpha = fieldAlpha;
ctx.drawImage(oc,
_artMap.offsetX + mx * z,
_artMap.offsetY + my * z,
oc.width * z / s,
oc.height * z / s
);
ctx.globalAlpha = 1;
}
}
// ── Live overlay layer: redraw the big/near bubbles every frame so they can
// scale (reveal/pop), bob and ripple. These are excluded from the static
// buffer above, so there's no double-draw. Viewport-culled + capped. ──
// ── Live overlay layer: big/near bubbles every frame (during the reveal,
// ALL bubbles) so they can scale/bob/ripple. Viewport-culled + capped. ──
_artMapDrawLiveLayer(ctx);
_artMapDrawRipples(ctx);
// ── Interactive overlay (drawn on main canvas, not buffer) ──
const cFade = _artMap._constellationFade || 0;