Artist Map v2 — Phase A: two-layer live animation engine
Foundation for the water/ripple redesign. Splits rendering into:
- Static far-field buffer: small/distant bubbles, baked once (cheap blit).
- Live overlay layer: every bubble big enough to read (radius*zoom >= LIVE_PX)
redrawn each frame in world space, so it can scale/bob/ripple. Viewport-
culled + capped at 600 draws.
The partition is frozen at buffer-build zoom (_liveBuildZoom) so the two sets
stay exact complements even mid-zoom — no flicker, no double-draw.
Adds an idle-capable rAF loop (_artMapStartLoop/_artMapStepAnimations) that runs
only while something animates and stops when still. First payload: a reveal —
the far field fades in globally while live bubbles pop outward from the camera
centre (ease-out-back, staggered by distance). Wired into all three loaders.
Bonus: live bubbles now draw full-res at the current zoom instead of through the
4096px-capped buffer, so zoomed-in artwork is crisp (addresses the earlier
low-res complaint structurally). Engine only — the island layout, ripple
choreography and click physics build on this in B–E. 64 JS integrity tests pass.
This commit is contained in:
parent
8d4de4dc49
commit
4b04f765a0
1 changed files with 180 additions and 7 deletions
|
|
@ -5011,6 +5011,20 @@ const _artMap = {
|
|||
// under the LOD dot threshold). Only binds on large worlds — small maps
|
||||
// stay crisp via the z*2 / 1.0 caps. Tunable.
|
||||
MAX_BUFFER_PX: 4096,
|
||||
|
||||
// ── v2 live-animation engine ──────────────────────────────────────────
|
||||
// Two-layer render: the offscreen buffer holds the STATIC far field (small
|
||||
// bubbles, drawn once); a LIVE overlay redraws only the bubbles big enough
|
||||
// to read on screen, every frame, so they can scale/bob/ripple. A node is
|
||||
// "live" when its on-screen radius (radius*zoom) clears LIVE_PX; everything
|
||||
// smaller stays baked in the buffer. This bounds per-frame work to what you
|
||||
// can actually see, not the full 2000-node world.
|
||||
LIVE_PX: 12,
|
||||
// The rAF loop runs ONLY while something is animating (reveal/ripple) and
|
||||
// idles otherwise, falling back to on-demand blits. _anim tracks it.
|
||||
_anim: { running: false, raf: null, last: 0 },
|
||||
_fieldAlpha: 1, // global fade for the static far-field buffer (reveal)
|
||||
_revealT0: 0, // performance.now() when the current reveal began
|
||||
};
|
||||
|
||||
async function openArtistMap() {
|
||||
|
|
@ -5215,16 +5229,15 @@ async function openArtistMap() {
|
|||
_artMap.placed.forEach(n => { n.opacity = 1; });
|
||||
|
||||
// Paint NOW — the map is fully interactive (pan/zoom/hover/click)
|
||||
// with placeholder circles before a single image is fetched. Images
|
||||
// then stream in throttled waves and sharpen the map in place. This
|
||||
// is the "click-click-click" win: no blocking on N image fetches
|
||||
// before the first frame.
|
||||
// before a single image is fetched. The reveal animation blooms the
|
||||
// bubbles in (far field fades, near bubbles pop outward); images then
|
||||
// stream in behind it and sharpen in place. No blocking on N fetches.
|
||||
_artMap.dirty = true;
|
||||
_artMapRender();
|
||||
|
||||
const le = document.getElementById('artist-map-loading');
|
||||
if (le) le.remove();
|
||||
|
||||
_artMapBeginReveal();
|
||||
_artMapStreamImages(_artMap.placed);
|
||||
}, 50);
|
||||
|
||||
|
|
@ -5335,6 +5348,8 @@ function _artMapRebuildBuffer() {
|
|||
_artMap._bufferScale = scale;
|
||||
_artMap._bufferMinX = minX;
|
||||
_artMap._bufferMinY = minY;
|
||||
// Freeze the live/buffer partition to this build's zoom (see _artMapIsLiveSize).
|
||||
_artMap._liveBuildZoom = _artMap.zoom;
|
||||
|
||||
octx.scale(scale, scale);
|
||||
octx.translate(-minX, -minY);
|
||||
|
|
@ -5370,6 +5385,7 @@ function _artMapRebuildBuffer() {
|
|||
else if (pass === 2 && !n._isLabel && (n.type === 'watchlist' || n.type === 'center' || n.ring === 1)) { /* draw */ }
|
||||
else continue;
|
||||
if (hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue;
|
||||
if (_artMapIsLiveSize(n)) continue; // big enough to read → drawn live on the overlay
|
||||
_artMapDrawNodeToBuffer(octx, n, scale);
|
||||
}
|
||||
}
|
||||
|
|
@ -5499,6 +5515,10 @@ function _artMapCompositeNode(n) {
|
|||
if (!oc || scale == null) return false;
|
||||
if (_artMap._hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) return false;
|
||||
if ((n.opacity || 0) < 0.01) return false;
|
||||
// Live-layer bubbles aren't in the buffer — they read their image fresh each
|
||||
// frame, so just signal "blit" and let the overlay pick it up. Compositing
|
||||
// them here would double-draw (buffer copy + live copy).
|
||||
if (_artMapIsLiveSize(n)) return true;
|
||||
const octx = oc.getContext('2d');
|
||||
octx.save();
|
||||
octx.scale(scale, scale);
|
||||
|
|
@ -5509,6 +5529,151 @@ function _artMapCompositeNode(n) {
|
|||
return true;
|
||||
}
|
||||
|
||||
// A node renders on the live overlay when it's big enough on screen to read.
|
||||
// Labels stay baked in the static buffer (no per-frame motion needed in v2).
|
||||
// IMPORTANT: this uses the zoom the BUFFER WAS BUILT AT (_liveBuildZoom), not
|
||||
// the live zoom. The buffer only rebuilds ~300ms after zooming stops, so the
|
||||
// live/buffer split must stay frozen to whatever the (possibly stale) buffer
|
||||
// excluded — otherwise a bubble could fall out of both during an active zoom
|
||||
// and flicker. Both the buffer-exclude and the live-draw read this same value,
|
||||
// so the two sets are always exact complements.
|
||||
function _artMapIsLiveSize(n) {
|
||||
if (n._isLabel) return false;
|
||||
const z = _artMap._liveBuildZoom || _artMap.zoom;
|
||||
return (n.radius || 0) * z >= _artMap.LIVE_PX;
|
||||
}
|
||||
|
||||
// Draw the live overlay: every big/near bubble, in world space, honouring its
|
||||
// per-node animation transform (aScale for reveal/pop, opacity for fade). Kept
|
||||
// cheap by viewport culling + a hard cap; the static far field is already on
|
||||
// screen via the buffer blit.
|
||||
function _artMapDrawLiveLayer(ctx) {
|
||||
const placed = _artMap.placed;
|
||||
if (!placed || !placed.length) return;
|
||||
const z = _artMap.zoom;
|
||||
const ox = _artMap.offsetX, oy = _artMap.offsetY;
|
||||
const w = _artMap.width, h = _artMap.height;
|
||||
const margin = 80;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(ox, oy);
|
||||
ctx.scale(z, z);
|
||||
|
||||
let drawn = 0;
|
||||
const CAP = 600;
|
||||
for (const n of placed) {
|
||||
if (!_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;
|
||||
const rPx = (n.radius || 0) * z;
|
||||
if (sx + rPx < -margin || sx - rPx > w + margin || sy + rPx < -margin || sy - rPx > h + margin) continue;
|
||||
_artMapDrawLiveNode(ctx, n);
|
||||
if (++drawn >= CAP) break;
|
||||
}
|
||||
ctx.restore();
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// 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.
|
||||
function _artMapDrawLiveNode(ctx, n) {
|
||||
const sc = n.aScale == null ? 1 : n.aScale;
|
||||
if (sc <= 0.001) return;
|
||||
if (sc !== 1) {
|
||||
ctx.save();
|
||||
ctx.translate(n.x, n.y);
|
||||
ctx.scale(sc, sc);
|
||||
ctx.translate(-n.x, -n.y);
|
||||
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
|
||||
ctx.restore();
|
||||
} else {
|
||||
_artMapDrawNodeToBuffer(ctx, n, _artMap.zoom);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// ── Animation loop ────────────────────────────────────────────────────────
|
||||
// Runs only while something is animating; idles otherwise. Each tick advances
|
||||
// the active animations (reveal field-fade + per-node pop), draws one frame,
|
||||
// and re-arms itself only if work remains — so a still map costs nothing.
|
||||
function _artMapStartLoop() {
|
||||
const a = _artMap._anim;
|
||||
if (a.running) return;
|
||||
a.running = true;
|
||||
a.last = performance.now();
|
||||
const tick = (t) => {
|
||||
if (!a.running) return;
|
||||
const more = _artMapStepAnimations(t);
|
||||
_artMapDraw();
|
||||
if (more) {
|
||||
a.raf = requestAnimationFrame(tick);
|
||||
} else {
|
||||
a.running = false;
|
||||
a.raf = null;
|
||||
}
|
||||
};
|
||||
a.raf = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
// 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.
|
||||
function _artMapStepAnimations(t) {
|
||||
let active = false;
|
||||
|
||||
// Global far-field fade-in over the reveal window.
|
||||
if (_artMap._fieldAlpha < 1) {
|
||||
const FIELD_MS = 700;
|
||||
const p = Math.min(1, (t - _artMap._revealT0) / FIELD_MS);
|
||||
_artMap._fieldAlpha = p;
|
||||
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.
|
||||
function _artMapBeginReveal() {
|
||||
const t0 = performance.now();
|
||||
_artMap._revealT0 = t0;
|
||||
_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();
|
||||
}
|
||||
|
||||
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.
|
||||
|
|
@ -5560,12 +5725,20 @@ function _artMapDraw() {
|
|||
// 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;
|
||||
|
||||
// ── 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. ──
|
||||
_artMapDrawLiveLayer(ctx);
|
||||
|
||||
// ── Interactive overlay (drawn on main canvas, not buffer) ──
|
||||
const cFade = _artMap._constellationFade || 0;
|
||||
|
|
@ -6253,7 +6426,7 @@ async function _openGenreMapWithSelection(selectedGenre) {
|
|||
if (le) le.remove();
|
||||
|
||||
_artMap.dirty = true;
|
||||
_artMapRender();
|
||||
_artMapBeginReveal();
|
||||
|
||||
// Stream images in throttled waves — interactive immediately, sharpens in place.
|
||||
_artMapStreamImages(_artMap.placed.filter(n => !n._isLabel));
|
||||
|
|
@ -6463,7 +6636,7 @@ async function _openArtistMapExplorerWithName(name) {
|
|||
if (le) le.remove();
|
||||
|
||||
_artMap.dirty = true;
|
||||
_artMapRender();
|
||||
_artMapBeginReveal();
|
||||
|
||||
// Stream images in throttled waves — interactive immediately, sharpens in place.
|
||||
_artMapStreamImages(_artMap.placed);
|
||||
|
|
|
|||
Loading…
Reference in a new issue