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: fix label scaling + eliminate shadowBlur + batch connection lines

- labels now use fixed screen-size fonts (11-12px) instead of
shrinking with zoom — readable at all zoom levels
- replaced shadowBlur (gaussian blur per fillText = perf killer)
with strokeText outline — same readability, orders of magnitude cheaper
- connection lines batched into 3 opacity buckets with single
beginPath/stroke per bucket instead of per-line style changes
- inlined coordinate transform (cx/cy/scale cached per frame)

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

+89 -93
+89 -93
site/constellation.js
··· 1 1 (function() { 2 2 'use strict'; 3 3 4 - // --- platform colors: [core, mid, edge] triplets --- 5 4 var PLATFORM_COLORS = { 6 5 leaflet: { core: '#4ade80', mid: '#22c55e', edge: '#166534' }, 7 6 whitewind: { core: '#60a5fa', mid: '#3b82f6', edge: '#1e3a8a' }, ··· 36 35 37 36 // --- data --- 38 37 var data = null; 39 - var pointsX = null; // Float32Array 38 + var pointsX = null; 40 39 var pointsY = null; 41 - var platformIdx = null; // Uint8Array — index into PLATFORMS 40 + var platformIdx = null; 42 41 var gridIndex = null; 43 42 44 43 // --- canvas --- ··· 47 46 var dpr = window.devicePixelRatio || 1; 48 47 var W, H; 49 48 50 - // --- sprite cache: pre-rendered point images per platform --- 51 - // sprites[platformIndex] = { normal: OffscreenCanvas, hover: OffscreenCanvas } 49 + // --- sprite cache --- 52 50 var sprites = null; 53 - var spriteSize = 0; // current sprite pixel size 54 - var spriteTheme = null; // 'dark' or 'light' — rebuild on change 51 + var spriteSize = 0; 52 + var spriteTheme = null; 55 53 56 54 function buildSprites(radius) { 57 55 var size = Math.ceil(radius * 6 * dpr) + 2; 58 56 if (size < 4) size = 4; 59 - var half = size / 2; 60 - var colors = getColors(); 61 57 var theme = isDark() ? 'dark' : 'light'; 62 - 63 - // skip rebuild if nothing changed 64 58 if (sprites && spriteSize === size && spriteTheme === theme) return; 65 59 spriteSize = size; 66 60 spriteTheme = theme; 67 - 61 + var colors = getColors(); 68 62 sprites = []; 69 63 for (var p = 0; p < PLATFORMS.length; p++) { 70 64 var c = colors[PLATFORMS[p]]; 71 65 sprites.push({ 72 - normal: makeSprite(size, half, radius * dpr, c, 0.7), 73 - hover: makeSprite(size * 2, size, radius * dpr * 2, c, 1.0), 66 + normal: makeSprite(size, radius * dpr, c, 0.7), 67 + hover: makeSprite(size * 2, radius * dpr * 2, c, 1.0), 74 68 }); 75 69 } 76 70 } 77 71 78 - function makeSprite(size, half, r, colors, alpha) { 72 + function makeSprite(size, r, colors, alpha) { 79 73 var cv = document.createElement('canvas'); 80 74 cv.width = size; cv.height = size; 81 75 var c = cv.getContext('2d'); 82 - 83 - // radial gradient — drawn once, stamped many times 76 + var half = size / 2; 84 77 var grad = c.createRadialGradient(half, half, 0, half, half, r * 2.5); 85 78 grad.addColorStop(0, colors.core); 86 79 grad.addColorStop(0.3, colors.mid); 87 80 grad.addColorStop(0.7, colors.edge); 88 81 grad.addColorStop(1, 'rgba(0,0,0,0)'); 89 - 90 82 c.globalAlpha = alpha; 91 83 c.fillStyle = grad; 92 84 c.beginPath(); 93 85 c.arc(half, half, r * 2.5, 0, Math.PI * 2); 94 86 c.fill(); 95 - 96 - // bright core 97 87 c.globalAlpha = alpha * 0.9; 98 88 c.fillStyle = colors.core; 99 89 c.beginPath(); 100 90 c.arc(half, half, r * 0.5, 0, Math.PI * 2); 101 91 c.fill(); 102 - 103 92 return cv; 104 93 } 105 94 106 - // --- tiny dot sprite for zoomed-out view (1-2px per point) --- 107 95 var dotSprites = null; 108 96 var dotTheme = null; 109 97 ··· 127 115 } 128 116 } 129 117 130 - // --- resize --- 131 118 function resizeCanvas() { 132 119 W = window.innerWidth; 133 120 H = window.innerHeight; ··· 136 123 canvas.style.width = W + 'px'; 137 124 canvas.style.height = H + 'px'; 138 125 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 139 - sprites = null; // force rebuild 126 + sprites = null; 140 127 dotSprites = null; 141 128 view.dirty = true; 142 129 } 143 130 144 - // --- coordinate transforms --- 145 - function dataToScreenX(dx) { 146 - return W / 2 + (dx + view.panX) * Math.min(W, H) * 0.42 * view.zoom; 147 - } 148 - function dataToScreenY(dy) { 149 - return H / 2 + (dy + view.panY) * Math.min(W, H) * 0.42 * view.zoom; 131 + // --- coordinate transforms (inlined for hot path) --- 132 + var scale = 1; // cached per frame 133 + var cx, cy; 134 + 135 + function cacheTransform() { 136 + scale = Math.min(W, H) * 0.42 * view.zoom; 137 + cx = W / 2 + view.panX * scale; 138 + cy = H / 2 + view.panY * scale; 150 139 } 151 140 152 141 function screenToData(sx, sy) { 153 - var scale = Math.min(W, H) * 0.42 * view.zoom; 154 142 return [(sx - W / 2) / scale - view.panX, (sy - H / 2) / scale - view.panY]; 155 143 } 156 144 157 - // --- spatial index (grid-based) --- 145 + // --- spatial index --- 158 146 function buildSpatialIndex() { 159 147 if (!data) return; 160 148 var cellSize = 0.02; ··· 170 158 if (!gridIndex) return -1; 171 159 var d = screenToData(sx, sy); 172 160 var dx = d[0], dy = d[1]; 173 - var searchRadius = maxDist / (Math.min(W, H) * 0.42 * view.zoom); 161 + var searchRadius = maxDist / scale; 174 162 var cs = gridIndex.cellSize; 175 163 var gxMin = Math.floor((dx - searchRadius) / cs); 176 164 var gxMax = Math.floor((dx + searchRadius) / cs); 177 165 var gyMin = Math.floor((dy - searchRadius) / cs); 178 166 var gyMax = Math.floor((dy + searchRadius) / cs); 179 167 var bestIdx = -1, bestDist = searchRadius * searchRadius; 180 - 181 168 for (var gx = gxMin; gx <= gxMax; gx++) { 182 169 for (var gy = gyMin; gy <= gyMax; gy++) { 183 170 var cell = gridIndex.cells[gx + ',' + gy]; ··· 193 180 return bestIdx; 194 181 } 195 182 183 + // --- label helper: strokeText outline instead of shadowBlur --- 184 + function drawLabel(text, x, y, dark) { 185 + ctx.strokeStyle = dark ? 'rgba(0,0,0,0.7)' : 'rgba(255,255,255,0.7)'; 186 + ctx.lineWidth = 3; 187 + ctx.lineJoin = 'round'; 188 + ctx.strokeText(text, x, y); 189 + ctx.fillText(text, x, y); 190 + } 191 + 196 192 // --- rendering --- 197 193 function render() { 198 194 if (!data || !view.dirty) return; ··· 202 198 var zoom = view.zoom; 203 199 var n = data.points.length; 204 200 201 + cacheTransform(); 202 + 205 203 // background 206 204 ctx.globalAlpha = 1; 207 205 ctx.fillStyle = dark ? '#050505' : '#f5f5f0'; 208 206 ctx.fillRect(0, 0, W, H); 209 207 210 - // visible bounds in data space (with padding) 208 + // visible bounds in data space 211 209 var tl = screenToData(0, 0); 212 210 var br = screenToData(W, H); 213 211 var pad = 0.05; 214 212 var xMin = tl[0] - pad, xMax = br[0] + pad; 215 213 var yMin = tl[1] - pad, yMax = br[1] + pad; 216 214 217 - // --- cluster glows (zoomed out, few items — OK to use gradients) --- 215 + // --- cluster glows (zoomed out) --- 218 216 if (zoom < 4) { 219 217 var clusters = zoom < 2 ? data.clusters.coarse : data.clusters.fine; 220 218 ctx.globalAlpha = zoom < 2 ? 0.6 : 0.3; 221 219 for (var c = 0; c < clusters.length; c++) { 222 220 var cl = clusters[c]; 223 - var sx = dataToScreenX(cl.cx), sy = dataToScreenY(cl.cy); 221 + var sx = cx + cl.cx * scale, sy = cy + cl.cy * scale; 224 222 if (sx < -100 || sx > W + 100 || sy < -100 || sy > H + 100) continue; 225 223 var r = Math.sqrt(cl.count) * 2; 226 224 var grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, r); ··· 233 231 } 234 232 } 235 233 236 - // --- connection lines (zoomed in, spatial-index accelerated) --- 234 + // --- connection lines (batched by opacity bucket) --- 237 235 if (zoom >= 3 && gridIndex) { 238 - var connRadius = 0.025; // data-space distance for connections 236 + var connRadius = 0.025; 239 237 var cs = gridIndex.cellSize; 240 - var lineColor = dark ? 'rgba(255,255,255,' : 'rgba(0,0,0,'; 241 - ctx.lineWidth = 0.5; 242 - var drawn = {}; // avoid duplicate edges 243 - var maxLines = 2000; 238 + var maxLines = 1500; 244 239 var lineCount = 0; 245 240 241 + // batch into 3 opacity buckets to minimize style changes 242 + var buckets = [[], [], []]; // near, mid, far 243 + 246 244 for (var i = 0; i < n && lineCount < maxLines; i++) { 247 245 var px = pointsX[i], py = pointsY[i]; 248 246 if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 249 247 248 + var sx1 = cx + px * scale, sy1 = cy + py * scale; 250 249 var gxMin2 = Math.floor((px - connRadius) / cs); 251 250 var gxMax2 = Math.floor((px + connRadius) / cs); 252 251 var gyMin2 = Math.floor((py - connRadius) / cs); 253 252 var gyMax2 = Math.floor((py + connRadius) / cs); 254 253 255 - var sx1 = dataToScreenX(px), sy1 = dataToScreenY(py); 256 - 257 254 for (var gx = gxMin2; gx <= gxMax2 && lineCount < maxLines; gx++) { 258 255 for (var gy = gyMin2; gy <= gyMax2 && lineCount < maxLines; gy++) { 259 256 var cell = gridIndex.cells[gx + ',' + gy]; 260 257 if (!cell) continue; 261 258 for (var k = 0; k < cell.length && lineCount < maxLines; k++) { 262 259 var j = cell[k]; 263 - if (j <= i) continue; // avoid duplicates 260 + if (j <= i) continue; 264 261 var dx = pointsX[j] - px, dy = pointsY[j] - py; 265 262 var dist2 = dx * dx + dy * dy; 266 263 if (dist2 > connRadius * connRadius || dist2 < 0.0001) continue; 267 - var dist = Math.sqrt(dist2); 268 - var opacity = (1 - dist / connRadius) * 0.12; 269 - ctx.beginPath(); 270 - ctx.moveTo(sx1, sy1); 271 - ctx.lineTo(dataToScreenX(pointsX[j]), dataToScreenY(pointsY[j])); 272 - ctx.strokeStyle = lineColor + opacity + ')'; 273 - ctx.stroke(); 264 + var t = Math.sqrt(dist2) / connRadius; // 0=close, 1=far 265 + var bucket = t < 0.33 ? 0 : t < 0.66 ? 1 : 2; 266 + buckets[bucket].push(sx1, sy1, cx + pointsX[j] * scale, cy + pointsY[j] * scale); 274 267 lineCount++; 275 268 } 276 269 } 277 270 } 278 271 } 272 + 273 + // draw each bucket in one path 274 + var opacities = dark 275 + ? ['rgba(255,255,255,0.10)', 'rgba(255,255,255,0.06)', 'rgba(255,255,255,0.03)'] 276 + : ['rgba(0,0,0,0.08)', 'rgba(0,0,0,0.05)', 'rgba(0,0,0,0.02)']; 277 + ctx.lineWidth = 0.5; 278 + for (var b = 0; b < 3; b++) { 279 + var buf = buckets[b]; 280 + if (!buf.length) continue; 281 + ctx.beginPath(); 282 + for (var l = 0; l < buf.length; l += 4) { 283 + ctx.moveTo(buf[l], buf[l + 1]); 284 + ctx.lineTo(buf[l + 2], buf[l + 3]); 285 + } 286 + ctx.strokeStyle = opacities[b]; 287 + ctx.globalAlpha = 1; 288 + ctx.stroke(); 289 + } 279 290 } 280 291 281 292 // --- points: sprite-stamped --- 282 293 ctx.globalAlpha = 1; 283 - 284 294 var useGlow = zoom >= 2; 285 295 if (useGlow) { 286 296 var pointR = zoom < 5 ? 1.5 + zoom * 0.3 : 2 + zoom * 0.2; ··· 292 302 for (var i = 0; i < n; i++) { 293 303 var px = pointsX[i], py = pointsY[i]; 294 304 if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 295 - 296 - var sx = dataToScreenX(px), sy = dataToScreenY(py); 305 + var sx = cx + px * scale, sy = cy + py * scale; 297 306 var pi = platformIdx[i]; 298 - 299 307 if (i === hoveredIndex && useGlow) { 300 308 var spr = sprites[pi].hover; 301 309 ctx.drawImage(spr, sx - spr.width / (2 * dpr), sy - spr.height / (2 * dpr), spr.width / dpr, spr.height / dpr); ··· 308 316 } 309 317 } 310 318 311 - // --- labels --- 319 + // --- labels (no shadowBlur — uses strokeText outline instead) --- 312 320 ctx.globalAlpha = 1; 313 - var labelColor = dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)'; 314 - ctx.fillStyle = labelColor; 321 + ctx.fillStyle = dark ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.65)'; 315 322 ctx.textAlign = 'center'; 316 323 ctx.textBaseline = 'middle'; 317 - ctx.shadowColor = dark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.8)'; 318 - ctx.shadowBlur = 4; 319 324 320 325 if (zoom < 2) { 321 - var fontSize = Math.max(10, 13 / zoom); 322 - ctx.font = fontSize + 'px monospace'; 323 - ctx.globalAlpha = 0.8; 326 + // coarse labels — fixed 12px screen size 327 + ctx.font = '12px monospace'; 328 + ctx.globalAlpha = 0.85; 324 329 for (var c = 0; c < data.clusters.coarse.length; c++) { 325 330 var cl = data.clusters.coarse[c]; 326 - var sx = dataToScreenX(cl.cx), sy = dataToScreenY(cl.cy); 331 + var sx = cx + cl.cx * scale, sy = cy + cl.cy * scale; 327 332 if (sx < -50 || sx > W + 50 || sy < -20 || sy > H + 20) continue; 328 - ctx.fillText(cl.label, sx, sy - Math.sqrt(cl.count) * 1.5); 333 + drawLabel(cl.label, sx, sy - Math.sqrt(cl.count) * 1.5, dark); 329 334 } 330 335 } else if (zoom < 5) { 331 - var fontSize = Math.max(9, 11 / (zoom * 0.5)); 332 - ctx.font = fontSize + 'px monospace'; 333 - ctx.globalAlpha = 0.7; 336 + // fine labels — fixed 11px screen size 337 + ctx.font = '11px monospace'; 338 + ctx.globalAlpha = 0.75; 334 339 for (var c = 0; c < data.clusters.fine.length; c++) { 335 340 var cl = data.clusters.fine[c]; 336 341 if (cl.cx < xMin || cl.cx > xMax || cl.cy < yMin || cl.cy > yMax) continue; 337 - var sx = dataToScreenX(cl.cx), sy = dataToScreenY(cl.cy); 342 + var sx = cx + cl.cx * scale, sy = cy + cl.cy * scale; 338 343 if (sx < -50 || sx > W + 50 || sy < -20 || sy > H + 20) continue; 339 - ctx.fillText(cl.label, sx, sy - 12); 344 + drawLabel(cl.label, sx, sy - 14, dark); 340 345 } 341 346 } else { 342 - var fontSize = Math.min(12, 10 / (zoom * 0.15)); 343 - ctx.font = fontSize + 'px monospace'; 344 - ctx.globalAlpha = 0.6; 345 - var shown = 0, maxLabels = 60; 347 + // document titles — fixed 11px, readable at any zoom 348 + ctx.font = '11px monospace'; 349 + ctx.globalAlpha = 0.7; 350 + var shown = 0, maxLabels = 50; 346 351 for (var i = 0; i < n && shown < maxLabels; i++) { 347 352 var px = pointsX[i], py = pointsY[i]; 348 353 if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 349 354 var title = data.points[i].title; 350 355 if (!title) continue; 351 - var sx = dataToScreenX(px), sy = dataToScreenY(py); 356 + var sx = cx + px * scale, sy = cy + py * scale; 352 357 if (sx < 0 || sx > W || sy < 0 || sy > H) continue; 353 - if (title.length > 40) title = title.substring(0, 38) + '\u2026'; 354 - ctx.fillText(title, sx, sy - (useGlow ? sprites[0].normal.height / (2 * dpr) : 4) - 4); 358 + if (title.length > 45) title = title.substring(0, 43) + '\u2026'; 359 + drawLabel(title, sx, sy - 10, dark); 355 360 shown++; 356 361 } 357 362 } 358 363 359 - ctx.shadowBlur = 0; 360 364 ctx.globalAlpha = 1; 361 365 } 362 366 ··· 376 380 var dragStartX, dragStartY, dragStartPanX, dragStartPanY; 377 381 var pinchStartDist = 0, pinchStartZoom = 1; 378 382 379 - // --- interaction: mouse --- 380 383 canvas.addEventListener('wheel', function(e) { 381 384 e.preventDefault(); 382 385 var factor = e.deltaY > 0 ? 0.9 : 1.1; 383 386 var newZoom = Math.max(view.minZoom, Math.min(view.maxZoom, view.zoom * factor)); 387 + cacheTransform(); 384 388 var d = screenToData(e.clientX, e.clientY); 385 389 view.zoom = newZoom; 390 + cacheTransform(); 386 391 var d2 = screenToData(e.clientX, e.clientY); 387 392 view.panX += d2[0] - d[0]; 388 393 view.panY += d2[1] - d[1]; ··· 399 404 window.addEventListener('mousemove', function(e) { 400 405 mouseX = e.clientX; mouseY = e.clientY; 401 406 if (dragging) { 402 - var scale = Math.min(W, H) * 0.42 * view.zoom; 407 + cacheTransform(); 403 408 view.panX = dragStartPanX + (e.clientX - dragStartX) / scale; 404 409 view.panY = dragStartPanY + (e.clientY - dragStartY) / scale; 405 410 view.dirty = true; ··· 408 413 } 409 414 clearTimeout(hoverTimer); 410 415 hoverTimer = setTimeout(function() { 416 + cacheTransform(); 411 417 var idx = findNearest(mouseX, mouseY, 20); 412 418 if (idx !== hoveredIndex) { 413 419 hoveredIndex = idx; ··· 459 465 } 460 466 var ids = Object.keys(touches); 461 467 if (ids.length === 1 && dragging) { 462 - var scale = Math.min(W, H) * 0.42 * view.zoom; 468 + cacheTransform(); 463 469 view.panX = dragStartPanX + (touches[ids[0]].x - dragStartX) / scale; 464 470 view.panY = dragStartPanY + (touches[ids[0]].y - dragStartY) / scale; 465 471 view.dirty = true; ··· 506 512 canvas.style.cursor = dragging ? 'grabbing' : 'grab'; 507 513 } 508 514 509 - // --- AT URI to URL --- 510 515 function atUriToUrl(uri, basePath, platform) { 511 516 var m = uri.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/(.+)$/); 512 517 if (!m) return null; ··· 516 521 return 'https://pds.pub/at/' + encodeURIComponent(uri); 517 522 } 518 523 519 - // --- legend --- 520 524 function renderLegend() { 521 525 var el = document.getElementById('legend'); 522 526 var colors = getColors(); ··· 527 531 el.innerHTML = html; 528 532 } 529 533 530 - // --- load data --- 531 534 function loadData() { 532 535 fetch('constellation.json') 533 536 .then(function(r) { ··· 540 543 pointsX = new Float32Array(n); 541 544 pointsY = new Float32Array(n); 542 545 platformIdx = new Uint8Array(n); 543 - 544 - // build platform lookup 545 546 var platMap = {}; 546 547 for (var p = 0; p < PLATFORMS.length; p++) platMap[PLATFORMS[p]] = p; 547 548 var otherIdx = platMap.other; 548 - 549 549 for (var i = 0; i < n; i++) { 550 550 pointsX[i] = d.points[i].x; 551 551 pointsY[i] = d.points[i].y; 552 552 platformIdx[i] = platMap[d.points[i].platform] !== undefined ? platMap[d.points[i].platform] : otherIdx; 553 553 } 554 - 555 554 buildSpatialIndex(); 556 555 renderLegend(); 557 - 558 556 document.getElementById('stats').textContent = 559 557 n.toLocaleString() + ' documents \u00B7 ' + 560 558 d.clusters.coarse.length + ' regions \u00B7 ' + 561 559 d.clusters.fine.length + ' clusters'; 562 - 563 560 document.getElementById('loading').classList.add('hidden'); 564 561 view.dirty = true; 565 562 }) ··· 569 566 }); 570 567 } 571 568 572 - // --- init --- 573 569 window.addEventListener('resize', resizeCanvas); 574 570 resizeCanvas(); 575 571 loadData();