Artist Map v2 — Phase A.1: pre-masked circular images, consistent art, clean reveal
Addresses the screenshot feedback (mix of detailed covers + blank dots, lag,
'weird' load):
- Pre-mask each album image into a circle ONCE at load (a canvas), so every
draw is a plain drawImage instead of a per-frame ctx.clip(). Clipping was
the live-layer stutter — hundreds of clips per frame. Now free.
- Draw album art at nearly every on-screen size (only sub-2.2px fall back to a
dot), instead of detailed-vs-blank-dot tiers. Consistent 'sea of covers'.
- Reveal is now a clean ease-out-cubic fade of the whole map (buffer blit +
live layer ramp together via _drawAlphaMul) — dropped the bouncy per-node
pop that read as 'weird'. The real island ripple bloom comes in Phase C.
64 JS integrity tests pass.
This commit is contained in:
parent
4b04f765a0
commit
0f63024d4b
1 changed files with 79 additions and 91 deletions
|
|
@ -5350,6 +5350,7 @@ function _artMapRebuildBuffer() {
|
|||
_artMap._bufferMinY = minY;
|
||||
// Freeze the live/buffer partition to this build's zoom (see _artMapIsLiveSize).
|
||||
_artMap._liveBuildZoom = _artMap.zoom;
|
||||
_artMap._drawAlphaMul = 1; // buffer bakes at full alpha; the blit applies the reveal fade
|
||||
|
||||
octx.scale(scale, scale);
|
||||
octx.translate(-minX, -minY);
|
||||
|
|
@ -5403,11 +5404,14 @@ function _artMapDrawNodeToBuffer(octx, n, scale) {
|
|||
if (op < 0.01) return;
|
||||
const r = n.radius;
|
||||
const isW = n.type === 'watchlist' || n.type === 'center';
|
||||
octx.globalAlpha = op;
|
||||
// Global fade multiplier (reveal). Lets the whole map fade in cleanly while
|
||||
// each painter keeps its own per-element alpha.
|
||||
const mul = _artMap._drawAlphaMul == null ? 1 : _artMap._drawAlphaMul;
|
||||
octx.globalAlpha = op * mul;
|
||||
|
||||
// Genre label node — transparent circle with large text
|
||||
if (n._isLabel) {
|
||||
octx.globalAlpha = 0.6;
|
||||
octx.globalAlpha = 0.6 * mul;
|
||||
octx.beginPath();
|
||||
octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2);
|
||||
octx.fillStyle = 'rgba(138,43,226,0.04)';
|
||||
|
|
@ -5428,13 +5432,14 @@ function _artMapDrawNodeToBuffer(octx, n, scale) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Render quality based on node size in buffer pixels
|
||||
// On-screen size drives detail. Album art shows at nearly every size (the
|
||||
// images are pre-masked to circles, so this is just a cheap drawImage — no
|
||||
// per-frame clip) for a consistent "sea of covers" look. Only the very
|
||||
// smallest fall back to a coloured dot.
|
||||
const rScaled = r * scale;
|
||||
const isSmall = rScaled < 8;
|
||||
const isTiny = rScaled < 3;
|
||||
const img = _artMap.images[n.id];
|
||||
|
||||
// Tiny nodes: just a colored dot (no clip, no image, no text)
|
||||
if (isTiny) {
|
||||
if (rScaled < 2.2) {
|
||||
octx.beginPath();
|
||||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
octx.fillStyle = isW ? '#6b21a8' : '#2a2a40';
|
||||
|
|
@ -5442,27 +5447,8 @@ function _artMapDrawNodeToBuffer(octx, n, scale) {
|
|||
return;
|
||||
}
|
||||
|
||||
// Small nodes: filled circle + border, no image clip
|
||||
if (isSmall) {
|
||||
octx.beginPath();
|
||||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
const img = _artMap.images[n.id];
|
||||
if (img) {
|
||||
octx.save(); octx.clip();
|
||||
octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2);
|
||||
octx.restore();
|
||||
} else {
|
||||
octx.fillStyle = isW ? '#1a0a30' : '#141420';
|
||||
octx.fill();
|
||||
}
|
||||
octx.strokeStyle = isW ? 'rgba(138,43,226,0.3)' : 'rgba(255,255,255,0.06)';
|
||||
octx.lineWidth = isW ? 1.5 : 0.5;
|
||||
octx.stroke();
|
||||
return;
|
||||
}
|
||||
|
||||
// Full quality: glow + clip + image + text
|
||||
if (isW) {
|
||||
// Focal glow ring for watchlist/center bubbles
|
||||
if (isW && rScaled >= 7) {
|
||||
octx.beginPath();
|
||||
octx.arc(n.x, n.y, r + 4, 0, Math.PI * 2);
|
||||
octx.strokeStyle = 'rgba(138,43,226,0.25)';
|
||||
|
|
@ -5470,37 +5456,43 @@ function _artMapDrawNodeToBuffer(octx, n, scale) {
|
|||
octx.stroke();
|
||||
}
|
||||
|
||||
octx.save();
|
||||
octx.beginPath();
|
||||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
octx.closePath();
|
||||
octx.clip();
|
||||
|
||||
const img = _artMap.images[n.id];
|
||||
// Body — pre-masked circular image (no clip) or a placeholder disc.
|
||||
if (img) {
|
||||
octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2);
|
||||
octx.fillStyle = 'rgba(0,0,0,0.45)';
|
||||
octx.fillRect(n.x - r, n.y - r, r * 2, r * 2);
|
||||
} else {
|
||||
octx.beginPath();
|
||||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
octx.fillStyle = isW ? '#1a0a30' : '#141420';
|
||||
octx.fillRect(n.x - r, n.y - r, r * 2, r * 2);
|
||||
octx.fill();
|
||||
}
|
||||
octx.restore();
|
||||
|
||||
const showLabel = rScaled >= 13;
|
||||
|
||||
// Darken art behind the label so the name stays legible.
|
||||
if (showLabel && img) {
|
||||
octx.beginPath();
|
||||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
octx.fillStyle = 'rgba(0,0,0,0.42)';
|
||||
octx.fill();
|
||||
}
|
||||
|
||||
// Border
|
||||
octx.beginPath();
|
||||
octx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
||||
octx.strokeStyle = isW ? 'rgba(138,43,226,0.4)' : 'rgba(255,255,255,0.08)';
|
||||
octx.lineWidth = isW ? 2 : 0.5;
|
||||
octx.strokeStyle = isW ? 'rgba(138,43,226,0.45)' : 'rgba(255,255,255,0.10)';
|
||||
octx.lineWidth = isW ? 2 : (rScaled >= 7 ? 1 : 0.5);
|
||||
octx.stroke();
|
||||
|
||||
const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3);
|
||||
octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`;
|
||||
octx.textAlign = 'center';
|
||||
octx.textBaseline = 'middle';
|
||||
octx.fillStyle = '#fff';
|
||||
const maxC = isW ? 20 : 12;
|
||||
const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name;
|
||||
octx.fillText(label, n.x, n.y);
|
||||
if (showLabel) {
|
||||
const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3);
|
||||
octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`;
|
||||
octx.textAlign = 'center';
|
||||
octx.textBaseline = 'middle';
|
||||
octx.fillStyle = '#fff';
|
||||
const maxC = isW ? 20 : 12;
|
||||
const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name;
|
||||
octx.fillText(label, n.x, n.y);
|
||||
}
|
||||
}
|
||||
|
||||
// Composite ONE node into the EXISTING buffer without a full rebuild. This is
|
||||
|
|
@ -5558,6 +5550,8 @@ 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;
|
||||
|
||||
let drawn = 0;
|
||||
const CAP = 600;
|
||||
|
|
@ -5571,6 +5565,7 @@ function _artMapDrawLiveLayer(ctx) {
|
|||
_artMapDrawLiveNode(ctx, n);
|
||||
if (++drawn >= CAP) break;
|
||||
}
|
||||
_artMap._drawAlphaMul = 1;
|
||||
ctx.restore();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
|
@ -5618,59 +5613,26 @@ function _artMapStartLoop() {
|
|||
}
|
||||
|
||||
// Advance every active animation by absolute time t (ms). Returns true while
|
||||
// anything is still moving. Phase A: global field fade-in + staggered per-node
|
||||
// reveal pop (ease-out-back) for the live bubbles.
|
||||
// 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.
|
||||
function _artMapStepAnimations(t) {
|
||||
let active = false;
|
||||
|
||||
// Global far-field fade-in over the reveal window.
|
||||
if (_artMap._fieldAlpha < 1) {
|
||||
const FIELD_MS = 700;
|
||||
const FIELD_MS = 600;
|
||||
const p = Math.min(1, (t - _artMap._revealT0) / FIELD_MS);
|
||||
_artMap._fieldAlpha = p;
|
||||
_artMap._fieldAlpha = 1 - Math.pow(1 - p, 3); // ease-out-cubic
|
||||
if (p < 1) active = true;
|
||||
}
|
||||
|
||||
// Per-node reveal pop: each node eases its aScale 0→1 once past its
|
||||
// staggered start time. Ease-out-back gives a subtle overshoot ("bloom").
|
||||
const placed = _artMap.placed;
|
||||
if (placed) {
|
||||
for (const n of placed) {
|
||||
if (n.aScale == null || n.aScale >= 1) continue;
|
||||
const start = n._revealAt || _artMap._revealT0;
|
||||
if (t < start) { active = true; continue; }
|
||||
const DUR = 520;
|
||||
const p = Math.min(1, (t - start) / DUR);
|
||||
// ease-out-back
|
||||
const c1 = 1.70158, c3 = c1 + 1;
|
||||
const e = 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2);
|
||||
n.aScale = p >= 1 ? 1 : e;
|
||||
if (p < 1) active = true;
|
||||
}
|
||||
}
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
// Kick off the reveal: fade the far field in, and stagger each live bubble's
|
||||
// pop by its distance from the camera centre so islands bloom outward.
|
||||
// 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).
|
||||
function _artMapBeginReveal() {
|
||||
const t0 = performance.now();
|
||||
_artMap._revealT0 = t0;
|
||||
_artMap._revealT0 = performance.now();
|
||||
_artMap._fieldAlpha = 0;
|
||||
const cx = (_artMap.width / 2 - _artMap.offsetX) / _artMap.zoom;
|
||||
const cy = (_artMap.height / 2 - _artMap.offsetY) / _artMap.zoom;
|
||||
let maxD = 1;
|
||||
for (const n of _artMap.placed) {
|
||||
n._d2 = (n.x - cx) * (n.x - cx) + (n.y - cy) * (n.y - cy);
|
||||
if (n._d2 > maxD) maxD = n._d2;
|
||||
}
|
||||
const maxD2 = Math.sqrt(maxD) || 1;
|
||||
for (const n of _artMap.placed) {
|
||||
n.aScale = 0;
|
||||
const d = Math.sqrt(n._d2);
|
||||
n._revealAt = t0 + (d / maxD2) * 520; // farther = later → outward bloom
|
||||
}
|
||||
_artMapStartLoop();
|
||||
}
|
||||
|
||||
|
|
@ -6778,9 +6740,35 @@ function _artMapDecodeSmall(blob, px) {
|
|||
try {
|
||||
return createImageBitmap(blob, {
|
||||
resizeWidth: d, resizeHeight: d, resizeQuality: 'high',
|
||||
}).catch(() => createImageBitmap(blob).catch(() => null)); // older engines ignore opts
|
||||
}).then(_artMapCircleMask)
|
||||
.catch(() => createImageBitmap(blob).then(_artMapCircleMask).catch(() => null));
|
||||
} catch (e) {
|
||||
return createImageBitmap(blob).catch(() => null);
|
||||
return createImageBitmap(blob).then(_artMapCircleMask).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-mask a decoded bitmap into a CIRCLE once, at load time, returning a
|
||||
// canvas. The whole map then draws bubbles with a plain drawImage (the canvas
|
||||
// is already round) instead of a per-frame ctx.clip() per bubble — clipping is
|
||||
// one of the most expensive canvas ops and, at hundreds of visible bubbles per
|
||||
// frame, was the live-layer stutter. Done once here, it's free forever after.
|
||||
function _artMapCircleMask(src) {
|
||||
if (!src) return null;
|
||||
const w = src.width || 0;
|
||||
if (!w) return src;
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
c.width = w; c.height = w;
|
||||
const cx = c.getContext('2d');
|
||||
cx.beginPath();
|
||||
cx.arc(w / 2, w / 2, w / 2, 0, Math.PI * 2);
|
||||
cx.closePath();
|
||||
cx.clip();
|
||||
cx.drawImage(src, 0, 0, w, w);
|
||||
if (src.close) src.close(); // free the ImageBitmap; we keep the canvas
|
||||
return c;
|
||||
} catch (e) {
|
||||
return src; // fall back to the raw bitmap (draw path still clips defensively)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue