Artist Map v2 — Phase E: click ripple shoves nearby bubbles

Clicking (or tapping) the map now drops a water ripple: a hue-tinted ring
expands from the point AND nearby bubbles get shoved radially outward at the
wavefront, then settle back as it passes and decays (_artMapNodeDisplacement —
a gaussian bump at the expanding front, world-space radial push). Ripples emit
from the clicked bubble's centre in its genre hue (or the bare click point),
and still open the artist after a beat. Replaces the old single purple ring.

Note: the physical shove acts on live-layer (zoomed-in) bubbles; at the far-out
overview the ring shows but the tiny baked bubbles don't move. 64 tests pass.
This commit is contained in:
BoulderBadgeDad 2026-06-03 10:16:27 -07:00
parent 33c4eea201
commit cdc1f0bc10

View file

@ -5659,14 +5659,17 @@ function _artMapDrawLiveNode(ctx, n) {
if (sc <= 0.001) return;
const baseMul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul;
_artMap._drawAlphaMul = baseMul * (n.aAlpha == null ? 1 : n.aAlpha);
// Ambient buoyancy (steady state only — the reveal has its own motion).
let bobY = 0;
if (!_artMap._revealing && n._bobAmp) {
bobY = Math.sin((_artMap._now || 0) * 0.0016 + (n._bobPhase || 0)) * n._bobAmp;
// Ambient buoyancy + ripple shove (steady state only — the reveal has its
// own motion). Both are world-space offsets applied about the node centre.
let ox = 0, oy = 0;
if (!_artMap._revealing) {
if (n._bobAmp) oy += Math.sin((_artMap._now || 0) * 0.0016 + (n._bobPhase || 0)) * n._bobAmp;
const disp = _artMapNodeDisplacement(n);
if (disp) { ox += disp.dx; oy += disp.dy; }
}
if (sc !== 1 || bobY) {
if (sc !== 1 || ox || oy) {
ctx.save();
ctx.translate(n.x, n.y + bobY);
ctx.translate(n.x + ox, n.y + oy);
ctx.scale(sc, sc);
ctx.translate(-n.x, -n.y);
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
@ -5807,6 +5810,43 @@ function _artMapDrawRipples(ctx) {
ctx.restore();
}
// Total world-space displacement on a node from all active "push" ripples — the
// expanding wavefront shoves nearby bubbles radially outward, then they settle
// back as the wave passes and decays. Returns null when nothing is pushing.
function _artMapNodeDisplacement(n) {
const rip = _artMap._ripples;
if (!rip || !rip.length) return null;
const t = _artMap._now || performance.now();
let dx = 0, dy = 0;
for (const r of rip) {
if (!r.push) continue;
const p = (t - r.t0) / r.dur;
if (p < 0 || p > 1) continue;
const front = r.maxR * (0.08 + 0.92 * (1 - Math.pow(1 - p, 2)));
const ddx = n.x - r.cx, ddy = n.y - r.cy;
const d = Math.hypot(ddx, ddy) || 1;
const delta = d - front;
const width = r.width || (r.maxR * 0.2);
const env = Math.exp(-(delta * delta) / (2 * width * width)); // bump at the wavefront
const push = r.push * env * (1 - p); // decays over the ripple's life
if (push > 0.05) { dx += (ddx / d) * push; dy += (ddy / d) * push; }
}
return (dx || dy) ? { dx, dy } : null;
}
// Emit a water ripple at a world point — a fading ring plus a radial shove of
// nearby bubbles. Used for click/tap feedback.
function _artMapEmitRipple(wx, wy, hue) {
if (!_artMap._ripples) _artMap._ripples = [];
const WR = _artMap.WATCHLIST_R;
_artMap._ripples.push({
cx: wx, cy: wy, hue: hue == null ? 270 : hue,
maxR: WR * 2.6, t0: performance.now(), dur: 900,
push: WR * 0.22, width: WR * 0.6,
});
_artMapStartLoop(); // animate the ripple (guards against double-start)
}
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.
@ -6017,28 +6057,6 @@ function _artMapDraw() {
ctx.restore();
}
// Click ripple animation
if (_artMap._ripple) {
const rip = _artMap._ripple;
const elapsed = performance.now() - rip.start;
const progress = elapsed / 400; // 400ms duration
if (progress < 1) {
ctx.save();
ctx.translate(_artMap.offsetX, _artMap.offsetY);
ctx.scale(z, z);
const ripR = rip.radius + rip.radius * progress * 0.5;
ctx.beginPath();
ctx.arc(rip.x, rip.y, ripR, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(138,43,226,${0.5 * (1 - progress)})`;
ctx.lineWidth = 3 * (1 - progress);
ctx.stroke();
ctx.restore();
requestAnimationFrame(() => _artMapRender());
} else {
_artMap._ripple = null;
}
}
if (_artMap._perf) _artMapDrawPerf(ctx, _t0);
}
@ -6931,15 +6949,13 @@ function _artMapSetupInteraction(canvas) {
isPanning = false;
if (!wasDrag && clickStart) {
// It was a click — find the node under cursor
// It was a click — emit a water ripple (shoves nearby bubbles) and,
// if it landed on an artist, open it after the ripple kicks off.
const { nx, ny } = _artMapScreenToWorld(e, canvas);
const node = _artMapHitTest(nx, ny);
if (node) {
_artMap._ripple = { x: node.x, y: node.y, radius: node.radius, start: performance.now() };
_artMapRender();
if (node.spotify_id || node.itunes_id || node.deezer_id) {
setTimeout(() => openYourArtistInfoModal_direct(node), 200);
}
_artMapEmitRipple(node ? node.x : nx, node ? node.y : ny, node ? node._hue : null);
if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) {
setTimeout(() => openYourArtistInfoModal_direct(node), 200);
}
}
@ -7001,6 +7017,7 @@ function _artMapSetupInteraction(canvas) {
const wx = (t.clientX - rect.left - _artMap.offsetX) / _artMap.zoom;
const wy = (t.clientY - rect.top - _artMap.offsetY) / _artMap.zoom;
const node = _artMapHitTest(wx, wy);
_artMapEmitRipple(node ? node.x : wx, node ? node.y : wy, node ? node._hue : null);
if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) {
openYourArtistInfoModal_direct(node);
}