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: stale pckt URLs, mobile layout overhaul, interactive legend

- indexer: backfill base_path when publication changes (not just when empty)
- fixed 80+ docs with stale pckt.blog subdomains in Turso
- bottom bar: legend + stats stacked in flex container, no overlap
- interactive legend: click platform to filter/highlight those points
- label clipping: clamp labels to stay within viewport bounds
- tap targets: 44px min for theme toggle and nav links on mobile

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

+135 -44
+3 -3
backend/src/ingest/indexer.zig
··· 259 259 &.{ uri, did, rkey, name, description orelse "", base_path orelse "" }, 260 260 ); 261 261 262 - // backfill: if documents arrived before this publication, they have empty base_path 262 + // backfill: update documents whose base_path is empty or stale (differs from publication) 263 263 if (base_path) |bp| { 264 264 if (bp.len > 0) { 265 265 c.exec( ··· 268 268 \\ indexed_at = strftime('%Y-%m-%dT%H:%M:%S', 'now'), 269 269 \\ embedded_at = NULL 270 270 \\WHERE publication_uri = ? 271 - \\ AND (base_path IS NULL OR base_path = '') 272 - , &.{ bp, uri }) catch |err| { 271 + \\ AND (base_path IS NULL OR base_path = '' OR base_path != ?) 272 + , &.{ bp, uri, bp }) catch |err| { 273 273 logfire.warn("indexer: base_path backfill failed for pub {s}: {}", .{ uri, err }); 274 274 }; 275 275 }
+54 -20
site/atlas.css
··· 93 93 .header .nav a { 94 94 color: var(--text-dim); 95 95 text-decoration: none; 96 + min-height: 44px; 97 + display: flex; 98 + align-items: center; 96 99 } 97 100 98 101 .header .nav a:hover { ··· 103 106 cursor: pointer; 104 107 color: var(--text-dim); 105 108 user-select: none; 109 + min-width: 44px; 110 + min-height: 44px; 111 + display: flex; 112 + align-items: center; 113 + justify-content: center; 106 114 } 107 115 108 116 .theme-toggle:hover { ··· 169 177 font-size: 13px; 170 178 } 171 179 172 - /* legend */ 173 - .legend { 180 + /* bottom bar: legend + stats stacked */ 181 + .bottom-bar { 174 182 position: fixed; 175 - bottom: 12px; 176 - left: 16px; 183 + bottom: 0; 184 + left: 0; 185 + right: 0; 177 186 z-index: 10; 187 + pointer-events: none; 188 + display: flex; 189 + flex-direction: column; 190 + align-items: center; 191 + gap: 4px; 192 + padding: 8px 16px 10px; 193 + } 194 + 195 + .legend { 196 + pointer-events: auto; 178 197 font-size: 10px; 179 198 color: var(--text-dim); 180 199 display: flex; 181 - gap: 10px; 200 + gap: 2px; 182 201 flex-wrap: wrap; 202 + justify-content: center; 183 203 } 184 204 185 205 .legend-item { 186 206 display: flex; 187 207 align-items: center; 188 208 gap: 4px; 209 + cursor: pointer; 210 + user-select: none; 211 + padding: 6px 8px; 212 + border-radius: 4px; 213 + transition: opacity 0.15s, background 0.15s; 214 + -webkit-tap-highlight-color: transparent; 215 + } 216 + 217 + .legend-item:hover { 218 + background: rgba(128, 128, 128, 0.15); 219 + } 220 + 221 + .legend-item.dimmed { 222 + opacity: 0.3; 189 223 } 190 224 191 225 .legend-dot { 192 226 width: 8px; 193 227 height: 8px; 194 228 border-radius: 50%; 229 + flex-shrink: 0; 230 + } 231 + 232 + .stats { 233 + font-size: 10px; 234 + color: var(--text-muted); 235 + text-align: center; 195 236 } 196 237 197 238 /* search */ ··· 228 269 white-space: nowrap; 229 270 } 230 271 231 - /* stats */ 232 - .stats { 233 - position: fixed; 234 - bottom: 12px; 235 - right: 16px; 236 - z-index: 10; 237 - font-size: 10px; 238 - color: var(--text-muted); 239 - } 240 - 241 272 /* mobile */ 242 273 @media (max-width: 600px) { 243 274 .header { padding: 8px 10px; } 244 275 .header h1 { font-size: 11px; } 245 - .header .nav { gap: 8px; font-size: 10px; } 246 - #search-input { width: 90px; font-size: 10px; padding: 4px 6px; } 247 - #search-input:focus { width: 130px; } 248 - .legend { bottom: 24px; left: 10px; right: 10px; gap: 6px; font-size: 9px; } 249 - .stats { bottom: 8px; left: 10px; right: 10px; text-align: center; font-size: 9px; } 276 + .header .nav { gap: 4px; font-size: 10px; } 277 + #search-input { width: 80px; font-size: 10px; padding: 4px 6px; } 278 + #search-input:focus { width: 120px; } 279 + .bottom-bar { padding: 4px 8px 6px; gap: 2px; } 280 + .legend { gap: 0; } 281 + .legend-item { padding: 5px 6px; font-size: 9px; } 282 + .legend-dot { width: 6px; height: 6px; } 283 + .stats { font-size: 9px; } 250 284 .tooltip { max-width: 260px; font-size: 11px; } 251 285 .tooltip .meta { font-size: 10px; } 252 286 .tooltip .platform-badge { font-size: 9px; }
+7 -3
site/atlas.html
··· 49 49 <div class="platform-badge" id="tooltip-platform"></div> 50 50 </div> 51 51 52 - <div class="legend" id="legend"></div> 53 - <div class="stats" id="stats"></div> 52 + <div class="bottom-bar"> 53 + <div class="legend" id="legend"></div> 54 + <div class="stats" id="stats"></div> 55 + </div> 54 56 55 57 <script> 56 58 var currentTheme = localStorage.getItem('theme') || 'dark'; ··· 72 74 function renderThemeToggle() { 73 75 var el = document.getElementById('theme-toggle'); 74 76 if (!el) return; 75 - el.innerHTML = '<span onclick="cycleTheme()" title="' + currentTheme + '">' + THEME_ICONS[currentTheme] + '</span>'; 77 + el.textContent = THEME_ICONS[currentTheme]; 78 + el.title = currentTheme; 79 + el.onclick = cycleTheme; 76 80 } 77 81 matchMedia('(prefers-color-scheme: light)').addEventListener('change', function() { 78 82 if (currentTheme === 'system') applyTheme('system');
+71 -18
site/atlas.js
··· 312 312 buildDotSprites(); 313 313 } 314 314 315 - for (var i = 0; i < n; i++) { 316 - var px = pointsX[i], py = pointsY[i]; 317 - if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 318 - var sx = cx + px * scale, sy = cy + py * scale; 319 - var pi = platformIdx[i]; 320 - if (i === hoveredIndex && useGlow) { 321 - var spr = sprites[pi].hover; 322 - ctx.drawImage(spr, sx - spr.width / (2 * dpr), sy - spr.height / (2 * dpr), spr.width / dpr, spr.height / dpr); 323 - } else if (useGlow) { 324 - var spr = sprites[pi].normal; 325 - ctx.drawImage(spr, sx - spr.width / (2 * dpr), sy - spr.height / (2 * dpr), spr.width / dpr, spr.height / dpr); 326 - } else { 327 - var dot = dotSprites[pi]; 328 - ctx.drawImage(dot, sx - dot.width / (2 * dpr), sy - dot.height / (2 * dpr), dot.width / dpr, dot.height / dpr); 315 + var filtering = activePlatforms !== null; 316 + // draw dimmed points first, then active points on top 317 + for (var pass = 0; pass < (filtering ? 2 : 1); pass++) { 318 + if (filtering && pass === 0) ctx.globalAlpha = 0.12; 319 + else ctx.globalAlpha = 1; 320 + for (var i = 0; i < n; i++) { 321 + var px = pointsX[i], py = pointsY[i]; 322 + if (px < xMin || px > xMax || py < yMin || py > yMax) continue; 323 + var pi = platformIdx[i]; 324 + var isActive = !filtering || activePlatforms.has(PLATFORMS[pi]); 325 + // pass 0 = dimmed (inactive), pass 1 = bright (active) 326 + if (filtering && ((pass === 0 && isActive) || (pass === 1 && !isActive))) continue; 327 + if (!filtering && pass === 1) continue; 328 + var sx = cx + px * scale, sy = cy + py * scale; 329 + if (i === hoveredIndex && useGlow) { 330 + var spr = sprites[pi].hover; 331 + ctx.drawImage(spr, sx - spr.width / (2 * dpr), sy - spr.height / (2 * dpr), spr.width / dpr, spr.height / dpr); 332 + } else if (useGlow) { 333 + var spr = sprites[pi].normal; 334 + ctx.drawImage(spr, sx - spr.width / (2 * dpr), sy - spr.height / (2 * dpr), spr.width / dpr, spr.height / dpr); 335 + } else { 336 + var dot = dotSprites[pi]; 337 + ctx.drawImage(dot, sx - dot.width / (2 * dpr), sy - dot.height / (2 * dpr), dot.width / dpr, dot.height / dpr); 338 + } 329 339 } 330 340 } 341 + ctx.globalAlpha = 1; 331 342 332 343 // --- search highlights --- 333 344 if (searchMatches && searchMatches.size > 0) { ··· 406 417 return true; 407 418 } 408 419 420 + // label margin: keep labels inside viewport with some padding 421 + var LABEL_MARGIN = small ? 8 : 12; 422 + 409 423 if (zoom < 2) { 410 424 // coarse labels — sort by cluster size so biggest labels win 411 425 ctx.font = (small ? '9px' : '12px') + ' monospace'; ··· 415 429 for (var c = 0; c < sorted.length; c++) { 416 430 var cl = sorted[c]; 417 431 var sx = cx + cl.cx * scale, sy = cy + cl.cy * scale - Math.sqrt(cl.count) * 1.5; 418 - if (sx < -50 || sx > W + 50 || sy < -20 || sy > H + 20) continue; 432 + if (sy < LABEL_MARGIN || sy > H - 40) continue; 419 433 var tw = ctx.measureText(cl.label).width; 434 + var halfW = tw / 2; 435 + // clamp horizontally so label stays in viewport 436 + if (sx - halfW < LABEL_MARGIN) sx = LABEL_MARGIN + halfW; 437 + if (sx + halfW > W - LABEL_MARGIN) sx = W - LABEL_MARGIN - halfW; 420 438 if (canPlace(sx, sy, tw, fontSize)) drawLabel(cl.label, sx, sy, dark); 421 439 } 422 440 } else if (zoom < 5) { ··· 429 447 var cl = sorted[c]; 430 448 if (cl.cx < xMin || cl.cx > xMax || cl.cy < yMin || cl.cy > yMax) continue; 431 449 var sx = cx + cl.cx * scale, sy = cy + cl.cy * scale - 14; 432 - if (sx < -50 || sx > W + 50 || sy < -20 || sy > H + 20) continue; 450 + if (sy < LABEL_MARGIN || sy > H - 40) continue; 433 451 var tw = ctx.measureText(cl.label).width; 452 + var halfW = tw / 2; 453 + if (sx - halfW < LABEL_MARGIN) sx = LABEL_MARGIN + halfW; 454 + if (sx + halfW > W - LABEL_MARGIN) sx = W - LABEL_MARGIN - halfW; 434 455 if (canPlace(sx, sy, tw, fontSize)) drawLabel(cl.label, sx, sy, dark); 435 456 } 436 457 } else { ··· 446 467 var title = data.points[i].title; 447 468 if (!title) continue; 448 469 var sx = cx + px * scale, sy = cy + py * scale - 10; 449 - if (sx < 0 || sx > W || sy < 0 || sy > H) continue; 470 + if (sy < LABEL_MARGIN || sy > H - 40) continue; 450 471 if (title.length > truncLen) title = title.substring(0, truncLen - 2) + '\u2026'; 451 472 var tw = ctx.measureText(title).width; 473 + var halfW = tw / 2; 474 + if (sx - halfW < LABEL_MARGIN) sx = LABEL_MARGIN + halfW; 475 + if (sx + halfW > W - LABEL_MARGIN) sx = W - LABEL_MARGIN - halfW; 452 476 if (canPlace(sx, sy, tw, fontSize)) { drawLabel(title, sx, sy, dark); shown++; } 453 477 } 454 478 } ··· 705 729 return 'https://pdsls.dev/at/' + did + '/' + collection + '/' + rkey; 706 730 } 707 731 732 + // --- platform filter state --- 733 + var activePlatforms = null; // null = all visible, Set = only these 734 + 708 735 function renderLegend() { 709 736 var el = document.getElementById('legend'); 710 737 var colors = getColors(); 711 738 var html = ''; 712 739 for (var i = 0; i < PLATFORMS.length; i++) { 713 - html += '<div class="legend-item"><span class="legend-dot" style="background:' + colors[PLATFORMS[i]].mid + '"></span>' + PLATFORMS[i] + '</div>'; 740 + var p = PLATFORMS[i]; 741 + var dimmed = activePlatforms && !activePlatforms.has(p) ? ' dimmed' : ''; 742 + html += '<div class="legend-item' + dimmed + '" data-platform="' + p + '"><span class="legend-dot" style="background:' + colors[p].mid + '"></span>' + p + '</div>'; 714 743 } 715 744 el.innerHTML = html; 745 + // attach click handlers 746 + var items = el.querySelectorAll('.legend-item'); 747 + for (var i = 0; i < items.length; i++) { 748 + items[i].addEventListener('click', onLegendClick); 749 + } 750 + } 751 + 752 + function onLegendClick(e) { 753 + var item = e.currentTarget; 754 + var platform = item.getAttribute('data-platform'); 755 + if (!activePlatforms) { 756 + // first click: select only this platform 757 + activePlatforms = new Set([platform]); 758 + } else if (activePlatforms.has(platform)) { 759 + activePlatforms.delete(platform); 760 + // if nothing selected, show all 761 + if (activePlatforms.size === 0) activePlatforms = null; 762 + } else { 763 + activePlatforms.add(platform); 764 + // if all selected, reset to null 765 + if (activePlatforms.size === PLATFORMS.length) activePlatforms = null; 766 + } 767 + renderLegend(); 768 + view.dirty = true; 716 769 } 717 770 718 771 function loadData() {