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.

Redesign: end-user apps, action×audience axes, maturity as color

+317 -362
+317 -362
index.html
··· 3 3 <head> 4 4 <meta charset="UTF-8"> 5 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"> 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 8 <style> 9 9 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 10 10 ··· 14 14 --bg3: #e8edf5; 15 15 --bg4: #dce3ef; 16 16 --text: #1b2840; 17 - --text-dim: #6b7d99; 18 - --text-muted: #a3b1c6; 17 + --text-dim: #5c7093; 18 + --text-muted: #9aabbf; 19 19 --accent: #0085ff; 20 - --accent2: #00c2ff; 20 + --accent2: #0066cc; 21 21 --border: rgba(0,133,255,0.15); 22 22 --border-active: rgba(0,133,255,0.35); 23 23 --cell-hover: rgba(0,133,255,0.05); 24 24 --established: #0085ff; 25 - --active: #00a676; 26 - --prototype: #e89d2d; 27 - --concept: #c4566a; 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); 28 30 } 29 31 30 32 body { 31 33 background: var(--bg); 32 34 color: var(--text); 33 - font-family: 'DM Sans', sans-serif; 35 + font-family: 'Outfit', sans-serif; 34 36 min-height: 100vh; 35 37 overflow-x: hidden; 36 38 } ··· 39 41 content: ''; 40 42 position: fixed; 41 43 inset: 0; 42 - background-image: radial-gradient(rgba(0,133,255,0.08) 1px, transparent 1px); 43 - background-size: 24px 24px; 44 + background-image: radial-gradient(rgba(0,133,255,0.07) 1px, transparent 1px); 45 + background-size: 28px 28px; 44 46 pointer-events: none; 45 47 z-index: 0; 46 48 } ··· 48 50 .app { 49 51 position: relative; 50 52 z-index: 1; 51 - max-width: 1200px; 53 + max-width: 1280px; 52 54 margin: 0 auto; 53 - padding: 40px 32px 80px; 55 + padding: 48px 32px 80px; 54 56 } 55 57 58 + /* HEADER */ 56 59 .header { margin-bottom: 40px; } 57 60 .header-top { 58 61 display: flex; 59 62 align-items: baseline; 60 63 gap: 12px; 61 - margin-bottom: 8px; 64 + margin-bottom: 10px; 62 65 } 63 66 .logo { 64 - font-family: 'IBM Plex Mono', monospace; 65 - font-size: 11px; 67 + font-family: 'JetBrains Mono', monospace; 68 + font-size: 10px; 66 69 font-weight: 600; 67 70 color: var(--accent); 68 - letter-spacing: 0.2em; 71 + letter-spacing: 0.25em; 69 72 text-transform: uppercase; 70 73 } 71 74 .title { 72 - font-size: 28px; 75 + font-size: 30px; 73 76 font-weight: 700; 74 - letter-spacing: -0.02em; 75 - background: linear-gradient(135deg, #0085ff, #00c2ff); 77 + letter-spacing: -0.03em; 78 + background: linear-gradient(135deg, #0085ff 0%, #00c2ff 50%, #0085ff 100%); 76 79 -webkit-background-clip: text; 77 80 -webkit-text-fill-color: transparent; 78 81 background-clip: text; ··· 80 83 .subtitle { 81 84 font-size: 13px; 82 85 color: var(--text-dim); 83 - line-height: 1.6; 84 - max-width: 600px; 86 + line-height: 1.7; 87 + max-width: 640px; 85 88 } 89 + .subtitle a { color: var(--accent); text-decoration: none; } 90 + .subtitle a:hover { text-decoration: underline; } 86 91 92 + /* CONTROLS */ 87 93 .controls { 88 94 display: flex; 89 95 align-items: center; 90 96 gap: 16px; 91 - margin-bottom: 32px; 97 + margin-bottom: 28px; 92 98 flex-wrap: wrap; 93 99 } 94 100 .view-toggle { ··· 102 108 background: none; 103 109 border: none; 104 110 color: var(--text-dim); 105 - font-family: 'IBM Plex Mono', monospace; 106 - font-size: 11px; 111 + font-family: 'JetBrains Mono', monospace; 112 + font-size: 10px; 107 113 font-weight: 600; 108 - letter-spacing: 0.1em; 114 + letter-spacing: 0.12em; 109 115 padding: 10px 18px; 110 116 cursor: pointer; 111 117 transition: all 0.2s; ··· 114 120 .view-btn:hover { color: var(--text); } 115 121 .view-btn.active { background: var(--accent); color: #fff; } 116 122 117 - .legend { display: flex; gap: 16px; margin-left: auto; } 123 + .legend { display: flex; gap: 18px; margin-left: auto; } 118 124 .legend-item { 119 125 display: flex; align-items: center; gap: 6px; 120 - font-size: 11px; color: var(--text-dim); 121 - font-family: 'IBM Plex Mono', monospace; 126 + font-size: 10px; color: var(--text-dim); 127 + font-family: 'JetBrains Mono', monospace; 128 + letter-spacing: 0.03em; 122 129 } 123 130 .legend-dot { width: 8px; height: 8px; border-radius: 50%; } 124 131 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; 132 + /* STATS BAR */ 133 + .stats-bar { 134 + display: flex; gap: 20px; margin-bottom: 28px; flex-wrap: wrap; 137 135 } 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); } 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); } 140 151 141 - /* ===== GRID ===== */ 152 + /* GRID */ 142 153 .grid-view { 143 154 display: grid; 144 - grid-template-columns: 120px repeat(4, 1fr); 145 - grid-template-rows: auto repeat(4, minmax(100px, auto)); 155 + grid-template-columns: 140px repeat(var(--cols), 1fr); 146 156 gap: 1px; 147 157 background: var(--border); 148 158 border: 1px solid var(--border); ··· 151 161 } 152 162 .grid-corner { 153 163 background: var(--bg2); 154 - display: flex; align-items: center; justify-content: center; padding: 12px; 164 + display: flex; align-items: center; justify-content: center; padding: 14px; 155 165 } 156 166 .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; 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; 160 171 } 161 172 .col-header { 162 173 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); 174 + padding: 16px 10px; text-align: center; 175 + font-family: 'JetBrains Mono', monospace; 176 + font-size: 10px; font-weight: 600; 177 + color: var(--accent2); 167 178 letter-spacing: 0.08em; text-transform: uppercase; 168 179 border-bottom: 2px solid var(--border-active); 169 180 } 170 181 .col-header .col-sub { 171 182 display: block; font-size: 9px; color: var(--text-dim); 172 183 font-weight: 400; margin-top: 4px; text-transform: none; 184 + letter-spacing: 0; 173 185 } 174 186 .row-header { 175 187 background: var(--bg2); 176 - padding: 12px 14px; 177 - display: flex; flex-direction: column; justify-content: center; gap: 4px; 188 + padding: 14px 14px; 189 + display: flex; flex-direction: column; justify-content: center; gap: 3px; 178 190 border-right: 2px solid var(--border-active); 179 191 } 180 192 .row-label { 181 - font-family: 'IBM Plex Mono', monospace; 182 - font-size: 11px; font-weight: 600; letter-spacing: 0.05em; 193 + font-family: 'JetBrains Mono', monospace; 194 + font-size: 11px; font-weight: 600; letter-spacing: 0.04em; 195 + color: var(--accent); 183 196 } 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); } 197 + .row-sub { font-size: 9px; color: var(--text-dim); line-height: 1.4; } 189 198 190 199 .grid-cell { 191 200 background: var(--bg); 192 201 padding: 10px; 193 - display: flex; flex-wrap: wrap; align-content: flex-start; gap: 6px; 202 + display: flex; flex-wrap: wrap; align-content: flex-start; gap: 5px; 194 203 transition: background 0.2s, box-shadow 0.2s; 195 - min-height: 100px; 204 + min-height: 90px; 196 205 cursor: pointer; 197 206 position: relative; 198 207 } 199 208 .grid-cell:hover { 200 209 background: var(--cell-hover); 201 - box-shadow: inset 0 0 0 2px var(--border-active); 210 + box-shadow: inset 0 0 0 1px var(--border-active); 202 211 } 203 - .grid-cell.empty-zone { position: relative; } 212 + .grid-cell.empty-zone { 213 + background: var(--gap-bg); 214 + } 204 215 .grid-cell.empty-zone::after { 205 216 content: '?'; 206 217 position: absolute; inset: 0; 207 218 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; 219 + font-family: 'JetBrains Mono', monospace; 220 + font-size: 22px; color: var(--text-muted); opacity: 0.25; 210 221 pointer-events: none; 211 222 } 212 - 213 223 .cell-count { 214 - position: absolute; top: 6px; right: 8px; 215 - font-family: 'IBM Plex Mono', monospace; 224 + position: absolute; top: 5px; right: 7px; 225 + font-family: 'JetBrains Mono', monospace; 216 226 font-size: 9px; color: var(--text-muted); 217 227 pointer-events: none; 218 228 } 219 229 230 + /* CHIPS */ 220 231 .chip { 221 232 display: inline-flex; align-items: center; gap: 5px; 222 233 background: var(--bg3); ··· 224 235 padding: 4px 8px; 225 236 font-size: 10px; font-weight: 500; color: var(--text); 226 237 transition: all 0.15s; 227 - max-width: 100%; 228 - pointer-events: none; 229 238 } 239 + .chip:hover { border-color: var(--border-active); background: var(--bg4); } 230 240 .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 - 241 + .chip-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100px; } 233 242 .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; 243 + font-family: 'JetBrains Mono', monospace; 244 + font-size: 9px; color: var(--text-muted); 241 245 } 242 246 243 - /* ===== MODAL ===== */ 244 - .modal-overlay { 247 + /* SCATTER VIEW */ 248 + .bubble-view { 245 249 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 { 250 + position: relative; 251 + width: 100%; height: 600px; 257 252 background: var(--bg2); 258 - border: 1px solid var(--border-active); 259 - border-radius: 16px; 260 - width: 100%; max-width: 640px; max-height: 80vh; 253 + border: 1px solid var(--border); 254 + border-radius: 12px; 261 255 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 256 } 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; 257 + .bubble-node { 258 + position: absolute; 259 + border-radius: 50%; 289 260 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; 261 + cursor: pointer; 262 + transition: transform 0.2s, opacity 0.2s; 324 263 } 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; 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; 333 272 } 273 + .bubble-node:hover .bubble-label { opacity: 1; } 334 274 .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 - } 275 + .axis-x, .axis-y { position: absolute; background: var(--border); } 276 + .axis-x { height: 1px; } 277 + .axis-y { width: 1px; } 339 278 .axis-x-label { 340 - position: absolute; bottom: 12px; 341 - font-family: 'IBM Plex Mono', monospace; 279 + position: absolute; 280 + font-family: 'JetBrains Mono', monospace; 342 281 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); 282 + letter-spacing: 0.06em; text-transform: uppercase; 348 283 } 349 284 .axis-y-label { 350 - position: absolute; left: 12px; 351 - font-family: 'IBM Plex Mono', monospace; 285 + position: absolute; left: 14px; 286 + font-family: 'JetBrains Mono', monospace; 352 287 font-size: 9px; color: var(--text-muted); 353 - letter-spacing: 0.1em; writing-mode: vertical-lr; transform: rotate(180deg); white-space: nowrap; 288 + transform: rotate(-90deg); transform-origin: left center; 289 + letter-spacing: 0.06em; text-transform: uppercase; 290 + white-space: nowrap; 354 291 } 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; } 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); } 380 295 .void-zone { 381 296 position: absolute; 382 - border: 2px dashed var(--text-muted); border-radius: 12px; 383 - opacity: 0.2; z-index: 1; 297 + border: 1px dashed var(--gap-border); 298 + border-radius: 8px; 384 299 display: flex; align-items: center; justify-content: center; 385 300 } 386 301 .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; 302 + font-family: 'JetBrains Mono', monospace; 303 + font-size: 10px; color: var(--text-muted); opacity: 0.3; 304 + letter-spacing: 0.15em; 389 305 } 390 306 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; 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); 396 354 } 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); 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; 401 359 } 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); } 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); } 404 362 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; } 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; } 413 379 } 414 380 </style> 415 381 </head> 416 382 <body> 383 + 417 384 <div class="app"> 418 385 <div class="header"> 419 386 <div class="header-top"> 420 - <span class="logo">AT://</span> 387 + <span class="logo">ATProto</span> 421 388 <h1 class="title">Ecosystem Map</h1> 422 389 </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> 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> 424 395 </div> 425 396 426 397 <div class="controls"> 427 398 <div class="view-toggle"> 428 399 <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> 400 + <button class="view-btn" data-view="scatter" onclick="switchView('scatter')">Scatter</button> 430 401 </div> 431 - <div class="filter-row" id="filterRow"></div> 432 402 <div class="legend"> 433 403 <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> 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> 437 407 </div> 438 408 </div> 439 409 410 + <div class="stats-bar" id="statsBar"></div> 440 411 <div class="grid-view" id="gridView"></div> 441 412 <div class="bubble-view" id="bubbleView"></div> 442 - <div class="stats-bar" id="statsBar"></div> 443 413 </div> 444 414 445 415 <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> 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> 456 422 </div> 457 423 </div> 458 424 459 425 <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' }, 426 + // === AXES === 427 + // Y-axis: User action (what do people DO with these apps?) 428 + const 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)' }, 465 435 ]; 466 436 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' }, 437 + // X-axis: Scope — how broad is the user base? 438 + const 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' }, 472 443 ]; 473 444 445 + // === MATURITY (expressed as color) === 446 + const 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) === 474 454 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' }, 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' }, 485 487 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' }, 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' }, 498 495 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' }, 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' }, 512 500 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' }, 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' }, 532 507 ]; 533 508 534 509 let currentView = 'grid'; 535 - let activeFilter = 'all'; 536 510 const MAX_CHIPS = 4; 537 511 538 - function getRowColor(id) { return ROWS.find(r => r.id === id)?.color || '#999'; } 512 + function getMaturityColor(m) { return MATURITY[m]?.color || 'var(--text-muted)'; } 539 513 540 514 // ===== GRID ===== 541 515 function renderGrid() { 542 516 const grid = document.getElementById('gridView'); 517 + grid.style.setProperty('--cols', COLUMNS.length); 543 518 grid.innerHTML = ''; 544 519 545 520 const corner = document.createElement('div'); 546 521 corner.className = 'grid-corner'; 547 - corner.innerHTML = '<div class="grid-corner-inner">MATURITY<br>↑<br>→ USER DISTANCE</div>'; 522 + corner.innerHTML = '<div class="grid-corner-inner">Action<br>↓<br>→ Audience</div>'; 548 523 grid.appendChild(corner); 549 524 550 525 COLUMNS.forEach(col => { ··· 557 532 ROWS.forEach(row => { 558 533 const rh = document.createElement('div'); 559 534 rh.className = 'row-header'; 560 - rh.setAttribute('data-maturity', row.id); 561 535 rh.innerHTML = `<span class="row-label">${row.label}</span><span class="row-sub">${row.sub}</span>`; 562 536 grid.appendChild(rh); 563 537 ··· 566 540 cell.className = 'grid-cell'; 567 541 cell.onclick = () => openModal(col.id, row.id); 568 542 569 - const items = PRODUCTS.filter(p => 570 - p.col === col.id && p.row === row.id && 571 - (activeFilter === 'all' || p.category === activeFilter) 572 - ); 543 + const items = PRODUCTS.filter(p => p.col === col.id && p.row === row.id); 573 544 574 545 if (items.length === 0) { 575 546 cell.classList.add('empty-zone'); ··· 582 553 items.slice(0, MAX_CHIPS).forEach(item => { 583 554 const chip = document.createElement('div'); 584 555 chip.className = 'chip'; 585 - chip.innerHTML = `<span class="chip-dot" style="background:${row.color}"></span><span class="chip-name">${item.name}</span>`; 556 + chip.innerHTML = `<span class="chip-dot" style="background:${getMaturityColor(item.maturity)}"></span><span class="chip-name">${item.name}</span>`; 586 557 cell.appendChild(chip); 587 558 }); 588 559 ··· 602 573 function openModal(colId, rowId) { 603 574 const col = COLUMNS.find(c => c.id === colId); 604 575 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 - ); 576 + const items = PRODUCTS.filter(p => p.col === colId && p.row === rowId); 609 577 610 - document.getElementById('modalZone').textContent = `${col.label} × ${row.label}`; 611 - document.getElementById('modalZone').style.color = row.color; 578 + document.getElementById('modalZone').textContent = `${row.label} × ${col.label}`; 579 + document.getElementById('modalZone').style.color = 'var(--accent2)'; 612 580 document.getElementById('modalTitle').textContent = items.length > 0 613 - ? `${items.length} product${items.length !== 1 ? 's' : ''}` 581 + ? `${items.length} app${items.length !== 1 ? 's' : ''}` 614 582 : 'Empty Zone'; 615 583 document.getElementById('modalSubtitle').textContent = items.length > 0 616 - ? col.sub : 'No products in this space yet — an opportunity.'; 584 + ? `${row.sub} · ${col.sub}` : 'No apps in this space yet — an opportunity.'; 617 585 618 586 const body = document.getElementById('modalBody'); 619 587 if (items.length === 0) { 620 588 body.innerHTML = ` 621 589 <div class="modal-empty"> 622 590 <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> 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> 625 593 </div>`; 626 594 } else { 627 595 body.innerHTML = items.map((item, i) => ` 628 596 <div class="product-card" style="animation-delay:${i * 0.05}s"> 629 - <div class="product-dot" style="background:${row.color}"></div> 597 + <div class="product-dot" style="background:${getMaturityColor(item.maturity)}"></div> 630 598 <div class="product-info"> 631 599 <div class="product-name">${item.name}</div> 632 600 <div class="product-desc">${item.desc}</div> 633 601 <div class="product-tags"> 634 - <span class="product-tag">${item.category}</span> 602 + <span class="product-tag">${item.maturity}</span> 603 + <span class="product-tag">${row.label}</span> 635 604 <span class="product-tag">${col.label}</span> 636 - <span class="product-tag">${row.label}</span> 637 605 </div> 638 606 </div> 639 607 </div>`).join(''); ··· 650 618 function closeModalOutside(e) { if (e.target === document.getElementById('modalOverlay')) closeModal(); } 651 619 document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); 652 620 653 - // ===== BUBBLE ===== 654 - function renderBubble() { 621 + // ===== SCATTER ===== 622 + function renderScatter() { 655 623 const c = document.getElementById('bubbleView'); 656 624 c.innerHTML = ''; 657 625 const W = c.clientWidth, H = c.clientHeight; 658 - const PL = 130, PR = 50, PT = 50, PB = 55; 626 + const PL = 140, PR = 50, PT = 50, PB = 55; 659 627 const pW = W-PL-PR, pH = H-PT-PB; 660 628 661 629 const cx = {}; COLUMNS.forEach((col,i) => { cx[col.id] = PL + (i+0.5)*(pW/COLUMNS.length); }); ··· 666 634 <div class="axis-x" style="bottom:${PB}px;left:${PL}px;right:${PR}px"></div> 667 635 <div class="axis-y" style="top:${PT}px;bottom:${PB}px;left:${PL}px"></div> 668 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('')} 669 - <div class="axis-y-label" style="top:${PT+pH/2}px">← Established — Concept →</div> 637 + <div class="axis-y-label" style="top:${PT+pH/2}px">← Communicate — Manage →</div> 670 638 ${ROWS.map(r => `<div class="zone-line-x" style="top:${cy[r.id]+pH/ROWS.length/2}px"></div>`).join('')} 671 639 ${COLUMNS.map(col => `<div class="zone-line-y" style="left:${cx[col.id]+pW/COLUMNS.length/2}px"></div>`).join('')} 672 640 </div>`); ··· 683 651 } 684 652 }); }); 685 653 686 - const filtered = PRODUCTS.filter(p => activeFilter==='all' || p.category===activeFilter); 687 654 const placed = []; 688 - filtered.forEach(item => { 655 + PRODUCTS.forEach(item => { 689 656 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); 657 + const sz = item.maturity==='established'?40:item.maturity==='growing'?32:item.maturity==='emerging'?24:18; 658 + const color = getMaturityColor(item.maturity); 692 659 let x = bx+(Math.random()-0.5)*(pW/COLUMNS.length*0.55); 693 660 let y = by+(Math.random()-0.5)*(pH/ROWS.length*0.45); 694 661 for (let a=0;a<20;a++) { ··· 699 666 placed.push({x,y,s:sz}); 700 667 const n = document.createElement('div'); 701 668 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`; 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)'}`; 703 670 n.innerHTML = `<span class="bubble-label">${item.name}</span>`; 704 671 c.appendChild(n); 705 672 }); ··· 709 676 function renderStats() { 710 677 const bar = document.getElementById('statsBar'); 711 678 const total = PRODUCTS.length; 712 - const bc = {}; PRODUCTS.forEach(p => { bc[p.col]=(bc[p.col]||0)+1; }); 713 679 let empty = 0; 714 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; }); 715 683 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(); 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>`; 732 687 } 733 688 734 689 // ===== VIEW SWITCH ===== ··· 736 691 currentView = view; 737 692 document.querySelectorAll('.view-btn').forEach(b=>b.classList.toggle('active',b.dataset.view===view)); 738 693 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); 694 + document.getElementById('bubbleView').style.display = view==='scatter'?'block':'none'; 695 + if (view==='scatter') setTimeout(()=>renderScatter(),10); 741 696 } 742 697 743 698 // INIT 744 - renderGrid(); renderStats(); renderFilters(); 699 + renderGrid(); renderStats(); 745 700 </script> 746 701 </body> 747 702 </html>