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.

at main 702 lines 28 kB view raw
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 — End-User Apps</title> 7<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;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: #5c7093; 18 --text-muted: #9aabbf; 19 --accent: #0085ff; 20 --accent2: #0066cc; 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 --growing: #00a676; 26 --emerging: #e89d2d; 27 --experimental: #c4566a; 28 --gap-bg: rgba(0,133,255,0.02); 29 --gap-border: rgba(0,133,255,0.06); 30} 31 32body { 33 background: var(--bg); 34 color: var(--text); 35 font-family: 'Outfit', sans-serif; 36 min-height: 100vh; 37 overflow-x: hidden; 38} 39 40body::before { 41 content: ''; 42 position: fixed; 43 inset: 0; 44 background-image: radial-gradient(rgba(0,133,255,0.07) 1px, transparent 1px); 45 background-size: 28px 28px; 46 pointer-events: none; 47 z-index: 0; 48} 49 50.app { 51 position: relative; 52 z-index: 1; 53 max-width: 1280px; 54 margin: 0 auto; 55 padding: 48px 32px 80px; 56} 57 58/* HEADER */ 59.header { margin-bottom: 40px; } 60.header-top { 61 display: flex; 62 align-items: baseline; 63 gap: 12px; 64 margin-bottom: 10px; 65} 66.logo { 67 font-family: 'JetBrains Mono', monospace; 68 font-size: 10px; 69 font-weight: 600; 70 color: var(--accent); 71 letter-spacing: 0.25em; 72 text-transform: uppercase; 73} 74.title { 75 font-size: 30px; 76 font-weight: 700; 77 letter-spacing: -0.03em; 78 background: linear-gradient(135deg, #0085ff 0%, #00c2ff 50%, #0085ff 100%); 79 -webkit-background-clip: text; 80 -webkit-text-fill-color: transparent; 81 background-clip: text; 82} 83.subtitle { 84 font-size: 13px; 85 color: var(--text-dim); 86 line-height: 1.7; 87 max-width: 640px; 88} 89.subtitle a { color: var(--accent); text-decoration: none; } 90.subtitle a:hover { text-decoration: underline; } 91 92/* CONTROLS */ 93.controls { 94 display: flex; 95 align-items: center; 96 gap: 16px; 97 margin-bottom: 28px; 98 flex-wrap: wrap; 99} 100.view-toggle { 101 display: flex; 102 background: var(--bg3); 103 border: 1px solid var(--border); 104 border-radius: 8px; 105 overflow: hidden; 106} 107.view-btn { 108 background: none; 109 border: none; 110 color: var(--text-dim); 111 font-family: 'JetBrains Mono', monospace; 112 font-size: 10px; 113 font-weight: 600; 114 letter-spacing: 0.12em; 115 padding: 10px 18px; 116 cursor: pointer; 117 transition: all 0.2s; 118 text-transform: uppercase; 119} 120.view-btn:hover { color: var(--text); } 121.view-btn.active { background: var(--accent); color: #fff; } 122 123.legend { display: flex; gap: 18px; margin-left: auto; } 124.legend-item { 125 display: flex; align-items: center; gap: 6px; 126 font-size: 10px; color: var(--text-dim); 127 font-family: 'JetBrains Mono', monospace; 128 letter-spacing: 0.03em; 129} 130.legend-dot { width: 8px; height: 8px; border-radius: 50%; } 131 132/* STATS BAR */ 133.stats-bar { 134 display: flex; gap: 20px; margin-bottom: 28px; flex-wrap: wrap; 135} 136.stat { 137 display: flex; flex-direction: column; align-items: center; 138 background: var(--bg2); border: 1px solid var(--border); 139 border-radius: 8px; padding: 10px 18px; min-width: 80px; 140} 141.stat-value { 142 font-family: 'JetBrains Mono', monospace; 143 font-size: 20px; font-weight: 700; color: var(--accent); 144} 145.stat-label { 146 font-size: 9px; color: var(--text-muted); 147 font-family: 'JetBrains Mono', monospace; 148 letter-spacing: 0.08em; text-transform: uppercase; margin-top: 2px; 149} 150.stat-highlight .stat-value { color: var(--experimental); } 151 152/* GRID */ 153.grid-view { 154 display: grid; 155 grid-template-columns: 140px repeat(var(--cols), 1fr); 156 gap: 1px; 157 background: var(--border); 158 border: 1px solid var(--border); 159 border-radius: 12px; 160 overflow: hidden; 161} 162.grid-corner { 163 background: var(--bg2); 164 display: flex; align-items: center; justify-content: center; padding: 14px; 165} 166.grid-corner-inner { 167 font-family: 'JetBrains Mono', monospace; 168 font-size: 8px; color: var(--text-muted); 169 text-align: center; line-height: 1.6; letter-spacing: 0.12em; 170 text-transform: uppercase; 171} 172.col-header { 173 background: var(--bg2); 174 padding: 16px 10px; text-align: center; 175 font-family: 'JetBrains Mono', monospace; 176 font-size: 10px; font-weight: 600; 177 color: var(--accent2); 178 letter-spacing: 0.08em; text-transform: uppercase; 179 border-bottom: 2px solid var(--border-active); 180} 181.col-header .col-sub { 182 display: block; font-size: 9px; color: var(--text-dim); 183 font-weight: 400; margin-top: 4px; text-transform: none; 184 letter-spacing: 0; 185} 186.row-header { 187 background: var(--bg2); 188 padding: 14px 14px; 189 display: flex; flex-direction: column; justify-content: center; gap: 3px; 190 border-right: 2px solid var(--border-active); 191} 192.row-label { 193 font-family: 'JetBrains Mono', monospace; 194 font-size: 11px; font-weight: 600; letter-spacing: 0.04em; 195 color: var(--accent); 196} 197.row-sub { font-size: 9px; color: var(--text-dim); line-height: 1.4; } 198 199.grid-cell { 200 background: var(--bg); 201 padding: 10px; 202 display: flex; flex-wrap: wrap; align-content: flex-start; gap: 5px; 203 transition: background 0.2s, box-shadow 0.2s; 204 min-height: 90px; 205 cursor: pointer; 206 position: relative; 207} 208.grid-cell:hover { 209 background: var(--cell-hover); 210 box-shadow: inset 0 0 0 1px var(--border-active); 211} 212.grid-cell.empty-zone { 213 background: var(--gap-bg); 214} 215.grid-cell.empty-zone::after { 216 content: '?'; 217 position: absolute; inset: 0; 218 display: flex; align-items: center; justify-content: center; 219 font-family: 'JetBrains Mono', monospace; 220 font-size: 22px; color: var(--text-muted); opacity: 0.25; 221 pointer-events: none; 222} 223.cell-count { 224 position: absolute; top: 5px; right: 7px; 225 font-family: 'JetBrains Mono', monospace; 226 font-size: 9px; color: var(--text-muted); 227 pointer-events: none; 228} 229 230/* CHIPS */ 231.chip { 232 display: inline-flex; align-items: center; gap: 5px; 233 background: var(--bg3); 234 border: 1px solid var(--border); border-radius: 6px; 235 padding: 4px 8px; 236 font-size: 10px; font-weight: 500; color: var(--text); 237 transition: all 0.15s; 238} 239.chip:hover { border-color: var(--border-active); background: var(--bg4); } 240.chip-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } 241.chip-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100px; } 242.chip-overflow { 243 font-family: 'JetBrains Mono', monospace; 244 font-size: 9px; color: var(--text-muted); 245} 246 247/* SCATTER VIEW */ 248.bubble-view { 249 display: none; 250 position: relative; 251 width: 100%; height: 600px; 252 background: var(--bg2); 253 border: 1px solid var(--border); 254 border-radius: 12px; 255 overflow: hidden; 256} 257.bubble-node { 258 position: absolute; 259 border-radius: 50%; 260 display: flex; align-items: center; justify-content: center; 261 cursor: pointer; 262 transition: transform 0.2s, opacity 0.2s; 263} 264.bubble-node:hover { transform: scale(1.25); opacity: 1 !important; z-index: 10; } 265.bubble-label { 266 position: absolute; 267 top: calc(100% + 4px); left: 50%; transform: translateX(-50%); 268 font-size: 9px; color: var(--text-dim); 269 white-space: nowrap; pointer-events: none; 270 font-family: 'JetBrains Mono', monospace; 271 opacity: 0; transition: opacity 0.2s; 272} 273.bubble-node:hover .bubble-label { opacity: 1; } 274.bubble-axes { position: absolute; inset: 0; pointer-events: none; } 275.axis-x, .axis-y { position: absolute; background: var(--border); } 276.axis-x { height: 1px; } 277.axis-y { width: 1px; } 278.axis-x-label { 279 position: absolute; 280 font-family: 'JetBrains Mono', monospace; 281 font-size: 9px; color: var(--text-muted); 282 letter-spacing: 0.06em; text-transform: uppercase; 283} 284.axis-y-label { 285 position: absolute; left: 14px; 286 font-family: 'JetBrains Mono', monospace; 287 font-size: 9px; color: var(--text-muted); 288 transform: rotate(-90deg); transform-origin: left center; 289 letter-spacing: 0.06em; text-transform: uppercase; 290 white-space: nowrap; 291} 292.zone-line-x, .zone-line-y { position: absolute; } 293.zone-line-x { left: 0; right: 0; height: 1px; background: var(--border); } 294.zone-line-y { top: 0; bottom: 0; width: 1px; background: var(--border); } 295.void-zone { 296 position: absolute; 297 border: 1px dashed var(--gap-border); 298 border-radius: 8px; 299 display: flex; align-items: center; justify-content: center; 300} 301.void-zone-label { 302 font-family: 'JetBrains Mono', monospace; 303 font-size: 10px; color: var(--text-muted); opacity: 0.3; 304 letter-spacing: 0.15em; 305} 306 307/* MODAL */ 308.modal-overlay { 309 display: none; position: fixed; inset: 0; 310 background: rgba(0,0,0,0.4); z-index: 100; 311 align-items: center; justify-content: center; 312 backdrop-filter: blur(4px); 313} 314.modal-overlay.open { display: flex; } 315.modal { 316 background: var(--bg2); 317 border: 1px solid var(--border-active); 318 border-radius: 14px; 319 width: 90%; max-width: 520px; max-height: 80vh; 320 overflow-y: auto; padding: 28px; 321 box-shadow: 0 24px 64px rgba(0,0,0,0.15); 322} 323.modal-zone { 324 font-family: 'JetBrains Mono', monospace; 325 font-size: 10px; font-weight: 600; 326 letter-spacing: 0.12em; text-transform: uppercase; 327 margin-bottom: 4px; 328} 329.modal-title { font-size: 20px; font-weight: 600; margin-bottom: 4px; } 330.modal-subtitle { font-size: 12px; color: var(--text-dim); margin-bottom: 20px; } 331.modal-close { 332 float: right; background: none; border: none; 333 color: var(--text-muted); font-size: 22px; cursor: pointer; 334 line-height: 1; 335} 336.modal-close:hover { color: var(--text); } 337.product-card { 338 display: flex; gap: 12px; padding: 12px; 339 background: var(--bg3); border: 1px solid var(--border); 340 border-radius: 8px; margin-bottom: 8px; 341 animation: fadeSlide 0.25s ease both; 342} 343@keyframes fadeSlide { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } 344.product-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; } 345.product-info { flex: 1; } 346.product-name { font-weight: 600; font-size: 13px; margin-bottom: 3px; } 347.product-desc { font-size: 11px; color: var(--text-dim); line-height: 1.5; margin-bottom: 6px; } 348.product-tags { display: flex; gap: 5px; flex-wrap: wrap; } 349.product-tag { 350 font-family: 'JetBrains Mono', monospace; 351 font-size: 9px; background: var(--bg4); 352 border: 1px solid var(--border); border-radius: 4px; 353 padding: 2px 7px; color: var(--text-dim); 354} 355.modal-empty { text-align: center; padding: 32px 0; } 356.modal-empty-icon { 357 font-family: 'JetBrains Mono', monospace; 358 font-size: 40px; color: var(--text-muted); opacity: 0.3; margin-bottom: 12px; 359} 360.modal-empty-text { font-size: 14px; color: var(--text-dim); margin-bottom: 6px; } 361.modal-empty-sub { font-size: 11px; color: var(--text-muted); } 362 363/* RESPONSIVE */ 364@media (max-width: 900px) { 365 .grid-view { grid-template-columns: 100px repeat(var(--cols), 1fr); font-size: 9px; } 366 .col-header { font-size: 9px; padding: 10px 6px; } 367 .row-header { padding: 10px 8px; } 368 .row-label { font-size: 9px; } 369 .chip { font-size: 9px; padding: 3px 6px; } 370 .legend { display: none; } 371} 372@media (max-width: 600px) { 373 .app { padding: 20px 12px 60px; } 374 .title { font-size: 20px; } 375 .grid-view { grid-template-columns: 80px repeat(var(--cols), 1fr); } 376 .col-header .col-sub { display: none; } 377 .stats-bar { gap: 8px; } 378 .stat { padding: 8px 10px; min-width: 60px; } 379} 380</style> 381</head> 382<body> 383 384<div class="app"> 385 <div class="header"> 386 <div class="header-top"> 387 <span class="logo">ATProto</span> 388 <h1 class="title">Ecosystem Map</h1> 389 </div> 390 <p class="subtitle"> 391 What exists — and what's missing — in the ATmosphere. End-user apps mapped by what people do with them.<br> 392 Data layer: <a href="https://discourse.atprotocol.community/t/a-community-app-lexicon/656" target="_blank">community app lexicon</a> · 393 Vis layer: <a href="https://tangled.org/moja.blue/atproto-ecosystem-map" target="_blank">source</a> 394 </p> 395 </div> 396 397 <div class="controls"> 398 <div class="view-toggle"> 399 <button class="view-btn active" data-view="grid" onclick="switchView('grid')">Grid</button> 400 <button class="view-btn" data-view="scatter" onclick="switchView('scatter')">Scatter</button> 401 </div> 402 <div class="legend"> 403 <div class="legend-item"><div class="legend-dot" style="background:var(--established)"></div>Established</div> 404 <div class="legend-item"><div class="legend-dot" style="background:var(--growing)"></div>Growing</div> 405 <div class="legend-item"><div class="legend-dot" style="background:var(--emerging)"></div>Emerging</div> 406 <div class="legend-item"><div class="legend-dot" style="background:var(--experimental)"></div>Experimental</div> 407 </div> 408 </div> 409 410 <div class="stats-bar" id="statsBar"></div> 411 <div class="grid-view" id="gridView"></div> 412 <div class="bubble-view" id="bubbleView"></div> 413</div> 414 415<div class="modal-overlay" id="modalOverlay" onclick="closeModalOutside(event)"> 416 <div class="modal"> 417 <button class="modal-close" onclick="closeModal()">×</button> 418 <div class="modal-zone" id="modalZone"></div> 419 <div class="modal-title" id="modalTitle"></div> 420 <div class="modal-subtitle" id="modalSubtitle"></div> 421 <div id="modalBody"></div> 422 </div> 423</div> 424 425<script> 426// === AXES === 427// Y-axis: User action (what do people DO with these apps?) 428const ROWS = [ 429 { id: 'communicate', label: 'Communicate', sub: 'Post, chat, message, reply', color: 'var(--established)' }, 430 { id: 'discover', label: 'Discover', sub: 'Search, explore, recommend', color: 'var(--established)' }, 431 { id: 'create', label: 'Create', sub: 'Blog, publish, broadcast', color: 'var(--established)' }, 432 { id: 'analyze', label: 'Analyze', sub: 'Stats, graphs, monitor', color: 'var(--established)' }, 433 { id: 'moderate', label: 'Moderate', sub: 'Label, filter, safety', color: 'var(--established)' }, 434 { id: 'manage', label: 'Manage', sub: 'Account, data, migrate', color: 'var(--established)' }, 435]; 436 437// X-axis: Scope — how broad is the user base? 438const COLUMNS = [ 439 { id: 'everyone', label: 'Everyone', sub: 'General-purpose' }, 440 { id: 'community', label: 'Community', sub: 'Interest groups' }, 441 { id: 'creator', label: 'Creators', sub: 'Publishers & makers' }, 442 { id: 'power', label: 'Power Users', sub: 'Advanced & niche' }, 443]; 444 445// === MATURITY (expressed as color) === 446const MATURITY = { 447 established: { color: 'var(--established)', label: 'Established' }, 448 growing: { color: 'var(--growing)', label: 'Growing' }, 449 emerging: { color: 'var(--emerging)', label: 'Emerging' }, 450 experimental: { color: 'var(--experimental)', label: 'Experimental' }, 451}; 452 453// === PRODUCTS (End-User Apps only, from atproto-jp.dev hackersspace + key ATmosphere apps) === 454const PRODUCTS = [ 455 // Communicate × Everyone 456 { name: 'Bluesky', col: 'everyone', row: 'communicate', maturity: 'established', desc: 'The flagship ATProto social app' }, 457 { name: 'Kite', col: 'everyone', row: 'communicate', maturity: 'growing', desc: 'Alternative Bluesky client for Android' }, 458 { name: 'Skeets', col: 'everyone', row: 'communicate', maturity: 'growing', desc: 'iOS Bluesky client with iCloud sync' }, 459 { name: 'Flux', col: 'everyone', row: 'communicate', maturity: 'growing', desc: 'Minimal iOS Bluesky client' }, 460 { name: 'Graysky', col: 'everyone', row: 'communicate', maturity: 'growing', desc: 'Feature-rich mobile Bluesky client' }, 461 { name: 'TOKIMEKI', col: 'everyone', row: 'communicate', maturity: 'growing', desc: 'Japanese-designed Bluesky web client' }, 462 // Communicate × Community 463 { name: 'Roomy', col: 'community', row: 'communicate', maturity: 'emerging', desc: 'Group chat on ATProto' }, 464 { name: 'Picosky', col: 'community', row: 'communicate', maturity: 'experimental',desc: 'Lightweight ATProto microblog' }, 465 // Communicate × Power 466 { name: 'SkyView', col: 'power', row: 'communicate', maturity: 'growing', desc: 'Public post viewer without auth' }, 467 468 // Discover × Everyone 469 { name: 'Skylight', col: 'everyone', row: 'discover', maturity: 'growing', desc: 'Video discovery on ATProto' }, 470 { name: 'Flashes', col: 'everyone', row: 'discover', maturity: 'growing', desc: 'Instagram-style photo discovery' }, 471 // Discover × Community 472 { name: 'Sill', col: 'community', row: 'discover', maturity: 'growing', desc: 'Popular link aggregator from social graph' }, 473 { name: 'docs.surf', col: 'community', row: 'discover', maturity: 'emerging', desc: 'standard.site post aggregator' }, 474 // Discover × Power 475 { name: 'Skyfeed', col: 'power', row: 'discover', maturity: 'growing', desc: 'Custom feed builder' }, 476 { name: 'UCHO-TEN', col: 'power', row: 'discover', maturity: 'growing', desc: 'Auto-labeler for feed curation' }, 477 478 // Create × Everyone 479 { name: 'Leaflet', col: 'everyone', row: 'create', maturity: 'growing', desc: 'Long-form blogging on ATProto' }, 480 // Create × Creator 481 { name: 'Greengale', col: 'creator', row: 'create', maturity: 'emerging', desc: 'Blog platform on ATProto' }, 482 { name: 'pckt', col: 'creator', row: 'create', maturity: 'emerging', desc: 'Publishing platform for ATProto blogs' }, 483 { name: 'Bluecast', col: 'creator', row: 'create', maturity: 'emerging', desc: 'Podcast hosting on ATProto' }, 484 { name: 'WhiteWind', col: 'creator', row: 'create', maturity: 'growing', desc: 'Markdown blogging on ATProto' }, 485 // Create × Power 486 { name: 'standard.site',col: 'power', row: 'create', maturity: 'emerging', desc: 'Blog lexicon standard' }, 487 488 // Analyze × Everyone 489 { name: 'Bluesky Stats',col: 'everyone', row: 'analyze', maturity: 'growing', desc: 'Account analytics dashboard' }, 490 // Analyze × Community 491 { name: 'ClearSky', col: 'community', row: 'analyze', maturity: 'growing', desc: 'Block list analytics' }, 492 { name: 'Atlas', col: 'community', row: 'analyze', maturity: 'emerging', desc: 'Network visualization & stats' }, 493 // Analyze × Power 494 { name: 'Firesky', col: 'power', row: 'analyze', maturity: 'growing', desc: 'Real-time firehose viewer' }, 495 496 // Moderate × Community 497 { name: 'Ozone', col: 'community', row: 'moderate', maturity: 'established', desc: 'Official moderation tool' }, 498 // Moderate × Power 499 { name: 'Blacksky', col: 'power', row: 'moderate', maturity: 'growing', desc: 'Community moderation service' }, 500 501 // Manage × Everyone 502 { name: 'cred.blue', col: 'everyone', row: 'manage', maturity: 'growing', desc: 'Verification & authentication' }, 503 // Manage × Power 504 { name: 'SkyBridge', col: 'power', row: 'manage', maturity: 'emerging', desc: 'Mastodon-compatible ATProto bridge' }, 505 { name: 'PDS Admin', col: 'power', row: 'manage', maturity: 'emerging', desc: 'Self-hosted PDS management' }, 506 { name: 'Semble', col: 'power', row: 'manage', maturity: 'growing', desc: 'Collection & curation tool' }, 507]; 508 509let currentView = 'grid'; 510const MAX_CHIPS = 4; 511 512function getMaturityColor(m) { return MATURITY[m]?.color || 'var(--text-muted)'; } 513 514// ===== GRID ===== 515function renderGrid() { 516 const grid = document.getElementById('gridView'); 517 grid.style.setProperty('--cols', COLUMNS.length); 518 grid.innerHTML = ''; 519 520 const corner = document.createElement('div'); 521 corner.className = 'grid-corner'; 522 corner.innerHTML = '<div class="grid-corner-inner">Action<br>↓<br>→ Audience</div>'; 523 grid.appendChild(corner); 524 525 COLUMNS.forEach(col => { 526 const el = document.createElement('div'); 527 el.className = 'col-header'; 528 el.innerHTML = `${col.label}<span class="col-sub">${col.sub}</span>`; 529 grid.appendChild(el); 530 }); 531 532 ROWS.forEach(row => { 533 const rh = document.createElement('div'); 534 rh.className = 'row-header'; 535 rh.innerHTML = `<span class="row-label">${row.label}</span><span class="row-sub">${row.sub}</span>`; 536 grid.appendChild(rh); 537 538 COLUMNS.forEach(col => { 539 const cell = document.createElement('div'); 540 cell.className = 'grid-cell'; 541 cell.onclick = () => openModal(col.id, row.id); 542 543 const items = PRODUCTS.filter(p => p.col === col.id && p.row === row.id); 544 545 if (items.length === 0) { 546 cell.classList.add('empty-zone'); 547 } else { 548 const badge = document.createElement('span'); 549 badge.className = 'cell-count'; 550 badge.textContent = items.length; 551 cell.appendChild(badge); 552 553 items.slice(0, MAX_CHIPS).forEach(item => { 554 const chip = document.createElement('div'); 555 chip.className = 'chip'; 556 chip.innerHTML = `<span class="chip-dot" style="background:${getMaturityColor(item.maturity)}"></span><span class="chip-name">${item.name}</span>`; 557 cell.appendChild(chip); 558 }); 559 560 if (items.length > MAX_CHIPS) { 561 const more = document.createElement('div'); 562 more.className = 'chip-overflow'; 563 more.textContent = `+${items.length - MAX_CHIPS} more`; 564 cell.appendChild(more); 565 } 566 } 567 grid.appendChild(cell); 568 }); 569 }); 570} 571 572// ===== MODAL ===== 573function openModal(colId, rowId) { 574 const col = COLUMNS.find(c => c.id === colId); 575 const row = ROWS.find(r => r.id === rowId); 576 const items = PRODUCTS.filter(p => p.col === colId && p.row === rowId); 577 578 document.getElementById('modalZone').textContent = `${row.label} × ${col.label}`; 579 document.getElementById('modalZone').style.color = 'var(--accent2)'; 580 document.getElementById('modalTitle').textContent = items.length > 0 581 ? `${items.length} app${items.length !== 1 ? 's' : ''}` 582 : 'Empty Zone'; 583 document.getElementById('modalSubtitle').textContent = items.length > 0 584 ? `${row.sub} · ${col.sub}` : 'No apps in this space yet — an opportunity.'; 585 586 const body = document.getElementById('modalBody'); 587 if (items.length === 0) { 588 body.innerHTML = ` 589 <div class="modal-empty"> 590 <div class="modal-empty-icon">?</div> 591 <div class="modal-empty-text">This zone has no apps yet.</div> 592 <div class="modal-empty-sub">${row.label} for ${col.label} — what could go here?</div> 593 </div>`; 594 } else { 595 body.innerHTML = items.map((item, i) => ` 596 <div class="product-card" style="animation-delay:${i * 0.05}s"> 597 <div class="product-dot" style="background:${getMaturityColor(item.maturity)}"></div> 598 <div class="product-info"> 599 <div class="product-name">${item.name}</div> 600 <div class="product-desc">${item.desc}</div> 601 <div class="product-tags"> 602 <span class="product-tag">${item.maturity}</span> 603 <span class="product-tag">${row.label}</span> 604 <span class="product-tag">${col.label}</span> 605 </div> 606 </div> 607 </div>`).join(''); 608 } 609 610 document.getElementById('modalOverlay').classList.add('open'); 611 document.body.style.overflow = 'hidden'; 612} 613 614function closeModal() { 615 document.getElementById('modalOverlay').classList.remove('open'); 616 document.body.style.overflow = ''; 617} 618function closeModalOutside(e) { if (e.target === document.getElementById('modalOverlay')) closeModal(); } 619document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); 620 621// ===== SCATTER ===== 622function renderScatter() { 623 const c = document.getElementById('bubbleView'); 624 c.innerHTML = ''; 625 const W = c.clientWidth, H = c.clientHeight; 626 const PL = 140, PR = 50, PT = 50, PB = 55; 627 const pW = W-PL-PR, pH = H-PT-PB; 628 629 const cx = {}; COLUMNS.forEach((col,i) => { cx[col.id] = PL + (i+0.5)*(pW/COLUMNS.length); }); 630 const cy = {}; ROWS.forEach((row,i) => { cy[row.id] = PT + (i+0.5)*(pH/ROWS.length); }); 631 632 c.insertAdjacentHTML('beforeend', ` 633 <div class="bubble-axes"> 634 <div class="axis-x" style="bottom:${PB}px;left:${PL}px;right:${PR}px"></div> 635 <div class="axis-y" style="top:${PT}px;bottom:${PB}px;left:${PL}px"></div> 636 ${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('')} 637 <div class="axis-y-label" style="top:${PT+pH/2}px">← Communicate — Manage →</div> 638 ${ROWS.map(r => `<div class="zone-line-x" style="top:${cy[r.id]+pH/ROWS.length/2}px"></div>`).join('')} 639 ${COLUMNS.map(col => `<div class="zone-line-y" style="left:${cx[col.id]+pW/COLUMNS.length/2}px"></div>`).join('')} 640 </div>`); 641 642 // Void zones 643 ROWS.forEach(r => { COLUMNS.forEach(col => { 644 if (!PRODUCTS.some(p => p.col===col.id && p.row===r.id)) { 645 const el = document.createElement('div'); 646 el.className = 'void-zone'; 647 const x = cx[col.id]-pW/COLUMNS.length/2+8, y = cy[r.id]-pH/ROWS.length/2+8; 648 el.style.cssText = `left:${x}px;top:${y}px;width:${pW/COLUMNS.length-16}px;height:${pH/ROWS.length-16}px`; 649 el.innerHTML = '<span class="void-zone-label">GAP</span>'; 650 c.appendChild(el); 651 } 652 }); }); 653 654 const placed = []; 655 PRODUCTS.forEach(item => { 656 const bx = cx[item.col], by = cy[item.row]; 657 const sz = item.maturity==='established'?40:item.maturity==='growing'?32:item.maturity==='emerging'?24:18; 658 const color = getMaturityColor(item.maturity); 659 let x = bx+(Math.random()-0.5)*(pW/COLUMNS.length*0.55); 660 let y = by+(Math.random()-0.5)*(pH/ROWS.length*0.45); 661 for (let a=0;a<20;a++) { 662 if (!placed.some(p => Math.sqrt((x-p.x)**2+(y-p.y)**2)<(sz/2+p.s/2+3))) break; 663 x = bx+(Math.random()-0.5)*(pW/COLUMNS.length*0.65); 664 y = by+(Math.random()-0.5)*(pH/ROWS.length*0.55); 665 } 666 placed.push({x,y,s:sz}); 667 const n = document.createElement('div'); 668 n.className = 'bubble-node'; 669 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.replace('var(--','').replace(')','') === 'established' ? 'rgba(0,133,255,0.25)' : 'rgba(255,255,255,0.1)'}`; 670 n.innerHTML = `<span class="bubble-label">${item.name}</span>`; 671 c.appendChild(n); 672 }); 673} 674 675// ===== STATS ===== 676function renderStats() { 677 const bar = document.getElementById('statsBar'); 678 const total = PRODUCTS.length; 679 let empty = 0; 680 ROWS.forEach(r => { COLUMNS.forEach(c => { if (!PRODUCTS.some(p=>p.col===c.id&&p.row===r.id)) empty++; }); }); 681 const byRow = {}; 682 ROWS.forEach(r => { byRow[r.id] = PRODUCTS.filter(p=>p.row===r.id).length; }); 683 bar.innerHTML = ` 684 <div class="stat"><span class="stat-value">${total}</span><span class="stat-label">Apps</span></div> 685 ${ROWS.map(r=>`<div class="stat"><span class="stat-value">${byRow[r.id]}</span><span class="stat-label">${r.label}</span></div>`).join('')} 686 <div class="stat stat-highlight"><span class="stat-value">${empty}</span><span class="stat-label">Gaps</span></div>`; 687} 688 689// ===== VIEW SWITCH ===== 690function switchView(view) { 691 currentView = view; 692 document.querySelectorAll('.view-btn').forEach(b=>b.classList.toggle('active',b.dataset.view===view)); 693 document.getElementById('gridView').style.display = view==='grid'?'grid':'none'; 694 document.getElementById('bubbleView').style.display = view==='scatter'?'block':'none'; 695 if (view==='scatter') setTimeout(()=>renderScatter(),10); 696} 697 698// INIT 699renderGrid(); renderStats(); 700</script> 701</body> 702</html>