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.

fix: dotenv priority in scripts, add connection lines to constellation

- all scripts now prioritize .env file over environment variables
(fixes stale env var overriding fresh .env keys)
- purged 4 remaining bridgy fed vectors from turbopuffer
- rebuilt constellation data (38,624 points, 81 coarse / 414 fine clusters)
- added post-particles-inspired connection lines between nearby points
when zoomed in (>= 3x), using spatial index for O(n) performance

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

+269 -245
+10
scripts/mark-bridgyfed
··· 36 36 turso_url: str 37 37 turso_token: str 38 38 39 + @classmethod 40 + def settings_customise_sources(cls, settings_cls, **kwargs): 41 + """Dotenv file wins over environment variables.""" 42 + return ( 43 + kwargs["init_settings"], 44 + kwargs["dotenv_settings"], 45 + kwargs["env_settings"], 46 + kwargs["file_secret_settings"], 47 + ) 48 + 39 49 @property 40 50 def turso_host(self) -> str: 41 51 url = self.turso_url
+10
scripts/purge-bridgyfed-vectors
··· 40 40 turbopuffer_api_key: str 41 41 turbopuffer_namespace: str = "leaflet-search" 42 42 43 + @classmethod 44 + def settings_customise_sources(cls, settings_cls, **kwargs): 45 + """Dotenv file wins over environment variables.""" 46 + return ( 47 + kwargs["init_settings"], 48 + kwargs["dotenv_settings"], 49 + kwargs["env_settings"], 50 + kwargs["file_secret_settings"], 51 + ) 52 + 43 53 44 54 async def resolve_pds( 45 55 client: httpx.AsyncClient, did: str, semaphore: asyncio.Semaphore
+249 -245
site/constellation.js
··· 11 11 other: { core: '#9ca3af', mid: '#6b7280', edge: '#374151' }, 12 12 }; 13 13 14 - // light theme overrides (darker cores for visibility) 15 14 var PLATFORM_COLORS_LIGHT = { 16 15 leaflet: { core: '#16a34a', mid: '#15803d', edge: '#a7f3d0' }, 17 16 whitewind: { core: '#2563eb', mid: '#1d4ed8', edge: '#bfdbfe' }, ··· 21 20 other: { core: '#4b5563', mid: '#374151', edge: '#d1d5db' }, 22 21 }; 23 22 23 + var PLATFORMS = ['leaflet', 'whitewind', 'pckt', 'offprint', 'greengale', 'other']; 24 + 24 25 function getColors() { 25 - var theme = document.documentElement.getAttribute('data-theme'); 26 - return theme === 'light' ? PLATFORM_COLORS_LIGHT : PLATFORM_COLORS; 26 + return document.documentElement.getAttribute('data-theme') === 'light' 27 + ? PLATFORM_COLORS_LIGHT : PLATFORM_COLORS; 27 28 } 28 29 29 30 function isDark() { ··· 31 32 } 32 33 33 34 // --- view state --- 34 - var view = { 35 - zoom: 1, 36 - panX: 0, 37 - panY: 0, 38 - minZoom: 0.5, 39 - maxZoom: 15, 40 - dirty: true, 41 - }; 35 + var view = { zoom: 1, panX: 0, panY: 0, minZoom: 0.5, maxZoom: 15, dirty: true }; 42 36 43 37 // --- data --- 44 38 var data = null; 45 - var pointsX = null; // Float32Array 39 + var pointsX = null; // Float32Array 46 40 var pointsY = null; 47 - var gridIndex = null; // spatial index for hover 41 + var platformIdx = null; // Uint8Array — index into PLATFORMS 42 + var gridIndex = null; 48 43 49 44 // --- canvas --- 50 45 var canvas = document.getElementById('canvas'); ··· 52 47 var dpr = window.devicePixelRatio || 1; 53 48 var W, H; 54 49 55 - // --- hover state --- 56 - var hoveredIndex = -1; 57 - var hoverTimer = null; 58 - var mouseX = 0, mouseY = 0; 50 + // --- sprite cache: pre-rendered point images per platform --- 51 + // sprites[platformIndex] = { normal: OffscreenCanvas, hover: OffscreenCanvas } 52 + var sprites = null; 53 + var spriteSize = 0; // current sprite pixel size 54 + var spriteTheme = null; // 'dark' or 'light' — rebuild on change 59 55 60 - // --- interaction state --- 61 - var dragging = false; 62 - var dragStartX, dragStartY; 63 - var dragStartPanX, dragStartPanY; 64 - var pinchStartDist = 0; 65 - var pinchStartZoom = 1; 56 + function buildSprites(radius) { 57 + var size = Math.ceil(radius * 6 * dpr) + 2; 58 + if (size < 4) size = 4; 59 + var half = size / 2; 60 + var colors = getColors(); 61 + var theme = isDark() ? 'dark' : 'light'; 62 + 63 + // skip rebuild if nothing changed 64 + if (sprites && spriteSize === size && spriteTheme === theme) return; 65 + spriteSize = size; 66 + spriteTheme = theme; 66 67 67 - // --- gradient cache --- 68 - var gradientCache = {}; 68 + sprites = []; 69 + for (var p = 0; p < PLATFORMS.length; p++) { 70 + var c = colors[PLATFORMS[p]]; 71 + sprites.push({ 72 + normal: makeSprite(size, half, radius * dpr, c, 0.7), 73 + hover: makeSprite(size * 2, size, radius * dpr * 2, c, 1.0), 74 + }); 75 + } 76 + } 69 77 78 + function makeSprite(size, half, r, colors, alpha) { 79 + var cv = document.createElement('canvas'); 80 + cv.width = size; cv.height = size; 81 + var c = cv.getContext('2d'); 82 + 83 + // radial gradient — drawn once, stamped many times 84 + var grad = c.createRadialGradient(half, half, 0, half, half, r * 2.5); 85 + grad.addColorStop(0, colors.core); 86 + grad.addColorStop(0.3, colors.mid); 87 + grad.addColorStop(0.7, colors.edge); 88 + grad.addColorStop(1, 'rgba(0,0,0,0)'); 89 + 90 + c.globalAlpha = alpha; 91 + c.fillStyle = grad; 92 + c.beginPath(); 93 + c.arc(half, half, r * 2.5, 0, Math.PI * 2); 94 + c.fill(); 95 + 96 + // bright core 97 + c.globalAlpha = alpha * 0.9; 98 + c.fillStyle = colors.core; 99 + c.beginPath(); 100 + c.arc(half, half, r * 0.5, 0, Math.PI * 2); 101 + c.fill(); 102 + 103 + return cv; 104 + } 105 + 106 + // --- tiny dot sprite for zoomed-out view (1-2px per point) --- 107 + var dotSprites = null; 108 + var dotTheme = null; 109 + 110 + function buildDotSprites() { 111 + var theme = isDark() ? 'dark' : 'light'; 112 + if (dotSprites && dotTheme === theme) return; 113 + dotTheme = theme; 114 + var colors = getColors(); 115 + dotSprites = []; 116 + var s = Math.max(4, Math.ceil(3 * dpr)); 117 + for (var p = 0; p < PLATFORMS.length; p++) { 118 + var cv = document.createElement('canvas'); 119 + cv.width = s; cv.height = s; 120 + var c = cv.getContext('2d'); 121 + c.fillStyle = colors[PLATFORMS[p]].mid; 122 + c.globalAlpha = 0.7; 123 + c.beginPath(); 124 + c.arc(s / 2, s / 2, s / 2, 0, Math.PI * 2); 125 + c.fill(); 126 + dotSprites.push(cv); 127 + } 128 + } 129 + 130 + // --- resize --- 70 131 function resizeCanvas() { 71 132 W = window.innerWidth; 72 133 H = window.innerHeight; ··· 75 136 canvas.style.width = W + 'px'; 76 137 canvas.style.height = H + 'px'; 77 138 ctx.setTransform(dpr, 0, 0, dpr, 0, 0); 78 - gradientCache = {}; 139 + sprites = null; // force rebuild 140 + dotSprites = null; 79 141 view.dirty = true; 80 142 } 81 143 82 144 // --- coordinate transforms --- 83 - function dataToScreen(dx, dy) { 84 - var cx = W / 2; 85 - var cy = H / 2; 86 - var scale = Math.min(W, H) * 0.42 * view.zoom; 87 - return [ 88 - cx + (dx + view.panX) * scale, 89 - cy + (dy + view.panY) * scale, 90 - ]; 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; 91 150 } 92 151 93 152 function screenToData(sx, sy) { 94 - var cx = W / 2; 95 - var cy = H / 2; 96 153 var scale = Math.min(W, H) * 0.42 * view.zoom; 97 - return [ 98 - (sx - cx) / scale - view.panX, 99 - (sy - cy) / scale - view.panY, 100 - ]; 154 + return [(sx - W / 2) / scale - view.panX, (sy - H / 2) / scale - view.panY]; 101 155 } 102 156 103 157 // --- spatial index (grid-based) --- 104 158 function buildSpatialIndex() { 105 159 if (!data) return; 106 - var cellSize = 0.02; // in data space 160 + var cellSize = 0.02; 107 161 gridIndex = { cellSize: cellSize, cells: {} }; 108 162 for (var i = 0; i < data.points.length; i++) { 109 - var gx = Math.floor(pointsX[i] / cellSize); 110 - var gy = Math.floor(pointsY[i] / cellSize); 111 - var key = gx + ',' + gy; 163 + var key = Math.floor(pointsX[i] / cellSize) + ',' + Math.floor(pointsY[i] / cellSize); 112 164 if (!gridIndex.cells[key]) gridIndex.cells[key] = []; 113 165 gridIndex.cells[key].push(i); 114 166 } ··· 118 170 if (!gridIndex) return -1; 119 171 var d = screenToData(sx, sy); 120 172 var dx = d[0], dy = d[1]; 121 - var scale = Math.min(W, H) * 0.42 * view.zoom; 122 - var searchRadius = maxDist / scale; 173 + var searchRadius = maxDist / (Math.min(W, H) * 0.42 * view.zoom); 123 174 var cs = gridIndex.cellSize; 124 175 var gxMin = Math.floor((dx - searchRadius) / cs); 125 176 var gxMax = Math.floor((dx + searchRadius) / cs); 126 177 var gyMin = Math.floor((dy - searchRadius) / cs); 127 178 var gyMax = Math.floor((dy + searchRadius) / cs); 128 - 129 - var bestIdx = -1; 130 - var bestDist = searchRadius * searchRadius; 179 + var bestIdx = -1, bestDist = searchRadius * searchRadius; 131 180 132 181 for (var gx = gxMin; gx <= gxMax; gx++) { 133 182 for (var gy = gyMin; gy <= gyMax; gy++) { ··· 135 184 if (!cell) continue; 136 185 for (var k = 0; k < cell.length; k++) { 137 186 var i = cell[k]; 138 - var ddx = pointsX[i] - dx; 139 - var ddy = pointsY[i] - dy; 187 + var ddx = pointsX[i] - dx, ddy = pointsY[i] - dy; 140 188 var dist2 = ddx * ddx + ddy * ddy; 141 - if (dist2 < bestDist) { 142 - bestDist = dist2; 143 - bestIdx = i; 144 - } 189 + if (dist2 < bestDist) { bestDist = dist2; bestIdx = i; } 145 190 } 146 191 } 147 192 } ··· 149 194 } 150 195 151 196 // --- rendering --- 152 - function getPointRadius(zoom) { 153 - if (zoom < 2) return 1.8; 154 - if (zoom < 5) return 1.5 + zoom * 0.3; 155 - return 2 + zoom * 0.2; 156 - } 157 - 158 - function drawPoint(x, y, r, colors, alpha) { 159 - if (r < 1.5) { 160 - // tiny points: simple filled circle 161 - ctx.globalAlpha = alpha; 162 - ctx.fillStyle = colors.mid; 163 - ctx.beginPath(); 164 - ctx.arc(x, y, r, 0, Math.PI * 2); 165 - ctx.fill(); 166 - return; 167 - } 168 - 169 - // celestial body: radial gradient 170 - var cacheKey = colors.core + '_' + Math.round(r * 10); 171 - var grad = gradientCache[cacheKey]; 172 - if (!grad) { 173 - grad = ctx.createRadialGradient(x, y, 0, x, y, r * 2.5); 174 - grad.addColorStop(0, colors.core); 175 - grad.addColorStop(0.3, colors.mid); 176 - grad.addColorStop(0.7, colors.edge); 177 - grad.addColorStop(1, 'transparent'); 178 - // don't cache position-dependent gradients for large radii 179 - } 180 - 181 - // for larger radii, always create fresh (position-dependent) 182 - grad = ctx.createRadialGradient(x, y, 0, x, y, r * 2.5); 183 - grad.addColorStop(0, colors.core); 184 - grad.addColorStop(0.3, colors.mid); 185 - grad.addColorStop(0.7, colors.edge); 186 - grad.addColorStop(1, 'transparent'); 187 - 188 - ctx.globalAlpha = alpha; 189 - ctx.fillStyle = grad; 190 - ctx.beginPath(); 191 - ctx.arc(x, y, r * 2.5, 0, Math.PI * 2); 192 - ctx.fill(); 193 - 194 - // bright core 195 - ctx.globalAlpha = alpha * 0.9; 196 - ctx.fillStyle = colors.core; 197 - ctx.beginPath(); 198 - ctx.arc(x, y, r * 0.5, 0, Math.PI * 2); 199 - ctx.fill(); 200 - } 201 - 202 - function drawClusterGlow(cx, cy, count, alpha) { 203 - var r = Math.sqrt(count) * 2; 204 - var grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); 205 - var dark = isDark(); 206 - grad.addColorStop(0, dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'); 207 - grad.addColorStop(1, 'transparent'); 208 - ctx.globalAlpha = alpha; 209 - ctx.fillStyle = grad; 210 - ctx.beginPath(); 211 - ctx.arc(cx, cy, r, 0, Math.PI * 2); 212 - ctx.fill(); 213 - } 214 - 215 - function drawLabel(text, sx, sy, fontSize, alpha) { 216 - ctx.globalAlpha = alpha; 217 - var dark = isDark(); 218 - ctx.fillStyle = dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)'; 219 - ctx.font = fontSize + 'px monospace'; 220 - ctx.textAlign = 'center'; 221 - ctx.textBaseline = 'middle'; 222 - 223 - // text shadow for readability 224 - if (dark) { 225 - ctx.shadowColor = 'rgba(0,0,0,0.8)'; 226 - ctx.shadowBlur = 4; 227 - } else { 228 - ctx.shadowColor = 'rgba(255,255,255,0.8)'; 229 - ctx.shadowBlur = 4; 230 - } 231 - ctx.fillText(text, sx, sy); 232 - ctx.shadowBlur = 0; 233 - } 234 - 235 197 function render() { 236 198 if (!data || !view.dirty) return; 237 199 view.dirty = false; 238 200 239 201 var dark = isDark(); 240 - ctx.clearRect(0, 0, W, H); 202 + var zoom = view.zoom; 203 + var n = data.points.length; 241 204 242 205 // background 206 + ctx.globalAlpha = 1; 243 207 ctx.fillStyle = dark ? '#050505' : '#f5f5f0'; 244 208 ctx.fillRect(0, 0, W, H); 245 209 246 - var zoom = view.zoom; 247 - var scale = Math.min(W, H) * 0.42 * zoom; 248 - var colors = getColors(); 249 - var r = getPointRadius(zoom); 250 - 251 - // visible bounds in data space 210 + // visible bounds in data space (with padding) 252 211 var tl = screenToData(0, 0); 253 212 var br = screenToData(W, H); 254 213 var pad = 0.05; 255 214 var xMin = tl[0] - pad, xMax = br[0] + pad; 256 215 var yMin = tl[1] - pad, yMax = br[1] + pad; 257 216 258 - // --- cluster glows (zoomed out) --- 217 + // --- cluster glows (zoomed out, few items — OK to use gradients) --- 259 218 if (zoom < 4) { 260 219 var clusters = zoom < 2 ? data.clusters.coarse : data.clusters.fine; 220 + ctx.globalAlpha = zoom < 2 ? 0.6 : 0.3; 261 221 for (var c = 0; c < clusters.length; c++) { 262 222 var cl = clusters[c]; 263 - var sp = dataToScreen(cl.cx, cl.cy); 264 - if (sp[0] < -100 || sp[0] > W + 100 || sp[1] < -100 || sp[1] > H + 100) continue; 265 - drawClusterGlow(sp[0], sp[1], cl.count, zoom < 2 ? 0.6 : 0.3); 223 + var sx = dataToScreenX(cl.cx), sy = dataToScreenY(cl.cy); 224 + if (sx < -100 || sx > W + 100 || sy < -100 || sy > H + 100) continue; 225 + var r = Math.sqrt(cl.count) * 2; 226 + var grad = ctx.createRadialGradient(sx, sy, 0, sx, sy, r); 227 + grad.addColorStop(0, dark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.02)'); 228 + grad.addColorStop(1, 'transparent'); 229 + ctx.fillStyle = grad; 230 + ctx.beginPath(); 231 + ctx.arc(sx, sy, r, 0, Math.PI * 2); 232 + ctx.fill(); 266 233 } 267 234 } 268 235 269 - // --- points --- 270 - var points = data.points; 271 - var n = points.length; 236 + // --- connection lines (zoomed in, spatial-index accelerated) --- 237 + if (zoom >= 3 && gridIndex) { 238 + var connRadius = 0.025; // data-space distance for connections 239 + 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; 244 + var lineCount = 0; 245 + 246 + for (var i = 0; i < n && lineCount < maxLines; i++) { 247 + var px = pointsX[i], py = pointsY[i]; 248 + if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 249 + 250 + var gxMin2 = Math.floor((px - connRadius) / cs); 251 + var gxMax2 = Math.floor((px + connRadius) / cs); 252 + var gyMin2 = Math.floor((py - connRadius) / cs); 253 + var gyMax2 = Math.floor((py + connRadius) / cs); 254 + 255 + var sx1 = dataToScreenX(px), sy1 = dataToScreenY(py); 256 + 257 + for (var gx = gxMin2; gx <= gxMax2 && lineCount < maxLines; gx++) { 258 + for (var gy = gyMin2; gy <= gyMax2 && lineCount < maxLines; gy++) { 259 + var cell = gridIndex.cells[gx + ',' + gy]; 260 + if (!cell) continue; 261 + for (var k = 0; k < cell.length && lineCount < maxLines; k++) { 262 + var j = cell[k]; 263 + if (j <= i) continue; // avoid duplicates 264 + var dx = pointsX[j] - px, dy = pointsY[j] - py; 265 + var dist2 = dx * dx + dy * dy; 266 + 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(); 274 + lineCount++; 275 + } 276 + } 277 + } 278 + } 279 + } 280 + 281 + // --- points: sprite-stamped --- 272 282 ctx.globalAlpha = 1; 273 283 284 + var useGlow = zoom >= 2; 285 + if (useGlow) { 286 + var pointR = zoom < 5 ? 1.5 + zoom * 0.3 : 2 + zoom * 0.2; 287 + buildSprites(pointR); 288 + } else { 289 + buildDotSprites(); 290 + } 291 + 274 292 for (var i = 0; i < n; i++) { 275 - var px = pointsX[i]; 276 - var py = pointsY[i]; 293 + var px = pointsX[i], py = pointsY[i]; 277 294 if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 278 295 279 - var sp = dataToScreen(px, py); 280 - var platform = points[i].platform || 'other'; 281 - var c = colors[platform] || colors.other; 282 - var alpha = (i === hoveredIndex) ? 1.0 : (zoom > 3 ? 0.85 : 0.7); 283 - drawPoint(sp[0], sp[1], (i === hoveredIndex) ? r * 2 : r, c, alpha); 296 + var sx = dataToScreenX(px), sy = dataToScreenY(py); 297 + var pi = platformIdx[i]; 298 + 299 + if (i === hoveredIndex && useGlow) { 300 + var spr = sprites[pi].hover; 301 + ctx.drawImage(spr, sx - spr.width / (2 * dpr), sy - spr.height / (2 * dpr), spr.width / dpr, spr.height / dpr); 302 + } else if (useGlow) { 303 + var spr = sprites[pi].normal; 304 + ctx.drawImage(spr, sx - spr.width / (2 * dpr), sy - spr.height / (2 * dpr), spr.width / dpr, spr.height / dpr); 305 + } else { 306 + var dot = dotSprites[pi]; 307 + ctx.drawImage(dot, sx - dot.width / (2 * dpr), sy - dot.height / (2 * dpr), dot.width / dpr, dot.height / dpr); 308 + } 284 309 } 285 310 286 311 // --- labels --- 287 312 ctx.globalAlpha = 1; 313 + var labelColor = dark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)'; 314 + ctx.fillStyle = labelColor; 315 + ctx.textAlign = 'center'; 316 + ctx.textBaseline = 'middle'; 317 + ctx.shadowColor = dark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.8)'; 318 + ctx.shadowBlur = 4; 288 319 289 320 if (zoom < 2) { 290 - // coarse cluster labels 291 321 var fontSize = Math.max(10, 13 / zoom); 322 + ctx.font = fontSize + 'px monospace'; 323 + ctx.globalAlpha = 0.8; 292 324 for (var c = 0; c < data.clusters.coarse.length; c++) { 293 325 var cl = data.clusters.coarse[c]; 294 - var sp = dataToScreen(cl.cx, cl.cy); 295 - if (sp[0] < -50 || sp[0] > W + 50 || sp[1] < -20 || sp[1] > H + 20) continue; 296 - drawLabel(cl.label, sp[0], sp[1] - Math.sqrt(cl.count) * 1.5, fontSize, 0.8); 326 + var sx = dataToScreenX(cl.cx), sy = dataToScreenY(cl.cy); 327 + 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); 297 329 } 298 330 } else if (zoom < 5) { 299 - // fine cluster labels 300 331 var fontSize = Math.max(9, 11 / (zoom * 0.5)); 332 + ctx.font = fontSize + 'px monospace'; 333 + ctx.globalAlpha = 0.7; 301 334 for (var c = 0; c < data.clusters.fine.length; c++) { 302 335 var cl = data.clusters.fine[c]; 303 336 if (cl.cx < xMin || cl.cx > xMax || cl.cy < yMin || cl.cy > yMax) continue; 304 - var sp = dataToScreen(cl.cx, cl.cy); 305 - if (sp[0] < -50 || sp[0] > W + 50 || sp[1] < -20 || sp[1] > H + 20) continue; 306 - drawLabel(cl.label, sp[0], sp[1] - 12, fontSize, 0.7); 337 + var sx = dataToScreenX(cl.cx), sy = dataToScreenY(cl.cy); 338 + if (sx < -50 || sx > W + 50 || sy < -20 || sy > H + 20) continue; 339 + ctx.fillText(cl.label, sx, sy - 12); 307 340 } 308 341 } else { 309 - // individual document titles 310 342 var fontSize = Math.min(12, 10 / (zoom * 0.15)); 311 - var shown = 0; 312 - var maxLabels = 60; 343 + ctx.font = fontSize + 'px monospace'; 344 + ctx.globalAlpha = 0.6; 345 + var shown = 0, maxLabels = 60; 313 346 for (var i = 0; i < n && shown < maxLabels; i++) { 314 - var px = pointsX[i]; 315 - var py = pointsY[i]; 347 + var px = pointsX[i], py = pointsY[i]; 316 348 if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 317 - var title = points[i].title; 349 + var title = data.points[i].title; 318 350 if (!title) continue; 319 - var sp = dataToScreen(px, py); 320 - if (sp[0] < 0 || sp[0] > W || sp[1] < 0 || sp[1] > H) continue; 321 - // truncate long titles 351 + var sx = dataToScreenX(px), sy = dataToScreenY(py); 352 + if (sx < 0 || sx > W || sy < 0 || sy > H) continue; 322 353 if (title.length > 40) title = title.substring(0, 38) + '\u2026'; 323 - drawLabel(title, sp[0], sp[1] - r * 3 - 4, fontSize, 0.6); 354 + ctx.fillText(title, sx, sy - (useGlow ? sprites[0].normal.height / (2 * dpr) : 4) - 4); 324 355 shown++; 325 356 } 326 357 } 327 358 359 + ctx.shadowBlur = 0; 328 360 ctx.globalAlpha = 1; 329 361 } 330 362 ··· 334 366 requestAnimationFrame(loop); 335 367 } 336 368 369 + // --- hover state --- 370 + var hoveredIndex = -1; 371 + var hoverTimer = null; 372 + var mouseX = 0, mouseY = 0; 373 + 374 + // --- interaction state --- 375 + var dragging = false; 376 + var dragStartX, dragStartY, dragStartPanX, dragStartPanY; 377 + var pinchStartDist = 0, pinchStartZoom = 1; 378 + 337 379 // --- interaction: mouse --- 338 380 canvas.addEventListener('wheel', function(e) { 339 381 e.preventDefault(); 340 382 var factor = e.deltaY > 0 ? 0.9 : 1.1; 341 383 var newZoom = Math.max(view.minZoom, Math.min(view.maxZoom, view.zoom * factor)); 342 - 343 - // zoom toward cursor 344 384 var d = screenToData(e.clientX, e.clientY); 345 385 view.zoom = newZoom; 346 386 var d2 = screenToData(e.clientX, e.clientY); 347 387 view.panX += d2[0] - d[0]; 348 388 view.panY += d2[1] - d[1]; 349 - 350 389 view.dirty = true; 351 - gradientCache = {}; 352 390 }, { passive: false }); 353 391 354 392 canvas.addEventListener('mousedown', function(e) { 355 393 if (e.button !== 0) return; 356 394 dragging = true; 357 - dragStartX = e.clientX; 358 - dragStartY = e.clientY; 359 - dragStartPanX = view.panX; 360 - dragStartPanY = view.panY; 395 + dragStartX = e.clientX; dragStartY = e.clientY; 396 + dragStartPanX = view.panX; dragStartPanY = view.panY; 361 397 }); 362 398 363 399 window.addEventListener('mousemove', function(e) { 364 - mouseX = e.clientX; 365 - mouseY = e.clientY; 366 - 400 + mouseX = e.clientX; mouseY = e.clientY; 367 401 if (dragging) { 368 402 var scale = Math.min(W, H) * 0.42 * view.zoom; 369 403 view.panX = dragStartPanX + (e.clientX - dragStartX) / scale; ··· 372 406 hideTooltip(); 373 407 return; 374 408 } 375 - 376 - // hover with delay 377 409 clearTimeout(hoverTimer); 378 410 hoverTimer = setTimeout(function() { 379 411 var idx = findNearest(mouseX, mouseY, 20); 380 412 if (idx !== hoveredIndex) { 381 413 hoveredIndex = idx; 382 414 view.dirty = true; 383 - if (idx >= 0) { 384 - showTooltip(idx, mouseX, mouseY); 385 - } else { 386 - hideTooltip(); 387 - } 415 + if (idx >= 0) showTooltip(idx, mouseX, mouseY); 416 + else hideTooltip(); 388 417 } 389 418 }, 100); 390 419 }); 391 420 392 421 window.addEventListener('mouseup', function(e) { 393 422 if (dragging) { 394 - var dx = Math.abs(e.clientX - dragStartX); 395 - var dy = Math.abs(e.clientY - dragStartY); 396 - // click detection: small drag = click 397 - if (dx < 4 && dy < 4 && hoveredIndex >= 0) { 423 + if (Math.abs(e.clientX - dragStartX) < 4 && Math.abs(e.clientY - dragStartY) < 4 && hoveredIndex >= 0) { 398 424 var p = data.points[hoveredIndex]; 399 425 var url = atUriToUrl(p.uri, p.basePath, p.platform); 400 426 if (url) window.open(url, '_blank'); ··· 403 429 } 404 430 }); 405 431 406 - // --- interaction: touch --- 432 + // --- touch --- 407 433 var touches = {}; 408 434 409 435 canvas.addEventListener('touchstart', function(e) { ··· 415 441 var ids = Object.keys(touches); 416 442 if (ids.length === 1) { 417 443 dragging = true; 418 - dragStartX = touches[ids[0]].x; 419 - dragStartY = touches[ids[0]].y; 420 - dragStartPanX = view.panX; 421 - dragStartPanY = view.panY; 444 + dragStartX = touches[ids[0]].x; dragStartY = touches[ids[0]].y; 445 + dragStartPanX = view.panX; dragStartPanY = view.panY; 422 446 } else if (ids.length === 2) { 423 447 dragging = false; 424 448 var a = touches[ids[0]], b = touches[ids[1]]; ··· 442 466 } else if (ids.length === 2) { 443 467 var a = touches[ids[0]], b = touches[ids[1]]; 444 468 var dist = Math.hypot(a.x - b.x, a.y - b.y); 445 - var newZoom = pinchStartZoom * (dist / pinchStartDist); 446 - view.zoom = Math.max(view.minZoom, Math.min(view.maxZoom, newZoom)); 469 + view.zoom = Math.max(view.minZoom, Math.min(view.maxZoom, pinchStartZoom * (dist / pinchStartDist))); 447 470 view.dirty = true; 448 - gradientCache = {}; 449 471 } 450 472 }, { passive: false }); 451 473 452 474 canvas.addEventListener('touchend', function(e) { 453 - for (var i = 0; i < e.changedTouches.length; i++) { 454 - delete touches[e.changedTouches[i].identifier]; 455 - } 456 - if (Object.keys(touches).length === 0) { 457 - dragging = false; 458 - } 475 + for (var i = 0; i < e.changedTouches.length; i++) delete touches[e.changedTouches[i].identifier]; 476 + if (Object.keys(touches).length === 0) dragging = false; 459 477 }); 460 478 461 479 // --- tooltip --- ··· 469 487 tooltipTitle.textContent = p.title || '(untitled)'; 470 488 tooltipMeta.textContent = p.basePath || p.uri; 471 489 tooltipPlatform.textContent = p.platform; 472 - var colors = getColors(); 473 - var c = colors[p.platform] || colors.other; 490 + var c = getColors()[p.platform] || getColors().other; 474 491 tooltipPlatform.style.background = c.edge; 475 492 tooltipPlatform.style.color = c.core; 476 - 477 493 tooltip.style.display = 'block'; 478 - // position: avoid going off screen 479 - var tw = tooltip.offsetWidth; 480 - var th = tooltip.offsetHeight; 481 - var tx = sx + 16; 482 - var ty = sy - th - 8; 494 + var tw = tooltip.offsetWidth, th = tooltip.offsetHeight; 495 + var tx = sx + 16, ty = sy - th - 8; 483 496 if (tx + tw > W - 10) tx = sx - tw - 16; 484 497 if (ty < 10) ty = sy + 16; 485 498 tooltip.style.left = tx + 'px'; 486 499 tooltip.style.top = ty + 'px'; 487 - 488 500 canvas.style.cursor = 'pointer'; 489 501 } 490 502 ··· 496 508 497 509 // --- AT URI to URL --- 498 510 function atUriToUrl(uri, basePath, platform) { 499 - // at://did:plc:xxx/collection/rkey 500 511 var m = uri.match(/^at:\/\/(did:[^/]+)\/([^/]+)\/(.+)$/); 501 512 if (!m) return null; 502 513 var did = m[1], collection = m[2], rkey = m[3]; 503 - 504 - if (platform === 'whitewind' || collection.startsWith('com.whtwnd.')) { 505 - return 'https://whtwnd.com/' + did + '/' + rkey; 506 - } 507 - if (basePath) { 508 - return 'https://' + basePath + '/' + rkey; 509 - } 510 - // fallback: try to construct a reasonable URL 514 + if (platform === 'whitewind' || collection.startsWith('com.whtwnd.')) return 'https://whtwnd.com/' + did + '/' + rkey; 515 + if (basePath) return 'https://' + basePath + '/' + rkey; 511 516 return 'https://pds.pub/at/' + encodeURIComponent(uri); 512 517 } 513 518 ··· 516 521 var el = document.getElementById('legend'); 517 522 var colors = getColors(); 518 523 var html = ''; 519 - var platforms = ['leaflet', 'whitewind', 'pckt', 'offprint', 'greengale', 'other']; 520 - for (var i = 0; i < platforms.length; i++) { 521 - var p = platforms[i]; 522 - var c = colors[p]; 523 - html += '<div class="legend-item"><span class="legend-dot" style="background:' + c.mid + '"></span>' + p + '</div>'; 524 + for (var i = 0; i < PLATFORMS.length; i++) { 525 + html += '<div class="legend-item"><span class="legend-dot" style="background:' + colors[PLATFORMS[i]].mid + '"></span>' + PLATFORMS[i] + '</div>'; 524 526 } 525 527 el.innerHTML = html; 526 528 } ··· 534 536 }) 535 537 .then(function(d) { 536 538 data = d; 537 - 538 - // build typed arrays 539 539 var n = d.points.length; 540 540 pointsX = new Float32Array(n); 541 541 pointsY = new Float32Array(n); 542 + platformIdx = new Uint8Array(n); 543 + 544 + // build platform lookup 545 + var platMap = {}; 546 + for (var p = 0; p < PLATFORMS.length; p++) platMap[PLATFORMS[p]] = p; 547 + var otherIdx = platMap.other; 548 + 542 549 for (var i = 0; i < n; i++) { 543 550 pointsX[i] = d.points[i].x; 544 551 pointsY[i] = d.points[i].y; 552 + platformIdx[i] = platMap[d.points[i].platform] !== undefined ? platMap[d.points[i].platform] : otherIdx; 545 553 } 546 554 547 555 buildSpatialIndex(); 548 556 renderLegend(); 549 557 550 - // stats 551 558 document.getElementById('stats').textContent = 552 559 n.toLocaleString() + ' documents \u00B7 ' + 553 560 d.clusters.coarse.length + ' regions \u00B7 ' + 554 561 d.clusters.fine.length + ' clusters'; 555 562 556 - // hide loading 557 563 document.getElementById('loading').classList.add('hidden'); 558 - 559 564 view.dirty = true; 560 565 }) 561 566 .catch(function(err) { 562 - document.getElementById('loading').querySelector('.spinner').textContent = 563 - 'error: ' + err.message; 567 + document.getElementById('loading').querySelector('.spinner').textContent = 'error: ' + err.message; 564 568 console.error(err); 565 569 }); 566 570 } ··· 571 575 loadData(); 572 576 loop(); 573 577 574 - // expose for theme toggle 575 578 window.constellation = { 576 579 setDirty: function() { 577 - gradientCache = {}; 580 + sprites = null; 581 + dotSprites = null; 578 582 renderLegend(); 579 583 view.dirty = true; 580 584 }