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.
|
// 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.
|
// 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.
|
// Deterministic hue (0–360) from a genre name, so each island has a stable tint.
|
||||||
function _artMapGenreHue(name) {
|
function _artMapGenreHue(name) {
|
||||||
let h = 0;
|
let h = 0;
|
||||||
|
|
@ -5119,7 +5145,7 @@ function _artMapLayoutIslands(groups, opts = {}) {
|
||||||
for (let j = i + 1; j < islands.length; j++) {
|
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 dx = islands[j].cx - islands[i].cx, dy = islands[j].cy - islands[i].cy;
|
||||||
const dist = Math.hypot(dx, dy) || 1;
|
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) {
|
if (dist < minD) {
|
||||||
const push = (minD - dist) / 2 + 1;
|
const push = (minD - dist) / 2 + 1;
|
||||||
islands[i].cx -= dx / dist * push; islands[i].cy -= dy / dist * push;
|
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 || '',
|
deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '',
|
||||||
musicbrainz_id: n.musicbrainz_id || '',
|
musicbrainz_id: n.musicbrainz_id || '',
|
||||||
popularity: n.popularity || 0, _hue: isl.hue, _island: isl.name,
|
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 });
|
_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');
|
const sidebar = document.getElementById('artmap-genre-sidebar');
|
||||||
if (sidebar) sidebar.style.display = 'none';
|
if (sidebar) sidebar.style.display = 'none';
|
||||||
if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame);
|
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);
|
if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler);
|
||||||
_artMapHideContextMenu();
|
_artMapHideContextMenu();
|
||||||
|
|
||||||
|
|
@ -5499,6 +5533,11 @@ function _artMapDrawNodeToBuffer(octx, n, scale) {
|
||||||
octx.fill();
|
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;
|
const showLabel = rScaled >= 13;
|
||||||
|
|
||||||
// Darken art behind the label so the name stays legible.
|
// Darken art behind the label so the name stays legible.
|
||||||
|
|
@ -5606,6 +5645,9 @@ function _artMapDrawLiveLayer(ctx) {
|
||||||
_artMap._drawAlphaMul = 1;
|
_artMap._drawAlphaMul = 1;
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
ctx.globalAlpha = 1;
|
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
|
// 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;
|
if (sc <= 0.001) return;
|
||||||
const baseMul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul;
|
const baseMul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul;
|
||||||
_artMap._drawAlphaMul = baseMul * (n.aAlpha == null ? 1 : n.aAlpha);
|
_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.save();
|
||||||
ctx.translate(n.x, n.y);
|
ctx.translate(n.x, n.y + bobY);
|
||||||
ctx.scale(sc, sc);
|
ctx.scale(sc, sc);
|
||||||
ctx.translate(-n.x, -n.y);
|
ctx.translate(-n.x, -n.y);
|
||||||
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
|
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
|
||||||
|
|
@ -5642,9 +5689,14 @@ function _artMapStartLoop() {
|
||||||
a.last = performance.now();
|
a.last = performance.now();
|
||||||
const tick = (t) => {
|
const tick = (t) => {
|
||||||
if (!a.running) return;
|
if (!a.running) return;
|
||||||
|
_artMap._now = t;
|
||||||
const more = _artMapStepAnimations(t);
|
const more = _artMapStepAnimations(t);
|
||||||
_artMapDraw();
|
_artMapDraw(); // sets _artMap._liveCount
|
||||||
if (more) {
|
// 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);
|
a.raf = requestAnimationFrame(tick);
|
||||||
} else {
|
} else {
|
||||||
a.running = false;
|
a.running = false;
|
||||||
|
|
@ -5654,6 +5706,12 @@ function _artMapStartLoop() {
|
||||||
a.raf = requestAnimationFrame(tick);
|
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
|
// 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)
|
// 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.
|
// once past its staggered start, and a water ripple expands from each island.
|
||||||
|
|
@ -5698,6 +5756,7 @@ function _artMapBeginReveal() {
|
||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
_artMap._revealT0 = t0;
|
_artMap._revealT0 = t0;
|
||||||
_artMap._revealing = true;
|
_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
|
_artMap._fieldAlpha = 1; // buffer is bypassed while revealing; live layer draws all
|
||||||
|
|
||||||
const islands = _artMap._islands || [];
|
const islands = _artMap._islands || [];
|
||||||
|
|
@ -5761,6 +5820,7 @@ function _artMapRender() {
|
||||||
function _artMapDraw() {
|
function _artMapDraw() {
|
||||||
/**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/
|
/**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/
|
||||||
const _t0 = _artMap._perf ? performance.now() : 0;
|
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 ctx = _artMap.ctx;
|
||||||
const w = _artMap.width;
|
const w = _artMap.width;
|
||||||
const h = _artMap.height;
|
const h = _artMap.height;
|
||||||
|
|
@ -6728,6 +6788,11 @@ function _artMapSetupInteraction(canvas) {
|
||||||
if (canvas._artMapListenersAttached) return;
|
if (canvas._artMapListenersAttached) return;
|
||||||
canvas._artMapListenersAttached = true;
|
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;
|
let isPanning = false, panStartX = 0, panStartY = 0;
|
||||||
|
|
||||||
canvas.addEventListener('wheel', (e) => {
|
canvas.addEventListener('wheel', (e) => {
|
||||||
|
|
@ -6742,6 +6807,7 @@ function _artMapSetupInteraction(canvas) {
|
||||||
_artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom);
|
_artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom);
|
||||||
_artMap.zoom = newZoom;
|
_artMap.zoom = newZoom;
|
||||||
_artMapRender(); // fast blit
|
_artMapRender(); // fast blit
|
||||||
|
_artMapEnsureAmbient(); // resume buoyancy if we zoomed bubbles into view
|
||||||
// Debounce hi-res rebuild after zoom settles
|
// Debounce hi-res rebuild after zoom settles
|
||||||
clearTimeout(_artMap._zoomRebuild);
|
clearTimeout(_artMap._zoomRebuild);
|
||||||
_artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300);
|
_artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue