Dense zones show competition. Empty zones show opportunity. A 2D map of the ATProto ecosystem.
0
fork

Configure Feed

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

add prototype map

+747
+747
index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>ATProto Ecosystem Map</title> 7 + <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet"> 8 + <style> 9 + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 10 + 11 + :root { 12 + --bg: #f3f6fb; 13 + --bg2: #ffffff; 14 + --bg3: #e8edf5; 15 + --bg4: #dce3ef; 16 + --text: #1b2840; 17 + --text-dim: #6b7d99; 18 + --text-muted: #a3b1c6; 19 + --accent: #0085ff; 20 + --accent2: #00c2ff; 21 + --border: rgba(0,133,255,0.15); 22 + --border-active: rgba(0,133,255,0.35); 23 + --cell-hover: rgba(0,133,255,0.05); 24 + --established: #0085ff; 25 + --active: #00a676; 26 + --prototype: #e89d2d; 27 + --concept: #c4566a; 28 + } 29 + 30 + body { 31 + background: var(--bg); 32 + color: var(--text); 33 + font-family: 'DM Sans', sans-serif; 34 + min-height: 100vh; 35 + overflow-x: hidden; 36 + } 37 + 38 + body::before { 39 + content: ''; 40 + position: fixed; 41 + inset: 0; 42 + background-image: radial-gradient(rgba(0,133,255,0.08) 1px, transparent 1px); 43 + background-size: 24px 24px; 44 + pointer-events: none; 45 + z-index: 0; 46 + } 47 + 48 + .app { 49 + position: relative; 50 + z-index: 1; 51 + max-width: 1200px; 52 + margin: 0 auto; 53 + padding: 40px 32px 80px; 54 + } 55 + 56 + .header { margin-bottom: 40px; } 57 + .header-top { 58 + display: flex; 59 + align-items: baseline; 60 + gap: 12px; 61 + margin-bottom: 8px; 62 + } 63 + .logo { 64 + font-family: 'IBM Plex Mono', monospace; 65 + font-size: 11px; 66 + font-weight: 600; 67 + color: var(--accent); 68 + letter-spacing: 0.2em; 69 + text-transform: uppercase; 70 + } 71 + .title { 72 + font-size: 28px; 73 + font-weight: 700; 74 + letter-spacing: -0.02em; 75 + background: linear-gradient(135deg, #0085ff, #00c2ff); 76 + -webkit-background-clip: text; 77 + -webkit-text-fill-color: transparent; 78 + background-clip: text; 79 + } 80 + .subtitle { 81 + font-size: 13px; 82 + color: var(--text-dim); 83 + line-height: 1.6; 84 + max-width: 600px; 85 + } 86 + 87 + .controls { 88 + display: flex; 89 + align-items: center; 90 + gap: 16px; 91 + margin-bottom: 32px; 92 + flex-wrap: wrap; 93 + } 94 + .view-toggle { 95 + display: flex; 96 + background: var(--bg3); 97 + border: 1px solid var(--border); 98 + border-radius: 8px; 99 + overflow: hidden; 100 + } 101 + .view-btn { 102 + background: none; 103 + border: none; 104 + color: var(--text-dim); 105 + font-family: 'IBM Plex Mono', monospace; 106 + font-size: 11px; 107 + font-weight: 600; 108 + letter-spacing: 0.1em; 109 + padding: 10px 18px; 110 + cursor: pointer; 111 + transition: all 0.2s; 112 + text-transform: uppercase; 113 + } 114 + .view-btn:hover { color: var(--text); } 115 + .view-btn.active { background: var(--accent); color: #fff; } 116 + 117 + .legend { display: flex; gap: 16px; margin-left: auto; } 118 + .legend-item { 119 + display: flex; align-items: center; gap: 6px; 120 + font-size: 11px; color: var(--text-dim); 121 + font-family: 'IBM Plex Mono', monospace; 122 + } 123 + .legend-dot { width: 8px; height: 8px; border-radius: 50%; } 124 + 125 + .filter-row { display: flex; gap: 8px; flex-wrap: wrap; } 126 + .filter-btn { 127 + background: var(--bg3); 128 + border: 1px solid var(--border); 129 + border-radius: 6px; 130 + padding: 6px 14px; 131 + font-family: 'IBM Plex Mono', monospace; 132 + font-size: 10px; font-weight: 500; 133 + color: var(--text-dim); 134 + cursor: pointer; 135 + transition: all 0.15s; 136 + letter-spacing: 0.05em; 137 + } 138 + .filter-btn:hover { border-color: var(--border-active); color: var(--text); } 139 + .filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); } 140 + 141 + /* ===== GRID ===== */ 142 + .grid-view { 143 + display: grid; 144 + grid-template-columns: 120px repeat(4, 1fr); 145 + grid-template-rows: auto repeat(4, minmax(100px, auto)); 146 + gap: 1px; 147 + background: var(--border); 148 + border: 1px solid var(--border); 149 + border-radius: 12px; 150 + overflow: hidden; 151 + } 152 + .grid-corner { 153 + background: var(--bg2); 154 + display: flex; align-items: center; justify-content: center; padding: 12px; 155 + } 156 + .grid-corner-inner { 157 + font-family: 'IBM Plex Mono', monospace; 158 + font-size: 9px; color: var(--text-muted); 159 + text-align: center; line-height: 1.5; letter-spacing: 0.1em; 160 + } 161 + .col-header { 162 + background: var(--bg2); 163 + padding: 16px 12px; text-align: center; 164 + font-family: 'IBM Plex Mono', monospace; 165 + font-size: 11px; font-weight: 600; 166 + color: var(--accent); 167 + letter-spacing: 0.08em; text-transform: uppercase; 168 + border-bottom: 2px solid var(--border-active); 169 + } 170 + .col-header .col-sub { 171 + display: block; font-size: 9px; color: var(--text-dim); 172 + font-weight: 400; margin-top: 4px; text-transform: none; 173 + } 174 + .row-header { 175 + background: var(--bg2); 176 + padding: 12px 14px; 177 + display: flex; flex-direction: column; justify-content: center; gap: 4px; 178 + border-right: 2px solid var(--border-active); 179 + } 180 + .row-label { 181 + font-family: 'IBM Plex Mono', monospace; 182 + font-size: 11px; font-weight: 600; letter-spacing: 0.05em; 183 + } 184 + .row-sub { font-size: 9px; color: var(--text-dim); } 185 + .row-header[data-maturity="established"] .row-label { color: var(--established); } 186 + .row-header[data-maturity="active"] .row-label { color: var(--active); } 187 + .row-header[data-maturity="prototype"] .row-label { color: var(--prototype); } 188 + .row-header[data-maturity="concept"] .row-label { color: var(--concept); } 189 + 190 + .grid-cell { 191 + background: var(--bg); 192 + padding: 10px; 193 + display: flex; flex-wrap: wrap; align-content: flex-start; gap: 6px; 194 + transition: background 0.2s, box-shadow 0.2s; 195 + min-height: 100px; 196 + cursor: pointer; 197 + position: relative; 198 + } 199 + .grid-cell:hover { 200 + background: var(--cell-hover); 201 + box-shadow: inset 0 0 0 2px var(--border-active); 202 + } 203 + .grid-cell.empty-zone { position: relative; } 204 + .grid-cell.empty-zone::after { 205 + content: '?'; 206 + position: absolute; inset: 0; 207 + display: flex; align-items: center; justify-content: center; 208 + font-family: 'IBM Plex Mono', monospace; 209 + font-size: 24px; color: var(--text-muted); opacity: 0.4; 210 + pointer-events: none; 211 + } 212 + 213 + .cell-count { 214 + position: absolute; top: 6px; right: 8px; 215 + font-family: 'IBM Plex Mono', monospace; 216 + font-size: 9px; color: var(--text-muted); 217 + pointer-events: none; 218 + } 219 + 220 + .chip { 221 + display: inline-flex; align-items: center; gap: 5px; 222 + background: var(--bg3); 223 + border: 1px solid var(--border); border-radius: 6px; 224 + padding: 4px 8px; 225 + font-size: 10px; font-weight: 500; color: var(--text); 226 + transition: all 0.15s; 227 + max-width: 100%; 228 + pointer-events: none; 229 + } 230 + .chip-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } 231 + .chip .chip-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } 232 + 233 + .chip-overflow { 234 + display: inline-flex; align-items: center; justify-content: center; 235 + background: var(--bg4); 236 + border: 1px dashed var(--border-active); border-radius: 6px; 237 + padding: 4px 10px; 238 + font-family: 'IBM Plex Mono', monospace; 239 + font-size: 10px; font-weight: 600; color: var(--accent); 240 + pointer-events: none; 241 + } 242 + 243 + /* ===== MODAL ===== */ 244 + .modal-overlay { 245 + display: none; 246 + position: fixed; inset: 0; 247 + background: rgba(27,40,64,0.45); 248 + backdrop-filter: blur(6px); 249 + z-index: 100; 250 + align-items: center; justify-content: center; 251 + padding: 32px; 252 + } 253 + .modal-overlay.open { display: flex; animation: fadeIn 0.2s ease; } 254 + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } 255 + 256 + .modal { 257 + background: var(--bg2); 258 + border: 1px solid var(--border-active); 259 + border-radius: 16px; 260 + width: 100%; max-width: 640px; max-height: 80vh; 261 + overflow: hidden; 262 + display: flex; flex-direction: column; 263 + box-shadow: 0 24px 80px rgba(0,133,255,0.12), 0 0 0 1px var(--border); 264 + animation: modalIn 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); 265 + } 266 + @keyframes modalIn { 267 + from { opacity: 0; transform: scale(0.92) translateY(16px); } 268 + to { opacity: 1; transform: scale(1) translateY(0); } 269 + } 270 + 271 + .modal-header { 272 + padding: 24px 28px 16px; 273 + border-bottom: 1px solid var(--border); 274 + display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; 275 + } 276 + .modal-title-group { flex: 1; } 277 + .modal-zone { 278 + font-family: 'IBM Plex Mono', monospace; 279 + font-size: 10px; font-weight: 600; 280 + letter-spacing: 0.15em; text-transform: uppercase; 281 + margin-bottom: 4px; 282 + } 283 + .modal-title { font-size: 20px; font-weight: 700; color: var(--text); } 284 + .modal-subtitle { font-size: 12px; color: var(--text-dim); margin-top: 4px; } 285 + 286 + .modal-close { 287 + background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; 288 + width: 36px; height: 36px; 289 + display: flex; align-items: center; justify-content: center; 290 + cursor: pointer; font-size: 18px; color: var(--text-dim); 291 + transition: all 0.15s; flex-shrink: 0; 292 + } 293 + .modal-close:hover { background: var(--bg4); color: var(--text); border-color: var(--border-active); } 294 + 295 + .modal-body { padding: 20px 28px 28px; overflow-y: auto; flex: 1; } 296 + 297 + .modal-empty { text-align: center; padding: 40px 20px; } 298 + .modal-empty-icon { font-size: 48px; opacity: 0.3; margin-bottom: 12px; } 299 + .modal-empty-text { font-size: 14px; color: var(--text-dim); margin-bottom: 4px; } 300 + .modal-empty-sub { font-size: 12px; color: var(--text-muted); } 301 + 302 + .product-card { 303 + display: flex; gap: 14px; 304 + padding: 16px 0; 305 + border-bottom: 1px solid var(--border); 306 + align-items: flex-start; 307 + animation: cardIn 0.3s ease both; 308 + } 309 + .product-card:last-child { border-bottom: none; } 310 + @keyframes cardIn { 311 + from { opacity: 0; transform: translateY(8px); } 312 + to { opacity: 1; transform: translateY(0); } 313 + } 314 + .product-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; } 315 + .product-info { flex: 1; min-width: 0; } 316 + .product-name { font-size: 14px; font-weight: 700; color: var(--text); margin-bottom: 3px; } 317 + .product-desc { font-size: 12px; color: var(--text-dim); line-height: 1.5; } 318 + .product-tags { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } 319 + .product-tag { 320 + font-family: 'IBM Plex Mono', monospace; 321 + font-size: 9px; font-weight: 500; color: var(--text-muted); 322 + background: var(--bg3); border: 1px solid var(--border); border-radius: 4px; 323 + padding: 2px 8px; letter-spacing: 0.05em; 324 + } 325 + 326 + /* ===== BUBBLE ===== */ 327 + .bubble-view { 328 + display: none; 329 + position: relative; width: 100%; height: 640px; 330 + background: var(--bg); 331 + border: 1px solid var(--border); border-radius: 12px; 332 + overflow: hidden; 333 + } 334 + .bubble-axes { position: absolute; inset: 0; pointer-events: none; } 335 + .axis-x { 336 + position: absolute; bottom: 40px; left: 120px; right: 40px; 337 + height: 1px; background: var(--border-active); 338 + } 339 + .axis-x-label { 340 + position: absolute; bottom: 12px; 341 + font-family: 'IBM Plex Mono', monospace; 342 + font-size: 9px; color: var(--text-muted); 343 + letter-spacing: 0.1em; text-transform: uppercase; white-space: nowrap; 344 + } 345 + .axis-y { 346 + position: absolute; top: 40px; bottom: 40px; left: 120px; 347 + width: 1px; background: var(--border-active); 348 + } 349 + .axis-y-label { 350 + position: absolute; left: 12px; 351 + font-family: 'IBM Plex Mono', monospace; 352 + font-size: 9px; color: var(--text-muted); 353 + letter-spacing: 0.1em; writing-mode: vertical-lr; transform: rotate(180deg); white-space: nowrap; 354 + } 355 + .zone-line-x, .zone-line-y { position: absolute; z-index: 0; } 356 + .zone-line-x { 357 + left: 120px; right: 40px; height: 1px; 358 + background: repeating-linear-gradient(90deg, var(--text-muted) 0, var(--text-muted) 4px, transparent 4px, transparent 12px); 359 + opacity: 0.15; 360 + } 361 + .zone-line-y { 362 + top: 40px; bottom: 40px; width: 1px; 363 + background: repeating-linear-gradient(180deg, var(--text-muted) 0, var(--text-muted) 4px, transparent 4px, transparent 12px); 364 + opacity: 0.15; 365 + } 366 + .bubble-node { 367 + position: absolute; border-radius: 50%; 368 + display: flex; align-items: center; justify-content: center; 369 + cursor: default; 370 + transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.2s; 371 + z-index: 2; 372 + } 373 + .bubble-node:hover { transform: scale(1.25); z-index: 10; } 374 + .bubble-label { 375 + position: absolute; top: calc(100% + 4px); left: 50%; transform: translateX(-50%); 376 + font-size: 9px; font-weight: 600; color: var(--text-dim); white-space: nowrap; 377 + pointer-events: none; opacity: 0; transition: opacity 0.2s; 378 + } 379 + .bubble-node:hover .bubble-label { opacity: 1; } 380 + .void-zone { 381 + position: absolute; 382 + border: 2px dashed var(--text-muted); border-radius: 12px; 383 + opacity: 0.2; z-index: 1; 384 + display: flex; align-items: center; justify-content: center; 385 + } 386 + .void-zone-label { 387 + font-family: 'IBM Plex Mono', monospace; 388 + font-size: 10px; color: var(--text-muted); letter-spacing: 0.1em; opacity: 0.6; 389 + } 390 + 391 + /* Stats */ 392 + .stats-bar { 393 + display: flex; gap: 24px; margin-top: 24px; padding: 16px 20px; 394 + background: var(--bg2); border: 1px solid var(--border); border-radius: 10px; 395 + box-shadow: 0 1px 4px rgba(0,0,0,0.04); flex-wrap: wrap; 396 + } 397 + .stat { display: flex; flex-direction: column; gap: 4px; } 398 + .stat-value { 399 + font-family: 'IBM Plex Mono', monospace; 400 + font-size: 22px; font-weight: 700; color: var(--accent); 401 + } 402 + .stat-label { font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; text-transform: uppercase; } 403 + .stat-highlight .stat-value { color: var(--concept); } 404 + 405 + @media (max-width: 768px) { 406 + .app { padding: 24px 16px 60px; } 407 + .grid-view { grid-template-columns: 80px repeat(4, 1fr); } 408 + .title { font-size: 22px; } 409 + .legend { margin-left: 0; } 410 + .controls { gap: 10px; } 411 + .bubble-view { height: 480px; } 412 + .modal { max-width: 100%; margin: 16px; } 413 + } 414 + </style> 415 + </head> 416 + <body> 417 + <div class="app"> 418 + <div class="header"> 419 + <div class="header-top"> 420 + <span class="logo">AT://</span> 421 + <h1 class="title">Ecosystem Map</h1> 422 + </div> 423 + <p class="subtitle">ATProto products plotted by user distance and maturity. Dense zones show competition. Empty zones show opportunity. Click any cell to zoom in.</p> 424 + </div> 425 + 426 + <div class="controls"> 427 + <div class="view-toggle"> 428 + <button class="view-btn active" data-view="grid" onclick="switchView('grid')">Grid</button> 429 + <button class="view-btn" data-view="bubble" onclick="switchView('bubble')">Scatter</button> 430 + </div> 431 + <div class="filter-row" id="filterRow"></div> 432 + <div class="legend"> 433 + <div class="legend-item"><div class="legend-dot" style="background:var(--established)"></div>Established</div> 434 + <div class="legend-item"><div class="legend-dot" style="background:var(--active)"></div>Active</div> 435 + <div class="legend-item"><div class="legend-dot" style="background:var(--prototype)"></div>Prototype</div> 436 + <div class="legend-item"><div class="legend-dot" style="background:var(--concept)"></div>Concept</div> 437 + </div> 438 + </div> 439 + 440 + <div class="grid-view" id="gridView"></div> 441 + <div class="bubble-view" id="bubbleView"></div> 442 + <div class="stats-bar" id="statsBar"></div> 443 + </div> 444 + 445 + <div class="modal-overlay" id="modalOverlay" onclick="closeModalOutside(event)"> 446 + <div class="modal" id="modal"> 447 + <div class="modal-header"> 448 + <div class="modal-title-group"> 449 + <div class="modal-zone" id="modalZone"></div> 450 + <div class="modal-title" id="modalTitle"></div> 451 + <div class="modal-subtitle" id="modalSubtitle"></div> 452 + </div> 453 + <button class="modal-close" onclick="closeModal()">&times;</button> 454 + </div> 455 + <div class="modal-body" id="modalBody"></div> 456 + </div> 457 + </div> 458 + 459 + <script> 460 + const COLUMNS = [ 461 + { id: 'infra', label: 'Infrastructure', sub: 'PDS · Relay · Hosting' }, 462 + { id: 'devtool', label: 'Dev Tools', sub: 'SDK · CLI · Libraries' }, 463 + { id: 'middleware', label: 'Middleware', sub: 'Feeds · Labelers · Bots' }, 464 + { id: 'app', label: 'End-User Apps', sub: 'Clients · Special-purpose' }, 465 + ]; 466 + 467 + const ROWS = [ 468 + { id: 'established', label: 'Established', sub: 'Widely adopted', color: '#0085ff' }, 469 + { id: 'active', label: 'Active', sub: 'Running, has users', color: '#00a676' }, 470 + { id: 'prototype', label: 'Prototype', sub: 'Works, pre-launch', color: '#e89d2d' }, 471 + { id: 'concept', label: 'Concept', sub: 'Proposed / WIP', color: '#c4566a' }, 472 + ]; 473 + 474 + const PRODUCTS = [ 475 + // Infrastructure 476 + { name: 'Bluesky PDS', col: 'infra', row: 'established', desc: 'Official Personal Data Server implementation', category: 'hosting' }, 477 + { name: 'Jetstream', col: 'infra', row: 'established', desc: 'Lightweight firehose consumption proxy by jaz', category: 'data' }, 478 + { name: 'BGS Relay', col: 'infra', row: 'established', desc: 'Big Graph Service — crawls & aggregates repos', category: 'data' }, 479 + { name: 'PDS on Docker', col: 'infra', row: 'active', desc: 'Community self-hosting packages for PDS', category: 'hosting' }, 480 + { name: 'pds.blue', col: 'infra', row: 'active', desc: 'Managed PDS hosting service', category: 'hosting' }, 481 + { name: 'Bluesky Backfill', col: 'infra', row: 'active', desc: 'Tools for backfilling repo data from network', category: 'data' }, 482 + { name: 'did:plc auditor', col: 'infra', row: 'prototype', desc: 'Audit tool for DID PLC operations log', category: 'identity' }, 483 + { name: 'Constellation', col: 'infra', row: 'concept', desc: 'Distributed relay architecture proposal', category: 'data' }, 484 + { name: 'did:web migration', col: 'infra', row: 'concept', desc: 'Support for did:web as alternative DID method', category: 'identity' }, 485 + 486 + // Dev Tools 487 + { name: '@atproto/api', col: 'devtool', row: 'established', desc: 'Official TypeScript SDK for AT Protocol', category: 'sdk' }, 488 + { name: 'indigo', col: 'devtool', row: 'established', desc: 'Go libraries for AT Protocol by Bluesky', category: 'sdk' }, 489 + { name: 'atproto (Python)', col: 'devtool', row: 'active', desc: 'Community Python SDK (MarshalX)', category: 'sdk' }, 490 + { name: 'atrium (Rust)', col: 'devtool', row: 'active', desc: 'Rust SDK for AT Protocol', category: 'sdk' }, 491 + { name: 'AT Protocol Viewer', col: 'devtool', row: 'active', desc: 'Web inspector for AT Protocol records', category: 'tooling' }, 492 + { name: 'pdsls', col: 'devtool', row: 'active', desc: 'CLI tool to explore PDS contents', category: 'tooling' }, 493 + { name: 'SkyBridge', col: 'devtool', row: 'active', desc: 'Mastodon API bridge for Bluesky', category: 'tooling' }, 494 + { name: 'cbsky', col: 'devtool', row: 'active', desc: 'C library for Bluesky API', category: 'sdk' }, 495 + { name: 'Lexicon CLI', col: 'devtool', row: 'prototype', desc: 'Code generation from Lexicon schemas', category: 'tooling' }, 496 + { name: 'atproto Dart', col: 'devtool', row: 'prototype', desc: 'Dart/Flutter SDK for AT Protocol', category: 'sdk' }, 497 + { name: 'Lexicon Playground', col: 'devtool', row: 'concept', desc: 'Interactive Lexicon schema designer', category: 'tooling' }, 498 + 499 + // Middleware 500 + { name: 'Ozone', col: 'middleware', row: 'established', desc: 'Official moderation tooling and labeler', category: 'moderation' }, 501 + { name: 'SkyFeed', col: 'middleware', row: 'active', desc: 'Visual feed builder for Bluesky', category: 'feed' }, 502 + { name: 'Blacksky', col: 'middleware', row: 'active', desc: 'Community feed for Black users', category: 'feed' }, 503 + { name: 'Goodfeeds', col: 'middleware', row: 'active', desc: 'Feed directory and discovery', category: 'feed' }, 504 + { name: 'Community Labelers', col: 'middleware', row: 'active', desc: 'Third-party labeling services (aegis, etc.)', category: 'moderation' }, 505 + { name: 'Bluesky Bot SDK', col: 'middleware', row: 'active', desc: 'Python bot framework for Bluesky', category: 'bot' }, 506 + { name: 'Contrails', col: 'middleware', row: 'active', desc: 'Cloudflare Workers feed generator template', category: 'feed' }, 507 + { name: 'Astral', col: 'middleware', row: 'active', desc: 'AI curation bot posting ATProto trend summaries', category: 'bot' }, 508 + { name: 'Bot frameworks (misc)', col: 'middleware', row: 'prototype', desc: 'Various community bot SDKs and templates', category: 'bot' }, 509 + { name: 'Feed Gen Starter', col: 'middleware', row: 'prototype', desc: 'Official feed generator starter template', category: 'feed' }, 510 + { name: 'Composable Trust', col: 'middleware', row: 'concept', desc: 'Credential-based trust layer (Roster + Venue)', category: 'trust' }, 511 + { name: 'Mezzanine (native)', col: 'middleware', row: 'concept', desc: 'Protocol-native channel system via Lexicon', category: 'channel' }, 512 + 513 + // End-User Apps 514 + { name: 'Bluesky (official)', col: 'app', row: 'established', desc: 'Official Bluesky client (iOS, Android, Web)', category: 'social' }, 515 + { name: 'deck.blue', col: 'app', row: 'active', desc: 'TweetDeck-style multi-column client', category: 'social' }, 516 + { name: 'Flux', col: 'app', row: 'active', desc: 'Third-party Bluesky client', category: 'social' }, 517 + { name: 'Graysky', col: 'app', row: 'active', desc: 'Mobile Bluesky client (React Native)', category: 'social' }, 518 + { name: 'Skeetdeck', col: 'app', row: 'active', desc: 'Desktop Bluesky client', category: 'social' }, 519 + { name: 'Frontpage', col: 'app', row: 'active', desc: 'Reddit-like link aggregator on ATProto', category: 'other' }, 520 + { name: 'WhiteWind', col: 'app', row: 'active', desc: 'Blog platform on AT Protocol', category: 'publishing' }, 521 + { name: 'Smoke Signal', col: 'app', row: 'active', desc: 'Long-form publishing on ATProto', category: 'publishing' }, 522 + { name: 'Tangled', col: 'app', row: 'active', desc: 'Git collaboration on AT Protocol', category: 'dev' }, 523 + { name: 'Bluecast', col: 'app', row: 'active', desc: 'Podcast hosting on ATProto', category: 'media' }, 524 + { name: 'SkyView', col: 'app', row: 'active', desc: 'Public Bluesky post viewer (no auth)', category: 'social' }, 525 + { name: 'ClearSky', col: 'app', row: 'active', desc: 'Block list and blocklist analytics', category: 'analytics' }, 526 + { name: 'Bluesky Stats', col: 'app', row: 'active', desc: 'Analytics dashboard for Bluesky accounts', category: 'analytics' }, 527 + { name: 'Picosky', col: 'app', row: 'prototype', desc: 'Lightweight ATProto microblog client', category: 'social' }, 528 + { name: 'ATProto browser', col: 'app', row: 'prototype', desc: 'Generic record browser for any Lexicon', category: 'dev' }, 529 + { name: 'Skylights', col: 'app', row: 'prototype', desc: 'Read-later / bookmarking on ATProto', category: 'other' }, 530 + { name: 'AT Marketplace', col: 'app', row: 'concept', desc: 'Marketplace / e-commerce on ATProto', category: 'other' }, 531 + { name: 'ATProto Calendar', col: 'app', row: 'concept', desc: 'Shared calendar / events on ATProto', category: 'other' }, 532 + ]; 533 + 534 + let currentView = 'grid'; 535 + let activeFilter = 'all'; 536 + const MAX_CHIPS = 4; 537 + 538 + function getRowColor(id) { return ROWS.find(r => r.id === id)?.color || '#999'; } 539 + 540 + // ===== GRID ===== 541 + function renderGrid() { 542 + const grid = document.getElementById('gridView'); 543 + grid.innerHTML = ''; 544 + 545 + const corner = document.createElement('div'); 546 + corner.className = 'grid-corner'; 547 + corner.innerHTML = '<div class="grid-corner-inner">MATURITY<br>↑<br>→ USER DISTANCE</div>'; 548 + grid.appendChild(corner); 549 + 550 + COLUMNS.forEach(col => { 551 + const el = document.createElement('div'); 552 + el.className = 'col-header'; 553 + el.innerHTML = `${col.label}<span class="col-sub">${col.sub}</span>`; 554 + grid.appendChild(el); 555 + }); 556 + 557 + ROWS.forEach(row => { 558 + const rh = document.createElement('div'); 559 + rh.className = 'row-header'; 560 + rh.setAttribute('data-maturity', row.id); 561 + rh.innerHTML = `<span class="row-label">${row.label}</span><span class="row-sub">${row.sub}</span>`; 562 + grid.appendChild(rh); 563 + 564 + COLUMNS.forEach(col => { 565 + const cell = document.createElement('div'); 566 + cell.className = 'grid-cell'; 567 + cell.onclick = () => openModal(col.id, row.id); 568 + 569 + const items = PRODUCTS.filter(p => 570 + p.col === col.id && p.row === row.id && 571 + (activeFilter === 'all' || p.category === activeFilter) 572 + ); 573 + 574 + if (items.length === 0) { 575 + cell.classList.add('empty-zone'); 576 + } else { 577 + const badge = document.createElement('span'); 578 + badge.className = 'cell-count'; 579 + badge.textContent = items.length; 580 + cell.appendChild(badge); 581 + 582 + items.slice(0, MAX_CHIPS).forEach(item => { 583 + const chip = document.createElement('div'); 584 + chip.className = 'chip'; 585 + chip.innerHTML = `<span class="chip-dot" style="background:${row.color}"></span><span class="chip-name">${item.name}</span>`; 586 + cell.appendChild(chip); 587 + }); 588 + 589 + if (items.length > MAX_CHIPS) { 590 + const more = document.createElement('div'); 591 + more.className = 'chip-overflow'; 592 + more.textContent = `+${items.length - MAX_CHIPS} more`; 593 + cell.appendChild(more); 594 + } 595 + } 596 + grid.appendChild(cell); 597 + }); 598 + }); 599 + } 600 + 601 + // ===== MODAL ===== 602 + function openModal(colId, rowId) { 603 + const col = COLUMNS.find(c => c.id === colId); 604 + const row = ROWS.find(r => r.id === rowId); 605 + const items = PRODUCTS.filter(p => 606 + p.col === colId && p.row === rowId && 607 + (activeFilter === 'all' || p.category === activeFilter) 608 + ); 609 + 610 + document.getElementById('modalZone').textContent = `${col.label} × ${row.label}`; 611 + document.getElementById('modalZone').style.color = row.color; 612 + document.getElementById('modalTitle').textContent = items.length > 0 613 + ? `${items.length} product${items.length !== 1 ? 's' : ''}` 614 + : 'Empty Zone'; 615 + document.getElementById('modalSubtitle').textContent = items.length > 0 616 + ? col.sub : 'No products in this space yet — an opportunity.'; 617 + 618 + const body = document.getElementById('modalBody'); 619 + if (items.length === 0) { 620 + body.innerHTML = ` 621 + <div class="modal-empty"> 622 + <div class="modal-empty-icon">?</div> 623 + <div class="modal-empty-text">This zone has no products yet.</div> 624 + <div class="modal-empty-sub">${col.label} at ${row.label} maturity — what could go here?</div> 625 + </div>`; 626 + } else { 627 + body.innerHTML = items.map((item, i) => ` 628 + <div class="product-card" style="animation-delay:${i * 0.05}s"> 629 + <div class="product-dot" style="background:${row.color}"></div> 630 + <div class="product-info"> 631 + <div class="product-name">${item.name}</div> 632 + <div class="product-desc">${item.desc}</div> 633 + <div class="product-tags"> 634 + <span class="product-tag">${item.category}</span> 635 + <span class="product-tag">${col.label}</span> 636 + <span class="product-tag">${row.label}</span> 637 + </div> 638 + </div> 639 + </div>`).join(''); 640 + } 641 + 642 + document.getElementById('modalOverlay').classList.add('open'); 643 + document.body.style.overflow = 'hidden'; 644 + } 645 + 646 + function closeModal() { 647 + document.getElementById('modalOverlay').classList.remove('open'); 648 + document.body.style.overflow = ''; 649 + } 650 + function closeModalOutside(e) { if (e.target === document.getElementById('modalOverlay')) closeModal(); } 651 + document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); 652 + 653 + // ===== BUBBLE ===== 654 + function renderBubble() { 655 + const c = document.getElementById('bubbleView'); 656 + c.innerHTML = ''; 657 + const W = c.clientWidth, H = c.clientHeight; 658 + const PL = 130, PR = 50, PT = 50, PB = 55; 659 + const pW = W-PL-PR, pH = H-PT-PB; 660 + 661 + const cx = {}; COLUMNS.forEach((col,i) => { cx[col.id] = PL + (i+0.5)*(pW/COLUMNS.length); }); 662 + const cy = {}; ROWS.forEach((row,i) => { cy[row.id] = PT + (i+0.5)*(pH/ROWS.length); }); 663 + 664 + c.insertAdjacentHTML('beforeend', ` 665 + <div class="bubble-axes"> 666 + <div class="axis-x" style="bottom:${PB}px;left:${PL}px;right:${PR}px"></div> 667 + <div class="axis-y" style="top:${PT}px;bottom:${PB}px;left:${PL}px"></div> 668 + ${COLUMNS.map(col => `<div class="axis-x-label" style="bottom:${PB-22}px;left:${cx[col.id]}px;transform:translateX(-50%)">${col.label}</div>`).join('')} 669 + <div class="axis-y-label" style="top:${PT+pH/2}px">← Established — Concept →</div> 670 + ${ROWS.map(r => `<div class="zone-line-x" style="top:${cy[r.id]+pH/ROWS.length/2}px"></div>`).join('')} 671 + ${COLUMNS.map(col => `<div class="zone-line-y" style="left:${cx[col.id]+pW/COLUMNS.length/2}px"></div>`).join('')} 672 + </div>`); 673 + 674 + // Void zones 675 + ROWS.forEach(r => { COLUMNS.forEach(col => { 676 + if (!PRODUCTS.some(p => p.col===col.id && p.row===r.id)) { 677 + const el = document.createElement('div'); 678 + el.className = 'void-zone'; 679 + const x = cx[col.id]-pW/COLUMNS.length/2+8, y = cy[r.id]-pH/ROWS.length/2+8; 680 + el.style.cssText = `left:${x}px;top:${y}px;width:${pW/COLUMNS.length-16}px;height:${pH/ROWS.length-16}px`; 681 + el.innerHTML = '<span class="void-zone-label">GAP</span>'; 682 + c.appendChild(el); 683 + } 684 + }); }); 685 + 686 + const filtered = PRODUCTS.filter(p => activeFilter==='all' || p.category===activeFilter); 687 + const placed = []; 688 + filtered.forEach(item => { 689 + const bx = cx[item.col], by = cy[item.row]; 690 + const sz = item.row==='established'?38:item.row==='active'?30:item.row==='prototype'?24:20; 691 + const color = getRowColor(item.row); 692 + let x = bx+(Math.random()-0.5)*(pW/COLUMNS.length*0.55); 693 + let y = by+(Math.random()-0.5)*(pH/ROWS.length*0.45); 694 + for (let a=0;a<20;a++) { 695 + if (!placed.some(p => Math.sqrt((x-p.x)**2+(y-p.y)**2)<(sz/2+p.s/2+3))) break; 696 + x = bx+(Math.random()-0.5)*(pW/COLUMNS.length*0.65); 697 + y = by+(Math.random()-0.5)*(pH/ROWS.length*0.55); 698 + } 699 + placed.push({x,y,s:sz}); 700 + const n = document.createElement('div'); 701 + n.className = 'bubble-node'; 702 + n.style.cssText = `left:${x-sz/2}px;top:${y-sz/2}px;width:${sz}px;height:${sz}px;background:${color};opacity:0.75;box-shadow:0 0 ${sz}px ${color}44`; 703 + n.innerHTML = `<span class="bubble-label">${item.name}</span>`; 704 + c.appendChild(n); 705 + }); 706 + } 707 + 708 + // ===== STATS ===== 709 + function renderStats() { 710 + const bar = document.getElementById('statsBar'); 711 + const total = PRODUCTS.length; 712 + const bc = {}; PRODUCTS.forEach(p => { bc[p.col]=(bc[p.col]||0)+1; }); 713 + let empty = 0; 714 + ROWS.forEach(r => { COLUMNS.forEach(c => { if (!PRODUCTS.some(p=>p.col===c.id&&p.row===r.id)) empty++; }); }); 715 + bar.innerHTML = ` 716 + <div class="stat"><span class="stat-value">${total}</span><span class="stat-label">Products</span></div> 717 + ${COLUMNS.map(c=>`<div class="stat"><span class="stat-value">${bc[c.id]||0}</span><span class="stat-label">${c.label}</span></div>`).join('')} 718 + <div class="stat stat-highlight"><span class="stat-value">${empty}</span><span class="stat-label">Empty Zones</span></div>`; 719 + } 720 + 721 + // ===== FILTERS ===== 722 + function renderFilters() { 723 + const cats = ['all',...new Set(PRODUCTS.map(p=>p.category))]; 724 + document.getElementById('filterRow').innerHTML = cats.map(c=> 725 + `<button class="filter-btn ${c===activeFilter?'active':''}" onclick="setFilter('${c}')">${c}</button>` 726 + ).join(''); 727 + } 728 + function setFilter(cat) { 729 + activeFilter = cat; 730 + renderFilters(); renderGrid(); 731 + if (currentView==='bubble') renderBubble(); 732 + } 733 + 734 + // ===== VIEW SWITCH ===== 735 + function switchView(view) { 736 + currentView = view; 737 + document.querySelectorAll('.view-btn').forEach(b=>b.classList.toggle('active',b.dataset.view===view)); 738 + document.getElementById('gridView').style.display = view==='grid'?'grid':'none'; 739 + document.getElementById('bubbleView').style.display = view==='bubble'?'block':'none'; 740 + if (view==='bubble') setTimeout(()=>renderBubble(),10); 741 + } 742 + 743 + // INIT 744 + renderGrid(); renderStats(); renderFilters(); 745 + </script> 746 + </body> 747 + </html>