search for standard sites pub-search.waow.tech
search zig blog atproto
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

perf: atlas idle-frame scheduling, cached colors, smooth transitions, halo sprites

- idle-frame optimization: rAF loop stops when not animating (GPU sleeps when idle)
- cache colors per frame: theme read once, precomputed rgba lookup eliminates
getColors/isDark/hexToRgba from hot path
- extend nebula halos to all zoom levels: continuous alpha curve instead of
hard cutoff at zoom 4 (25% floor at zoom 10)
- smooth zoom transitions: labels cross-fade coarse↔fine↔titles, connections
fade in over zoom 2.5–3.5 instead of hard pop at zoom 3
- cache nebula halo sprites: pre-rendered offscreen canvases keyed by
(platform, radiusBucket), drawImage instead of createRadialGradient per frame
- reuse connection line buffers: pre-allocated Float32Arrays instead of 18
fresh arrays + thousands of push() calls per frame
- quantize sprite sizes: snap radius to 0.5px steps, reduces sprite rebuilds
during smooth zoom from ~60/s to ~4-5/s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+288 -74
+288 -74
site/atlas.js
··· 21 21 22 22 var PLATFORMS = ['leaflet', 'whitewind', 'pckt', 'offprint', 'greengale', 'other']; 23 23 24 - function getColors() { 25 - return document.documentElement.getAttribute('data-theme') === 'light' 26 - ? PLATFORM_COLORS_LIGHT : PLATFORM_COLORS; 24 + // --- precomputed color cache (rebuilt once per frame) --- 25 + var frameColors = null; // current platform colors object 26 + var frameDark = true; // current theme 27 + var frameRgba = null; // { platform: { core_XX: 'rgba(...)' } } — precomputed rgba strings 28 + 29 + function parseHex(hex) { 30 + return [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)]; 27 31 } 28 32 29 - function isDark() { 30 - return document.documentElement.getAttribute('data-theme') !== 'light'; 33 + // build all rgba strings we'll need this frame 34 + function cacheFrameColors() { 35 + var dark = document.documentElement.getAttribute('data-theme') !== 'light'; 36 + frameDark = dark; 37 + frameColors = dark ? PLATFORM_COLORS : PLATFORM_COLORS_LIGHT; 38 + frameRgba = {}; 39 + var alphas = [0.03, 0.04, 0.05, 0.06, 0.08, 0.10, 0.12, 0.14, 0.18, 0.25, 0.40, 0.70, 1.0]; 40 + for (var p = 0; p < PLATFORMS.length; p++) { 41 + var name = PLATFORMS[p]; 42 + var c = frameColors[name]; 43 + var entry = {}; 44 + var parts = { core: parseHex(c.core), mid: parseHex(c.mid), edge: parseHex(c.edge) }; 45 + for (var key in parts) { 46 + var rgb = parts[key]; 47 + for (var a = 0; a < alphas.length; a++) { 48 + entry[key + '_' + alphas[a]] = 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + alphas[a] + ')'; 49 + } 50 + } 51 + frameRgba[name] = entry; 52 + } 53 + } 54 + 55 + function hexToRgba(hex, a) { 56 + var r = parseInt(hex.slice(1, 3), 16); 57 + var g = parseInt(hex.slice(3, 5), 16); 58 + var b = parseInt(hex.slice(5, 7), 16); 59 + return 'rgba(' + r + ',' + g + ',' + b + ',' + a + ')'; 31 60 } 32 61 33 62 // --- view state --- 34 63 var view = { zoom: 1, panX: 0, panY: 0, minZoom: 0.5, maxZoom: 30, dirty: true }; 35 64 65 + // --- demand-driven frame scheduling --- 66 + var frameRequested = false; 67 + 68 + function scheduleFrame() { 69 + if (!frameRequested) { 70 + frameRequested = true; 71 + requestAnimationFrame(loop); 72 + } 73 + } 74 + 75 + function markDirty() { 76 + view.dirty = true; 77 + scheduleFrame(); 78 + } 79 + 36 80 // --- data --- 37 81 var data = null; 38 82 var pointsX = null; ··· 40 84 var platformIdx = null; 41 85 var gridIndex = null; 42 86 var uriToIndex = null; // Map<uri, index> for search matching 87 + var clusterFineArr = null; // Uint8Array of fine cluster IDs per point 43 88 44 89 // --- search state --- 45 90 var searchMatches = null; // Set of point indices matching current search ··· 65 110 var spriteTheme = null; 66 111 67 112 function buildSprites(radius) { 113 + // quantize radius to 0.5px steps to avoid rebuilding during smooth zoom 114 + radius = Math.round(radius * 2) / 2; 68 115 var size = Math.ceil(radius * 6 * dpr) + 2; 69 116 if (size < 4) size = 4; 70 - var theme = isDark() ? 'dark' : 'light'; 117 + var theme = frameDark ? 'dark' : 'light'; 71 118 if (sprites && spriteSize === size && spriteTheme === theme) return; 72 119 spriteSize = size; 73 120 spriteTheme = theme; 74 - var colors = getColors(); 75 121 sprites = []; 76 122 for (var p = 0; p < PLATFORMS.length; p++) { 77 - var c = colors[PLATFORMS[p]]; 123 + var c = frameColors[PLATFORMS[p]]; 78 124 sprites.push({ 79 125 normal: makeSprite(size, radius * dpr, c, 0.7), 80 126 hover: makeSprite(size * 2, radius * dpr * 2, c, 1.0), ··· 109 155 var dotTheme = null; 110 156 111 157 function buildDotSprites() { 112 - var theme = isDark() ? 'dark' : 'light'; 158 + var theme = frameDark ? 'dark' : 'light'; 113 159 if (dotSprites && dotTheme === theme) return; 114 160 dotTheme = theme; 115 - var colors = getColors(); 116 161 dotSprites = []; 117 162 var s = Math.max(4, Math.ceil(3 * dpr)); 118 163 for (var p = 0; p < PLATFORMS.length; p++) { 119 164 var cv = document.createElement('canvas'); 120 165 cv.width = s; cv.height = s; 121 166 var c = cv.getContext('2d'); 122 - c.fillStyle = colors[PLATFORMS[p]].mid; 167 + c.fillStyle = frameColors[PLATFORMS[p]].mid; 123 168 c.globalAlpha = 0.7; 124 169 c.beginPath(); 125 170 c.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2); ··· 128 173 } 129 174 } 130 175 176 + // --- nebula halo sprite cache --- 177 + var haloSprites = null; // { theme, entries: { 'platform_bucket': canvas } } 178 + var HALO_BUCKETS = [20, 50, 100, 200, 400]; // radius pixel buckets 179 + 180 + function getHaloBucket(radiusPx) { 181 + for (var i = 0; i < HALO_BUCKETS.length; i++) { 182 + if (radiusPx <= HALO_BUCKETS[i]) return HALO_BUCKETS[i]; 183 + } 184 + return HALO_BUCKETS[HALO_BUCKETS.length - 1]; 185 + } 186 + 187 + function buildHaloSprite(platform, bucket) { 188 + var size = bucket * 2 + 4; 189 + var cv = document.createElement('canvas'); 190 + cv.width = size; cv.height = size; 191 + var c = cv.getContext('2d'); 192 + var half = size / 2; 193 + var nc = frameColors[platform]; 194 + var grad = c.createRadialGradient(half, half, 0, half, half, bucket); 195 + grad.addColorStop(0, hexToRgba(nc.core, 1)); 196 + grad.addColorStop(0.3, hexToRgba(nc.mid, 0.5)); 197 + grad.addColorStop(0.7, hexToRgba(nc.edge, 0.2)); 198 + grad.addColorStop(1, 'rgba(0,0,0,0)'); 199 + c.fillStyle = grad; 200 + c.beginPath(); 201 + c.arc(half, half, bucket, 0, Math.PI * 2); 202 + c.fill(); 203 + return cv; 204 + } 205 + 206 + function getHaloSprite(platform, radiusPx) { 207 + var theme = frameDark ? 'dark' : 'light'; 208 + if (!haloSprites || haloSprites.theme !== theme) { 209 + haloSprites = { theme: theme, entries: {} }; 210 + } 211 + var bucket = getHaloBucket(radiusPx); 212 + var key = platform + '_' + bucket; 213 + if (!haloSprites.entries[key]) { 214 + haloSprites.entries[key] = buildHaloSprite(platform, bucket); 215 + } 216 + return { sprite: haloSprites.entries[key], bucket: bucket }; 217 + } 218 + 131 219 function resizeCanvas() { 132 220 W = window.innerWidth; 133 221 H = window.innerHeight; ··· 138 226 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 139 227 sprites = null; 140 228 dotSprites = null; 141 - view.dirty = true; 229 + haloSprites = null; 230 + markDirty(); 142 231 } 143 232 144 233 // --- coordinate transforms (inlined for hot path) --- ··· 202 291 ctx.fillText(text, x, y); 203 292 } 204 293 294 + // --- smooth transition helpers --- 295 + function clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; } 296 + function fadeIn(zoom, start, range) { return clamp01((zoom - start) / range); } 297 + function fadeOut(zoom, start, range) { return 1 - clamp01((zoom - start) / range); } 298 + 299 + // --- connection line buffers (pre-allocated, reused each frame) --- 300 + var connBufSize = 6000; 301 + var connBufs = null; // [platform][bucket] = Float32Array 302 + var connBufLens = null; // [platform][bucket] = current length 303 + 304 + function initConnBuffers() { 305 + connBufs = []; 306 + connBufLens = []; 307 + for (var p = 0; p < PLATFORMS.length; p++) { 308 + connBufs.push([ 309 + new Float32Array(connBufSize), 310 + new Float32Array(connBufSize), 311 + new Float32Array(connBufSize) 312 + ]); 313 + connBufLens.push([0, 0, 0]); 314 + } 315 + } 316 + 317 + function resetConnBuffers() { 318 + for (var p = 0; p < PLATFORMS.length; p++) { 319 + connBufLens[p][0] = 0; 320 + connBufLens[p][1] = 0; 321 + connBufLens[p][2] = 0; 322 + } 323 + } 324 + 205 325 // --- rendering --- 206 326 function render() { 207 327 if (!data || !view.dirty) return; 208 328 view.dirty = false; 209 329 210 - var dark = isDark(); 330 + // cache theme + colors once per frame 331 + cacheFrameColors(); 332 + var dark = frameDark; 211 333 var zoom = view.zoom; 212 334 var n = data.points.length; 213 335 ··· 225 347 var xMin = tl[0] - pad, xMax = br[0] + pad; 226 348 var yMin = tl[1] - pad, yMax = br[1] + pad; 227 349 228 - // --- cluster glows (zoomed out) --- 229 - if (zoom < 4) { 350 + // --- cluster nebula halos (colored by dominant platform) --- 351 + // continuous alpha curve: full at zoom<2, fades gradually, floor at 25% of base 352 + var haloAlphaFactor = zoom < 2 ? 1.0 : Math.max(0.25, 1 - (zoom - 2) / 8); 353 + if (haloAlphaFactor > 0.01) { 230 354 var clusters = zoom < 2 ? data.clusters.coarse : data.clusters.fine; 231 - ctx.globalAlpha = zoom < 2 ? 0.6 : 0.3; 355 + var baseAlpha = dark ? (zoom < 2 ? 0.06 : 0.04) : (zoom < 2 ? 0.05 : 0.03); 356 + baseAlpha *= haloAlphaFactor; 232 357 for (var c = 0; c < clusters.length; c++) { 233 358 var cl = clusters[c]; 359 + var r = (cl.radius || 0.05) * scale; 360 + if (r < 2) continue; 234 361 var sx = cx + cl.cx * scale, sy = cy + cl.cy * scale; 235 - if (sx < -100 || sx > W + 100 || sy < -100 || sy > H + 100) continue; 236 - var r = Math.sqrt(cl.count) * 2; 237 - var grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, r); 238 - grad.addColorStop(0, dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'); 239 - grad.addColorStop(1, 'transparent'); 240 - ctx.fillStyle = grad; 241 - ctx.beginPath(); 242 - ctx.arc(sx, sy, r, 0, Math.PI * 2); 243 - ctx.fill(); 362 + if (sx + r < 0 || sx - r > W || sy + r < 0 || sy - r > H) continue; 363 + var platform = cl.dominantPlatform || 'other'; 364 + var halo = getHaloSprite(platform, r); 365 + var spriteScale = r / halo.bucket; 366 + var drawSize = halo.sprite.width * spriteScale; 367 + ctx.globalAlpha = baseAlpha * 2; // match original: gradient center was baseAlpha*2 368 + ctx.drawImage(halo.sprite, sx - drawSize / 2, sy - drawSize / 2, drawSize, drawSize); 244 369 } 245 370 } 246 371 247 - // --- connection lines (batched by opacity bucket) --- 248 - if (zoom >= 3 && gridIndex) { 372 + // --- connection lines (intra-cluster, colored by platform) --- 373 + // smooth fade-in over zoom 2.5–3.5 374 + var connAlphaFactor = fadeIn(zoom, 2.5, 1.0); 375 + if (connAlphaFactor > 0 && gridIndex && clusterFineArr) { 376 + if (!connBufs) initConnBuffers(); 377 + resetConnBuffers(); 378 + 249 379 var connRadius = 0.025; 250 380 var cs = gridIndex.cellSize; 251 381 var maxLines = 1500; 252 382 var lineCount = 0; 253 - 254 - // batch into 3 opacity buckets to minimize style changes 255 - var buckets = [[], [], []]; // near, mid, far 256 383 257 384 for (var i = 0; i < n && lineCount < maxLines; i++) { 258 385 var px = pointsX[i], py = pointsY[i]; 259 386 if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 260 387 261 388 var sx1 = cx + px * scale, sy1 = cy + py * scale; 389 + var ci = clusterFineArr[i]; 262 390 var gxMin2 = Math.floor((px - connRadius) / cs); 263 391 var gxMax2 = Math.floor((px + connRadius) / cs); 264 392 var gyMin2 = Math.floor((py - connRadius) / cs); ··· 271 399 for (var k = 0; k < cell.length && lineCount < maxLines; k++) { 272 400 var j = cell[k]; 273 401 if (j <= i) continue; 402 + if (clusterFineArr[j] !== ci) continue; 274 403 var dx = pointsX[j] - px, dy = pointsY[j] - py; 275 404 var dist2 = dx * dx + dy * dy; 276 405 if (dist2 > connRadius * connRadius || dist2 < 0.0001) continue; 277 - var t = Math.sqrt(dist2) / connRadius; // 0=close, 1=far 406 + var t = Math.sqrt(dist2) / connRadius; 278 407 var bucket = t < 0.33 ? 0 : t < 0.66 ? 1 : 2; 279 - buckets[bucket].push(sx1, sy1, cx + pointsX[j] * scale, cy + pointsY[j] * scale); 408 + var pi = platformIdx[i]; 409 + var buf = connBufs[pi][bucket]; 410 + var len = connBufLens[pi][bucket]; 411 + if (len + 4 <= buf.length) { 412 + buf[len] = sx1; 413 + buf[len + 1] = sy1; 414 + buf[len + 2] = cx + pointsX[j] * scale; 415 + buf[len + 3] = cy + pointsY[j] * scale; 416 + connBufLens[pi][bucket] = len + 4; 417 + } 280 418 lineCount++; 281 419 } 282 420 } 283 421 } 284 422 } 285 423 286 - // draw each bucket in one path 287 - var opacities = dark 288 - ? ['rgba(255,255,255,0.10)', 'rgba(255,255,255,0.06)', 'rgba(255,255,255,0.03)'] 289 - : ['rgba(0,0,0,0.08)', 'rgba(0,0,0,0.05)', 'rgba(0,0,0,0.02)']; 424 + // draw each platform × distance bucket 425 + var connAlphas = dark ? [0.18, 0.10, 0.05] : [0.14, 0.08, 0.03]; 290 426 ctx.lineWidth = 0.5; 291 - for (var b = 0; b < 3; b++) { 292 - var buf = buckets[b]; 293 - if (!buf.length) continue; 294 - ctx.beginPath(); 295 - for (var l = 0; l < buf.length; l += 4) { 296 - ctx.moveTo(buf[l], buf[l + 1]); 297 - ctx.lineTo(buf[l + 2], buf[l + 3]); 427 + for (var p = 0; p < PLATFORMS.length; p++) { 428 + var cc = frameColors[PLATFORMS[p]]; 429 + for (var b = 0; b < 3; b++) { 430 + var len = connBufLens[p][b]; 431 + if (!len) continue; 432 + var buf = connBufs[p][b]; 433 + ctx.beginPath(); 434 + for (var l = 0; l < len; l += 4) { 435 + ctx.moveTo(buf[l], buf[l + 1]); 436 + ctx.lineTo(buf[l + 2], buf[l + 3]); 437 + } 438 + ctx.strokeStyle = hexToRgba(cc.mid, connAlphas[b] * connAlphaFactor); 439 + ctx.globalAlpha = 1; 440 + ctx.stroke(); 298 441 } 299 - ctx.strokeStyle = opacities[b]; 300 - ctx.globalAlpha = 1; 301 - ctx.stroke(); 302 442 } 303 443 } 304 444 ··· 420 560 // label margin: keep labels inside viewport with some padding 421 561 var LABEL_MARGIN = small ? 8 : 12; 422 562 423 - if (zoom < 2) { 424 - // coarse labels — sort by cluster size so biggest labels win 563 + // smooth label transitions: 564 + // coarse labels: full opacity zoom<1.7, fade out 1.7–2.3 565 + // fine labels: fade in 1.7–2.3, full opacity 2.3–4.5, fade out 4.5–5.5 566 + // titles: fade in 4.5–5.5, full opacity 5.5+ 567 + var coarseAlpha = fadeOut(zoom, 1.7, 0.6); 568 + var fineAlpha = fadeIn(zoom, 1.7, 0.6) * fadeOut(zoom, 4.5, 1.0); 569 + var titleAlpha = fadeIn(zoom, 4.5, 1.0); 570 + 571 + if (coarseAlpha > 0.01) { 425 572 ctx.font = (small ? '9px' : '12px') + ' monospace'; 426 - ctx.globalAlpha = 0.85; 573 + ctx.globalAlpha = 0.85 * coarseAlpha; 574 + ctx.fillStyle = dark ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.65)'; 427 575 var fontSize = small ? 9 : 12; 428 576 var sorted = data.clusters.coarse.slice().sort(function(a, b) { return b.count - a.count; }); 429 577 for (var c = 0; c < sorted.length; c++) { ··· 432 580 if (sy < LABEL_MARGIN || sy > H - 40) continue; 433 581 var tw = ctx.measureText(cl.label).width; 434 582 var halfW = tw / 2; 435 - // clamp horizontally so label stays in viewport 436 583 if (sx - halfW < LABEL_MARGIN) sx = LABEL_MARGIN + halfW; 437 584 if (sx + halfW > W - LABEL_MARGIN) sx = W - LABEL_MARGIN - halfW; 438 585 if (canPlace(sx, sy, tw, fontSize)) drawLabel(cl.label, sx, sy, dark); 439 586 } 440 - } else if (zoom < 5) { 441 - // fine labels — sort by size 587 + } 588 + 589 + if (fineAlpha > 0.01) { 442 590 ctx.font = (small ? '8px' : '11px') + ' monospace'; 443 - ctx.globalAlpha = 0.75; 591 + ctx.globalAlpha = 0.75 * fineAlpha; 592 + ctx.fillStyle = dark ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.65)'; 444 593 var fontSize = small ? 8 : 11; 445 594 var sorted = data.clusters.fine.slice().sort(function(a, b) { return b.count - a.count; }); 446 595 for (var c = 0; c < sorted.length; c++) { ··· 454 603 if (sx + halfW > W - LABEL_MARGIN) sx = W - LABEL_MARGIN - halfW; 455 604 if (canPlace(sx, sy, tw, fontSize)) drawLabel(cl.label, sx, sy, dark); 456 605 } 457 - } else { 458 - // document titles 606 + } 607 + 608 + if (titleAlpha > 0.01) { 459 609 ctx.font = (small ? '9px' : '11px') + ' monospace'; 460 - ctx.globalAlpha = 0.7; 610 + ctx.globalAlpha = 0.7 * titleAlpha; 611 + ctx.fillStyle = dark ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.65)'; 461 612 var fontSize = small ? 9 : 11; 462 613 var shown = 0, maxLabels = small ? 20 : 50; 463 614 var truncLen = small ? 25 : 45; ··· 499 650 animTo = { zoom: targetZoom, panX: -targetX, panY: -targetY }; 500 651 animStart = Date.now(); 501 652 animating = true; 653 + scheduleFrame(); 502 654 } 503 655 504 656 function loop() { 657 + frameRequested = false; 505 658 tickAnimation(); 506 659 render(); 507 - requestAnimationFrame(loop); 660 + // keep looping only while animating 661 + if (animating) { 662 + scheduleFrame(); 663 + } 508 664 } 509 665 510 666 // --- hover state --- ··· 532 688 var d2 = screenToData(e.clientX, e.clientY); 533 689 view.panX += d2[0] - d[0]; 534 690 view.panY += d2[1] - d[1]; 535 - view.dirty = true; 691 + markDirty(); 536 692 }, { passive: false }); 537 693 538 694 canvas.addEventListener('mousedown', function(e) { ··· 548 704 cacheTransform(); 549 705 view.panX = dragStartPanX + (e.clientX - dragStartX) / scale; 550 706 view.panY = dragStartPanY + (e.clientY - dragStartY) / scale; 551 - view.dirty = true; 707 + markDirty(); 552 708 hideTooltip(); 553 709 return; 554 710 } ··· 556 712 var idx = findNearest(mouseX, mouseY, HIT_RADIUS); 557 713 if (idx !== hoveredIndex) { 558 714 hoveredIndex = idx; 559 - view.dirty = true; 715 + markDirty(); 560 716 if (idx >= 0) showTooltip(idx, mouseX, mouseY); 561 717 else hideTooltip(); 562 718 } ··· 614 770 cacheTransform(); 615 771 view.panX = dragStartPanX + (touches[ids[0]].x - dragStartX) / scale; 616 772 view.panY = dragStartPanY + (touches[ids[0]].y - dragStartY) / scale; 617 - view.dirty = true; 773 + markDirty(); 618 774 hideTooltip(); 619 775 selectedIndex = -1; 620 776 } else if (ids.length === 2) { ··· 628 784 var midDataNew = screenToData(pinchMidX, pinchMidY); 629 785 view.panX += midDataNew[0] - midDataOld[0]; 630 786 view.panY += midDataNew[1] - midDataOld[1]; 631 - view.dirty = true; 787 + markDirty(); 632 788 } 633 789 }, { passive: false }); 634 790 ··· 661 817 selectedIndex = idx; 662 818 hoveredIndex = idx; 663 819 showTooltip(idx, tx, ty); 664 - view.dirty = true; 820 + markDirty(); 665 821 } 666 822 } else { 667 823 // tapped empty space — dismiss 668 824 selectedIndex = -1; 669 825 hideTooltip(); 670 - view.dirty = true; 826 + markDirty(); 671 827 } 672 828 } 673 829 } ··· 684 840 tooltipTitle.textContent = p.title || '(untitled)'; 685 841 tooltipMeta.textContent = p.basePath || p.uri; 686 842 tooltipPlatform.textContent = p.platform; 687 - var c = getColors()[p.platform] || getColors().other; 843 + var c = frameColors[p.platform] || frameColors.other; 688 844 tooltipPlatform.style.background = c.edge; 689 845 tooltipPlatform.style.color = c.core; 690 846 tooltip.style.display = 'block'; ··· 736 892 737 893 function renderLegend() { 738 894 var el = document.getElementById('legend'); 739 - var colors = getColors(); 895 + if (!frameColors) cacheFrameColors(); 740 896 var html = ''; 741 897 for (var i = 0; i < PLATFORMS.length; i++) { 742 898 var p = PLATFORMS[i]; 743 899 var dimmed = activePlatforms && !activePlatforms.has(p) ? ' dimmed' : ''; 744 - html += '<div class="legend-item' + dimmed + '" data-platform="' + p + '"><span class="legend-dot" style="background:' + colors[p].mid + '"></span>' + p + '</div>'; 900 + html += '<div class="legend-item' + dimmed + '" data-platform="' + p + '"><span class="legend-dot" style="background:' + frameColors[p].mid + '"></span>' + p + '</div>'; 745 901 } 746 902 el.innerHTML = html; 747 903 // attach click handlers ··· 767 923 if (activePlatforms.size === PLATFORMS.length) activePlatforms = null; 768 924 } 769 925 renderLegend(); 770 - view.dirty = true; 926 + markDirty(); 771 927 } 772 928 773 929 function loadData() { ··· 795 951 for (var i = 0; i < n; i++) { 796 952 uriToIndex.set(d.points[i].uri, i); 797 953 } 954 + // build cluster metadata: fine cluster array, dominant platform, spatial radius 955 + clusterFineArr = new Uint8Array(n); 956 + var coarsePlatCounts = {}; 957 + var finePlatCounts = {}; 958 + for (var i = 0; i < n; i++) { 959 + var cc = d.points[i].clusterCoarse; 960 + var cf = d.points[i].clusterFine; 961 + clusterFineArr[i] = cf; 962 + if (!coarsePlatCounts[cc]) coarsePlatCounts[cc] = new Uint16Array(PLATFORMS.length); 963 + if (!finePlatCounts[cf]) finePlatCounts[cf] = new Uint16Array(PLATFORMS.length); 964 + coarsePlatCounts[cc][platformIdx[i]]++; 965 + finePlatCounts[cf][platformIdx[i]]++; 966 + } 967 + function dominantPlatform(counts) { 968 + if (!counts) return 'other'; 969 + var best = 0, bestP = 0; 970 + for (var p = 0; p < PLATFORMS.length; p++) { 971 + if (counts[p] > best) { best = counts[p]; bestP = p; } 972 + } 973 + return PLATFORMS[bestP]; 974 + } 975 + var coarseById = {}; 976 + for (var c = 0; c < d.clusters.coarse.length; c++) { 977 + var cl = d.clusters.coarse[c]; 978 + cl.dominantPlatform = dominantPlatform(coarsePlatCounts[cl.id]); 979 + cl._distSum = 0; cl._distN = 0; 980 + coarseById[cl.id] = cl; 981 + } 982 + var fineById = {}; 983 + for (var c = 0; c < d.clusters.fine.length; c++) { 984 + var cl = d.clusters.fine[c]; 985 + cl.dominantPlatform = dominantPlatform(finePlatCounts[cl.id]); 986 + cl._distSum = 0; cl._distN = 0; 987 + fineById[cl.id] = cl; 988 + } 989 + for (var i = 0; i < n; i++) { 990 + var ccl = coarseById[d.points[i].clusterCoarse]; 991 + if (ccl) { 992 + var dx = pointsX[i] - ccl.cx, dy = pointsY[i] - ccl.cy; 993 + ccl._distSum += Math.sqrt(dx * dx + dy * dy); 994 + ccl._distN++; 995 + } 996 + var fcl = fineById[d.points[i].clusterFine]; 997 + if (fcl) { 998 + var dx = pointsX[i] - fcl.cx, dy = pointsY[i] - fcl.cy; 999 + fcl._distSum += Math.sqrt(dx * dx + dy * dy); 1000 + fcl._distN++; 1001 + } 1002 + } 1003 + for (var c = 0; c < d.clusters.coarse.length; c++) { 1004 + var cl = d.clusters.coarse[c]; 1005 + cl.radius = cl._distN > 0 ? (cl._distSum / cl._distN) * 2 : 0.05; 1006 + } 1007 + for (var c = 0; c < d.clusters.fine.length; c++) { 1008 + var cl = d.clusters.fine[c]; 1009 + cl.radius = cl._distN > 0 ? (cl._distSum / cl._distN) * 2 : 0.02; 1010 + } 798 1011 buildSpatialIndex(); 799 1012 renderLegend(); 800 1013 document.getElementById('stats').textContent = ··· 802 1015 d.clusters.coarse.length + ' regions \u00B7 ' + 803 1016 d.clusters.fine.length + ' clusters'; 804 1017 document.getElementById('loading').classList.add('hidden'); 805 - view.dirty = true; 1018 + markDirty(); 806 1019 // jump to specific document by URI (from "view on atlas" links) 807 1020 if (pendingUri) { 808 1021 var idx = uriToIndex.get(pendingUri); ··· 861 1074 .catch(function() { return null; }); 862 1075 } 863 1076 loadData(); 864 - loop(); 1077 + scheduleFrame(); 865 1078 866 1079 function setSearchStatus(msg) { 867 1080 if (!searchStatusEl) { ··· 882 1095 url.searchParams.delete('q'); 883 1096 history.replaceState(null, '', url); 884 1097 } 885 - view.dirty = true; 1098 + markDirty(); 886 1099 } 887 1100 888 1101 function applySearchResults(resp, query) { ··· 892 1105 setSearchStatus('no results'); 893 1106 searchMatches = null; 894 1107 searchCenter = null; 895 - view.dirty = true; 1108 + markDirty(); 896 1109 return; 897 1110 } 898 1111 ··· 915 1128 setSearchStatus(results.length + ' results, 0 on map'); 916 1129 searchMatches = null; 917 1130 searchCenter = null; 918 - view.dirty = true; 1131 + markDirty(); 919 1132 return; 920 1133 } 921 1134 ··· 985 1198 setDirty: function() { 986 1199 sprites = null; 987 1200 dotSprites = null; 1201 + haloSprites = null; 988 1202 renderLegend(); 989 - view.dirty = true; 1203 + markDirty(); 990 1204 } 991 1205 }; 992 1206 })();