Artist Map v2 — Phase D: ambient buoyancy + glassy bubbles + tighter islands
- Ambient bob: bubbles gently float (sine offset, phase varies by position so
they move in a wave, not in unison). Driven by a persistent rAF loop that runs
only while bubbles are on screen + the tab is visible, and parks when zoomed
out (_liveCount==0) or the map closes — so an idle overview costs nothing.
- Glassy specular highlight (cached sprite, cheap drawImage per bubble) so
bubbles read as glossy orbs at every size.
- Tighter island spacing (water gap 7*nodeR → 3.5*nodeR) so the settled
overview is more substantial, not thin-spread — addresses the 'mini version'
feel after the reveal ripples fade.
- Ambient resumes on zoom and on tab re-focus; stops cleanly on close.
64 JS integrity tests pass.
This commit is contained in:
parent
9cce7810ea
commit
33c4eea201
1 changed files with 71 additions and 5 deletions
|
|
@ -5033,6 +5033,32 @@ const _artMap = {
|
|||
// donut hole), with a floating genre title above it and a per-genre accent hue.
|
||||
// Islands are spread by a golden spiral + push-apart with generous spacing.
|
||||
|
||||
// A cached circular "gloss" sprite — a soft top-left specular highlight that,
|
||||
// drawn over each bubble, makes it read as a glassy orb. One radial gradient,
|
||||
// rendered once; per-bubble it's just a cheap drawImage (no per-frame gradient).
|
||||
function _artMapGlossSprite() {
|
||||
if (_artMap._gloss) return _artMap._gloss;
|
||||
const S = 128;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = S; c.height = S;
|
||||
const cx = c.getContext('2d');
|
||||
cx.beginPath(); cx.arc(S / 2, S / 2, S / 2, 0, Math.PI * 2); cx.clip();
|
||||
const g = cx.createRadialGradient(S * 0.34, S * 0.28, S * 0.02, S * 0.5, S * 0.5, S * 0.62);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.40)');
|
||||
g.addColorStop(0.32, 'rgba(255,255,255,0.10)');
|
||||
g.addColorStop(0.6, 'rgba(255,255,255,0.0)');
|
||||
cx.fillStyle = g;
|
||||
cx.fillRect(0, 0, S, S);
|
||||
// A faint inner-bottom shade for roundness
|
||||
const g2 = cx.createRadialGradient(S * 0.5, S * 0.78, S * 0.05, S * 0.5, S * 0.5, S * 0.7);
|
||||
g2.addColorStop(0, 'rgba(0,0,0,0.18)');
|
||||
g2.addColorStop(0.5, 'rgba(0,0,0,0.0)');
|
||||
cx.fillStyle = g2;
|
||||
cx.fillRect(0, 0, S, S);
|
||||
_artMap._gloss = c;
|
||||
return c;
|
||||
}
|
||||
|
||||
// Deterministic hue (0–360) from a genre name, so each island has a stable tint.
|
||||
function _artMapGenreHue(name) {
|
||||
let h = 0;
|
||||
|
|
@ -5119,7 +5145,7 @@ function _artMapLayoutIslands(groups, opts = {}) {
|
|||
for (let j = i + 1; j < islands.length; j++) {
|
||||
const dx = islands[j].cx - islands[i].cx, dy = islands[j].cy - islands[i].cy;
|
||||
const dist = Math.hypot(dx, dy) || 1;
|
||||
const minD = islands[i].islandR + islands[j].islandR + nodeR * 7;
|
||||
const minD = islands[i].islandR + islands[j].islandR + nodeR * 3.5;
|
||||
if (dist < minD) {
|
||||
const push = (minD - dist) / 2 + 1;
|
||||
islands[i].cx -= dx / dist * push; islands[i].cy -= dy / dist * push;
|
||||
|
|
@ -5150,6 +5176,10 @@ function _artMapLayoutIslands(groups, opts = {}) {
|
|||
deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '',
|
||||
musicbrainz_id: n.musicbrainz_id || '',
|
||||
popularity: n.popularity || 0, _hue: isl.hue, _island: isl.name,
|
||||
// Ambient buoyancy — phase varies by position so bubbles bob in a
|
||||
// gentle wave (not in unison); amplitude in world units.
|
||||
_bobPhase: (isl.cx + p.dx + isl.cy + p.dy) * 0.0022,
|
||||
_bobAmp: nodeR * 0.12,
|
||||
});
|
||||
}
|
||||
_artMap._islands.push({ name: isl.name, cx: isl.cx, cy: isl.cy, r: isl.islandR, hue: isl.hue, count: isl.count });
|
||||
|
|
@ -5338,6 +5368,10 @@ function closeArtistMap() {
|
|||
const sidebar = document.getElementById('artmap-genre-sidebar');
|
||||
if (sidebar) sidebar.style.display = 'none';
|
||||
if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame);
|
||||
// Stop the ambient buoyancy loop so it doesn't run with the map hidden.
|
||||
_artMap._ambient = false;
|
||||
_artMap._anim.running = false;
|
||||
if (_artMap._anim.raf) { cancelAnimationFrame(_artMap._anim.raf); _artMap._anim.raf = null; }
|
||||
if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler);
|
||||
_artMapHideContextMenu();
|
||||
|
||||
|
|
@ -5499,6 +5533,11 @@ function _artMapDrawNodeToBuffer(octx, n, scale) {
|
|||
octx.fill();
|
||||
}
|
||||
|
||||
// Glassy specular highlight (orb look) — skip the tiniest where it'd be noise.
|
||||
if (rScaled >= 5) {
|
||||
octx.drawImage(_artMapGlossSprite(), n.x - r, n.y - r, r * 2, r * 2);
|
||||
}
|
||||
|
||||
const showLabel = rScaled >= 13;
|
||||
|
||||
// Darken art behind the label so the name stays legible.
|
||||
|
|
@ -5606,6 +5645,9 @@ function _artMapDrawLiveLayer(ctx) {
|
|||
_artMap._drawAlphaMul = 1;
|
||||
ctx.restore();
|
||||
ctx.globalAlpha = 1;
|
||||
// Count of non-label bubbles drawn live — drives whether the ambient loop
|
||||
// keeps running (zoomed out = 0 = loop parks).
|
||||
_artMap._liveCount = revealing ? 0 : drawn;
|
||||
}
|
||||
|
||||
// Draw one live bubble with its animation transform. aScale scales about the
|
||||
|
|
@ -5617,9 +5659,14 @@ 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);
|
||||
if (sc !== 1) {
|
||||
// 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;
|
||||
}
|
||||
if (sc !== 1 || bobY) {
|
||||
ctx.save();
|
||||
ctx.translate(n.x, n.y);
|
||||
ctx.translate(n.x, n.y + bobY);
|
||||
ctx.scale(sc, sc);
|
||||
ctx.translate(-n.x, -n.y);
|
||||
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
|
||||
|
|
@ -5642,9 +5689,14 @@ function _artMapStartLoop() {
|
|||
a.last = performance.now();
|
||||
const tick = (t) => {
|
||||
if (!a.running) return;
|
||||
_artMap._now = t;
|
||||
const more = _artMapStepAnimations(t);
|
||||
_artMapDraw();
|
||||
if (more) {
|
||||
_artMapDraw(); // sets _artMap._liveCount
|
||||
// Keep looping while something is animating, OR while ambient buoyancy is
|
||||
// on AND there are live bubbles on screen to bob (so a zoomed-out static
|
||||
// overview costs nothing — the loop parks until the next interaction).
|
||||
const keep = more || (_artMap._ambient && _artMap._liveCount > 0 && !document.hidden);
|
||||
if (keep) {
|
||||
a.raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
a.running = false;
|
||||
|
|
@ -5654,6 +5706,12 @@ function _artMapStartLoop() {
|
|||
a.raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// (Re)start the ambient loop if buoyancy is on and it isn't already running —
|
||||
// called after the reveal and on zoom/pan so bob resumes when bubbles appear.
|
||||
function _artMapEnsureAmbient() {
|
||||
if (_artMap._ambient && !_artMap._anim.running && !document.hidden) _artMapStartLoop();
|
||||
}
|
||||
|
||||
// Advance every active animation by absolute time t (ms). Returns true while
|
||||
// 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.
|
||||
|
|
@ -5698,6 +5756,7 @@ function _artMapBeginReveal() {
|
|||
const t0 = performance.now();
|
||||
_artMap._revealT0 = t0;
|
||||
_artMap._revealing = true;
|
||||
_artMap._ambient = true; // keep the loop alive afterwards for buoyancy
|
||||
_artMap._fieldAlpha = 1; // buffer is bypassed while revealing; live layer draws all
|
||||
|
||||
const islands = _artMap._islands || [];
|
||||
|
|
@ -5761,6 +5820,7 @@ function _artMapRender() {
|
|||
function _artMapDraw() {
|
||||
/**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/
|
||||
const _t0 = _artMap._perf ? performance.now() : 0;
|
||||
if (!_artMap._anim.running) _artMap._now = performance.now(); // keep bob current on on-demand draws
|
||||
const ctx = _artMap.ctx;
|
||||
const w = _artMap.width;
|
||||
const h = _artMap.height;
|
||||
|
|
@ -6728,6 +6788,11 @@ function _artMapSetupInteraction(canvas) {
|
|||
if (canvas._artMapListenersAttached) return;
|
||||
canvas._artMapListenersAttached = true;
|
||||
|
||||
// Pause ambient buoyancy when the tab is hidden; resume on return.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) _artMapEnsureAmbient();
|
||||
});
|
||||
|
||||
let isPanning = false, panStartX = 0, panStartY = 0;
|
||||
|
||||
canvas.addEventListener('wheel', (e) => {
|
||||
|
|
@ -6742,6 +6807,7 @@ function _artMapSetupInteraction(canvas) {
|
|||
_artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom);
|
||||
_artMap.zoom = newZoom;
|
||||
_artMapRender(); // fast blit
|
||||
_artMapEnsureAmbient(); // resume buoyancy if we zoomed bubbles into view
|
||||
// Debounce hi-res rebuild after zoom settles
|
||||
clearTimeout(_artMap._zoomRebuild);
|
||||
_artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300);
|
||||
|
|
|
|||
Loading…
Reference in a new issue