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:
parent
f311ff0440
commit
9cce7810ea
1 changed files with 123 additions and 45 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue