Dense zones show competition. Empty zones show opportunity. A 2D map of the ATProto ecosystem.
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>