petri-dish.html
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Spacedust PDS Visualization</title>
7 <style>
8 :root {
9 --background-color: #111827;
10 --text-color: #e5e7eb;
11 --grid-color: #374151;
12 --panel-bg-color: #1f2937;
13 --border-color: #4b5563;
14 --accent-color-live: #22c55e;
15 --accent-color-reconnecting: #f97316;
16 --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
17 }
18
19 body {
20 font-family: var(--font-family);
21 background-color: var(--background-color);
22 color: var(--text-color);
23 margin: 0;
24 padding: 0;
25 display: flex;
26 flex-direction: column;
27 align-items: center;
28 justify-content: center;
29 min-height: 100vh;
30 }
31
32 .container {
33 display: flex;
34 flex-direction: column;
35 align-items: center;
36 width: 100%;
37 max-width: 1200px;
38 padding: 1rem;
39 box-sizing: border-box;
40 }
41
42 header {
43 width: 100%;
44 text-align: center;
45 margin-bottom: 1rem;
46 }
47
48 h1 {
49 font-size: 2rem;
50 font-weight: 600;
51 margin: 0;
52 color: #f9fafb;
53 }
54
55 p.subtitle {
56 font-size: 1rem;
57 color: #9ca3af;
58 margin: 0.25rem 0 0 0;
59 }
60
61 main {
62 position: relative;
63 width: 100%;
64 border: 1px solid var(--border-color);
65 border-radius: 0.75rem;
66 overflow: hidden;
67 box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
68 }
69
70 #canvas {
71 display: block;
72 width: 100%;
73 height: auto;
74 aspect-ratio: 4 / 3;
75 background-color: var(--background-color);
76 cursor: pointer;
77 }
78
79 .ui-overlay {
80 position: absolute;
81 top: 0;
82 left: 0;
83 right: 0;
84 bottom: 0;
85 padding: 0.75rem;
86 display: flex;
87 justify-content: space-between;
88 align-items: flex-start;
89 pointer-events: none;
90 }
91
92 .stats-panel, .controls-panel, .pds-legend-panel {
93 background-color: rgba(31, 41, 55, 0.8);
94 backdrop-filter: blur(8px);
95 padding: 0.75rem;
96 border-radius: 0.5rem;
97 border: 1px solid var(--border-color);
98 font-size: 0.875rem;
99 pointer-events: auto;
100 transition: opacity 0.3s ease-in-out;
101 }
102
103 .stats-panel div, .pds-legend-panel div {
104 margin-bottom: 0.25rem;
105 }
106
107 .stats-panel strong {
108 color: #9ca3af;
109 width: 80px;
110 display: inline-block;
111 }
112
113 #connection-indicator {
114 font-weight: 600;
115 }
116
117 .controls-panel {
118 position: absolute;
119 top: 0.75rem;
120 left: 50%;
121 transform: translateX(-50%);
122 display: flex;
123 gap: 1rem;
124 align-items: center;
125 }
126
127 .controls-panel button {
128 background-color: #374151;
129 color: var(--text-color);
130 border: 1px solid var(--border-color);
131 padding: 0.5rem 1rem;
132 border-radius: 0.375rem;
133 cursor: pointer;
134 font-family: var(--font-family);
135 font-size: 0.875rem;
136 transition: background-color 0.2s;
137 }
138
139 .controls-panel button:hover {
140 background-color: #4b5563;
141 }
142
143 .filter-toggle {
144 display: flex;
145 align-items: center;
146 gap: 0.5rem;
147 cursor: pointer;
148 }
149
150 .pds-legend-panel {
151 position: absolute;
152 top: 4.5rem;
153 right: 0.75rem;
154 }
155
156 .pds-legend-panel h4 {
157 margin: 0 0 0.5rem 0;
158 font-weight: 600;
159 color: #9ca3af;
160 border-bottom: 1px solid var(--border-color);
161 padding-bottom: 0.25rem;
162 font-size: 0.875rem;
163 }
164
165 .legend-item {
166 display: flex;
167 align-items: center;
168 font-size: 0.8rem;
169 }
170
171 .legend-color {
172 width: 12px;
173 height: 12px;
174 border-radius: 50%;
175 margin-right: 0.5rem;
176 flex-shrink: 0;
177 border: 1px solid rgba(255, 255, 255, 0.2);
178 }
179
180 #event-types {
181 margin-top: 0.5rem;
182 padding-top: 0.5rem;
183 border-top: 1px solid var(--border-color);
184 display: grid;
185 grid-template-columns: repeat(2, 1fr);
186 gap: 0.25rem;
187 font-size: 0.8rem;
188 }
189 #event-types span {
190 text-transform: capitalize;
191 }
192
193 #tooltip {
194 position: fixed;
195 display: none;
196 background-color: rgba(17, 24, 39, 0.9);
197 color: white;
198 padding: 0.5rem 0.75rem;
199 border-radius: 0.375rem;
200 font-size: 0.875rem;
201 pointer-events: none;
202 border: 1px solid var(--border-color);
203 box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
204 white-space: nowrap;
205 z-index: 100;
206 }
207
208 .ui-toggle-container {
209 position: absolute;
210 top: 0.75rem;
211 right: 0.75rem;
212 pointer-events: auto;
213 }
214
215 .ui-toggle-container button {
216 background-color: #374151;
217 color: var(--text-color);
218 border: 1px solid var(--border-color);
219 width: 36px;
220 height: 36px;
221 border-radius: 50%;
222 cursor: pointer;
223 font-family: var(--font-family);
224 font-size: 0.875rem;
225 transition: background-color 0.2s, opacity 0.3s ease-in-out;
226 display: flex;
227 align-items: center;
228 justify-content: center;
229 }
230 .ui-toggle-container button:hover {
231 background-color: #4b5563;
232 }
233
234 body.ui-hidden .stats-panel,
235 body.ui-hidden .controls-panel,
236 body.ui-hidden .pds-legend-panel {
237 opacity: 0;
238 pointer-events: none;
239 }
240
241 @media (max-width: 768px) {
242 h1 { font-size: 1.5rem; }
243 .stats-panel, .controls-panel, .pds-legend-panel {
244 font-size: 0.75rem;
245 padding: 0.5rem;
246 }
247 .stats-panel strong { width: 60px; }
248 .controls-panel button { padding: 0.4rem 0.8rem; }
249 .pds-legend-panel { display: none; } /* Hide legend on small screens */
250 }
251 </style>
252</head>
253<body>
254
255 <div class="container">
256 <header>
257 <h1>Spacedust PDS Visualization</h1>
258 <p class="subtitle">Real-time AT Protocol network activity, visualized.</p>
259 </header>
260
261 <main>
262 <canvas id="canvas"></canvas>
263 <div id="tooltip"></div>
264 <div class="ui-overlay">
265 <div id="stats" class="stats-panel">
266 <div><strong>Blobs:</strong> 0</div>
267 <div><strong>Events:</strong> 0</div>
268 <div><strong>FPS:</strong> 0.0</div>
269 <div><strong>Frame:</strong> 0.00ms</div>
270 <div id="connection-indicator" style="color: var(--accent-color-reconnecting);">◌ Connecting...</div>
271 <div id="event-types"></div>
272 </div>
273 <div class="controls-panel">
274 <button id="pause-resume">Pause</button>
275 <label class="filter-toggle">
276 <input type="checkbox" id="filter-bluesky">
277 Filter Bluesky PDS
278 </label>
279 <label class="filter-toggle">
280 <input type="checkbox" id="toggle-labels">
281 Show Labels
282 </label>
283 </div>
284 <div id="pds-legend" class="pds-legend-panel">
285 <h4>Top PDS</h4>
286 <div id="pds-legend-list"></div>
287 </div>
288 <div class="ui-toggle-container">
289 <button id="toggle-ui" title="Toggle UI (h)">👁️</button>
290 </div>
291 </div>
292 </main>
293 </div>
294
295 <script type="module">
296 /**
297 * An enumeration of all known event types, including official bsky lexicons
298 * and community-driven custom lexicons, used for categorization and applying
299 * physics modifiers.
300 */
301 const EventType = {
302 Post: "post", Like: "like", Repost: "repost", Follow: "follow", Block: "block",
303 Profile: "profile", List: "list", Threadgate: "threadgate", Other: "other",
304 Statusphere: "statusphere", Tangled: "tangled", Pinksea: "pinksea",
305 Anisota: "anisota", Place: "place", Leaflet: "leaflet", Blog: "blog",
306 Unravel: "unravel", Smokesignal: "smokesignal", Skylights: "skylights", Recipe: "recipe"
307 };
308
309 /**
310 * Manages application configuration, providing default values for the simulation.
311 * These can be overridden by a global `window.spacedust_config` object for tuning.
312 */
313 const DEFAULT_CONFIG = {
314 firehoseUrl: 'wss://spacedust.snek.cc/subscribe',
315 canvasWidth: 800, canvasHeight: 600, friction: 0.95, boundaryBounceDamping: 0.8,
316 minBlobMass: 5, maxBlobMass: 500, maxActiveBlobs: 300, baseGrowthMultiplier: 1.5,
317 decayFactor: 0.995, inactivityThresholdMs: 15000, statsUpdateIntervalMs: 200,
318 logLevel: 'info', enableCollisionDetection: true, enableBlobSplitting: true,
319 enableDecay: true, mushroomThreshold: 250, emojiThreshold: 150,
320 };
321 function loadConfig() {
322 const userConfig = (typeof window !== 'undefined' && window.spacedust_config) || {};
323 const mergedConfig = { ...DEFAULT_CONFIG, ...userConfig };
324 console.log('[CONFIG] Spacedust Configuration Loaded:', mergedConfig);
325 const canvas = document.getElementById('canvas');
326 if(canvas) {
327 const container = canvas.parentElement;
328 if(container) {
329 const aspectRatio = mergedConfig.canvasWidth / mergedConfig.canvasHeight;
330 canvas.style.aspectRatio = `${aspectRatio}`;
331 }
332 }
333 return mergedConfig;
334 }
335 const CONFIG = loadConfig();
336
337 /**
338 * A simple structured logger for categorizing console output.
339 */
340 const LogLevel = { DEBUG: "DEBUG", INFO: "INFO", WARN: "WARN", ERROR: "ERROR" };
341 class Logger {
342 minLevel = LogLevel.INFO;
343 constructor(minLevel = LogLevel.INFO) { this.minLevel = minLevel; }
344 setMinLevel(level) { this.minLevel = level; }
345 log(level, context, message, data) {
346 const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
347 if (levels.indexOf(level) < levels.indexOf(this.minLevel)) return;
348 const prefix = `[${level}] ${new Date().toLocaleTimeString()} [${context}]`;
349 if (data) console.log(prefix, message, data);
350 else console.log(prefix, message);
351 }
352 debug(context, message, data) { this.log(LogLevel.DEBUG, context, message, data); }
353 info(context, message, data) { this.log(LogLevel.INFO, context, message, data); }
354 warn(context, message, data) { this.log(LogLevel.WARN, context, message, data); }
355 error(context, message, data) { this.log(LogLevel.ERROR, context, message, data); }
356 }
357 /**
358 * Tracks performance metrics, such as frame time, over a sliding window.
359 */
360 class PerformanceMonitor {
361 metrics = new Map();
362 maxSamples = 60;
363 record(label, value) {
364 if (!this.metrics.has(label)) this.metrics.set(label, []);
365 const samples = this.metrics.get(label);
366 samples.push(value);
367 if (samples.length > this.maxSamples) samples.shift();
368 }
369 getStats(label) {
370 const samples = this.metrics.get(label);
371 if (!samples || samples.length === 0) return null;
372 const sorted = [...samples].sort((a, b) => a - b);
373 const avg = sorted.reduce((a, b) => a + b, 0) / sorted.length;
374 return { avg, p95: sorted[Math.floor(sorted.length * 0.95)] };
375 }
376 }
377 const logger = new Logger();
378 logger.setMinLevel(CONFIG.logLevel);
379 const perfMonitor = new PerformanceMonitor();
380
381 /**
382 * Implements a "dirty flag" and throttling mechanism to prevent redundant
383 * UI updates, which are expensive DOM operations.
384 */
385 class UIStateManager {
386 flags = { statsNeedUpdate: true, pdsLegendNeedUpdate: true };
387 lastStatsUpdateTime = 0;
388 lastLegendUpdateTime = 0;
389 minStatsUpdateInterval = CONFIG.statsUpdateIntervalMs;
390 minLegendUpdateInterval = 1000;
391 invalidateStats() { this.flags.statsNeedUpdate = true; }
392 invalidatePdsLegend() { this.flags.pdsLegendNeedUpdate = true; }
393 shouldUpdateStats(currentTime) {
394 if (!this.flags.statsNeedUpdate) return false;
395 if (currentTime - this.lastStatsUpdateTime < this.minStatsUpdateInterval) return false;
396 this.lastStatsUpdateTime = currentTime;
397 this.flags.statsNeedUpdate = false;
398 return true;
399 }
400 shouldUpdatePdsLegend(currentTime) {
401 if (!this.flags.pdsLegendNeedUpdate) return false;
402 if (currentTime - this.lastLegendUpdateTime < this.minLegendUpdateInterval) return false;
403 this.lastLegendUpdateTime = currentTime;
404 this.flags.pdsLegendNeedUpdate = false;
405 return true;
406 }
407 }
408 class OptimizedUIUpdater {
409 constructor(uiManager) { this.uiManager = uiManager; }
410 tryUpdateStats(currentTime, updateFn) {
411 if (this.uiManager.shouldUpdateStats(currentTime)) updateFn();
412 }
413 tryUpdatePdsLegend(currentTime, updateFn) {
414 if (this.uiManager.shouldUpdatePdsLegend(currentTime)) updateFn();
415 }
416 }
417
418 /**
419 * The single source of truth for all simulation objects (blobs).
420 * Uses Map-based indices for efficient O(1) lookups by ID and hostname.
421 */
422 class BlobStore {
423 blobs = new Map();
424 hostnameIndex = new Map();
425 addBlob(blob) {
426 if (!blob || !blob.id || this.blobs.has(blob.id)) return;
427 this.blobs.set(blob.id, blob);
428 if (blob.pdsHostname) {
429 if (!this.hostnameIndex.has(blob.pdsHostname)) this.hostnameIndex.set(blob.pdsHostname, new Set());
430 this.hostnameIndex.get(blob.pdsHostname).add(blob.id);
431 }
432 }
433 getBlobById(blobId) {
434 const blob = this.blobs.get(blobId);
435 return blob ? { ...blob } : null;
436 }
437 getBlobsByHostname(hostname) {
438 const ids = this.hostnameIndex.get(hostname);
439 return ids ? Array.from(ids).map(id => this.blobs.get(id)).filter(Boolean) : [];
440 }
441 updateBlob(blobId, updates) {
442 const blob = this.blobs.get(blobId);
443 if (!blob) return null;
444 const updatedBlob = { ...blob, ...updates };
445 this.blobs.set(blobId, updatedBlob);
446 return { ...updatedBlob };
447 }
448 removeBlob(blobId) {
449 const blob = this.blobs.get(blobId);
450 if (!blob) return false;
451 if (blob.pdsHostname) this.hostnameIndex.get(blob.pdsHostname)?.delete(blobId);
452 this.blobs.delete(blobId);
453 return true;
454 }
455 getAllBlobs() { return Array.from(this.blobs.values()); }
456 cleanup(minMass, maxAgeSecs) {
457 const now = Date.now();
458 const toRemove = [];
459 for (const [id, blob] of this.blobs) {
460 const age = (now - blob.lastActive) / 1000;
461 if (blob.mass < minMass || age > maxAgeSecs) toRemove.push(id);
462 }
463 toRemove.forEach(id => this.removeBlob(id));
464 return toRemove.length;
465 }
466 size() { return this.blobs.size; }
467 }
468
469 /**
470 * Contains functions related to the lifecycle of blobs, such as decay and splitting.
471 */
472 function applyDecay(blobStore) {
473 const now = Date.now();
474 for (const blob of blobStore.getAllBlobs()) {
475 if (blob.isFiltered) continue;
476 if (now - blob.lastActive > CONFIG.inactivityThresholdMs) {
477 const newMass = blob.mass * CONFIG.decayFactor;
478 blobStore.updateBlob(blob.id, {
479 mass: Math.max(newMass, CONFIG.minBlobMass),
480 size: Math.sqrt(Math.max(newMass, CONFIG.minBlobMass))
481 });
482 }
483 }
484 }
485 function cleanupDeadBlobs(blobStore) {
486 const removedCount = blobStore.cleanup(CONFIG.minBlobMass, 300);
487 if (removedCount > 0) logger.debug('Lifecycle', `Cleaned up ${removedCount} dead blobs.`);
488 }
489 function findSafestSplitDirection(blob, blobStore) {
490 const checkAngles = 8;
491 let bestAngle = Math.random() * Math.PI * 2;
492 let maxClosestDist = 0;
493
494 const activeBlobs = blobStore.getAllBlobs().filter(b => !b.isFiltered);
495
496 for (let i = 0; i < checkAngles; i++) {
497 const angle = (i / checkAngles) * Math.PI * 2;
498 const checkX = blob.x + Math.cos(angle) * blob.size * 3;
499 const checkY = blob.y + Math.sin(angle) * blob.size * 3;
500
501 let closestDistSq = Infinity;
502 for (const other of activeBlobs) {
503 if (blob.id === other.id) continue;
504 const distSq = (checkX - other.x)**2 + (checkY - other.y)**2;
505 if (distSq < closestDistSq) {
506 closestDistSq = distSq;
507 }
508 }
509 if (closestDistSq > maxClosestDist) {
510 maxClosestDist = closestDistSq;
511 bestAngle = angle;
512 }
513 }
514 return bestAngle;
515 }
516 function splitBlobIfNeeded(blobStore, blobId) {
517 const blob = blobStore.getBlobById(blobId);
518 if (!blob || blob.isFiltered || blob.mass < CONFIG.maxBlobMass) return;
519
520 const childMass = blob.mass / 2;
521 blobStore.updateBlob(blobId, { mass: childMass, size: Math.sqrt(childMass) });
522
523 const angle = findSafestSplitDirection(blob, blobStore);
524 const impulse = 5;
525
526 const childBlob = { ...blob, id: `${blob.id}-child-${Date.now()}`, mass: childMass, size: Math.sqrt(childMass), isChild: true, parentId: blobId, vx: blob.vx + Math.cos(angle) * impulse, vy: blob.vy + Math.sin(angle) * impulse };
527 blobStore.addBlob(childBlob);
528 logger.info('Lifecycle', `Blob ${blobId} split into child ${childBlob.id}`);
529 }
530
531 /**
532 * Implements an O(n log n) collision detection system using a QuadTree.
533 */
534 class Rectangle {
535 constructor(x, y, w, h) { this.x = x; this.y = y; this.w = w; this.h = h; }
536 contains(blob) { return blob.x >= this.x && blob.x < this.x + this.w && blob.y >= this.y && blob.y < this.y + this.h; }
537 intersects(other) { return !(other.x > this.x + this.w || other.x + other.w < this.x || other.y > this.y + this.h || other.y + other.h < this.y); }
538 }
539 class QuadTree {
540 blobs = [];
541 divided = false;
542 constructor(boundary, capacity = 8) { this.boundary = boundary; this.capacity = capacity; }
543 insert(blob) {
544 if (!this.boundary.contains(blob)) return false;
545 if (this.blobs.length < this.capacity) { this.blobs.push(blob); return true; }
546 if (!this.divided) this.subdivide();
547 return this.northeast.insert(blob) || this.northwest.insert(blob) || this.southeast.insert(blob) || this.southwest.insert(blob);
548 }
549 query(range, found = []) {
550 if (!this.boundary.intersects(range)) return found;
551 for (const blob of this.blobs) {
552 if (range.contains(blob)) found.push(blob);
553 }
554 if (this.divided) {
555 this.northeast.query(range, found); this.northwest.query(range, found);
556 this.southeast.query(range, found); this.southwest.query(range, found);
557 }
558 return found;
559 }
560 subdivide() {
561 const { x, y, w, h } = this.boundary;
562 const hw = w / 2, hh = h / 2;
563 this.northeast = new QuadTree(new Rectangle(x + hw, y, hw, hh), this.capacity);
564 this.northwest = new QuadTree(new Rectangle(x, y, hw, hh), this.capacity);
565 this.southeast = new QuadTree(new Rectangle(x + hw, y + hh, hw, hh), this.capacity);
566 this.southwest = new QuadTree(new Rectangle(x, y + hh, hw, hh), this.capacity);
567 this.divided = true;
568 }
569 }
570 function findCollisions(blobStore, canvasWidth, canvasHeight) {
571 const activeBlobs = blobStore.getAllBlobs().filter(b => !b.isFiltered);
572 const qtree = new QuadTree(new Rectangle(0, 0, canvasWidth, canvasHeight));
573 for (const blob of activeBlobs) qtree.insert(blob);
574 const collisions = [];
575 const checked = new Set();
576 for (const blob of activeBlobs) {
577 const range = new Rectangle(blob.x - blob.size, blob.y - blob.size, blob.size * 2, blob.size * 2);
578 const nearby = qtree.query(range);
579 for (const other of nearby) {
580 if (blob.id === other.id) continue;
581 const pairKey = [blob.id, other.id].sort().join('-');
582 if (checked.has(pairKey)) continue;
583 checked.add(pairKey);
584 const dx = blob.x - other.x, dy = blob.y - other.y;
585 if (dx * dx + dy * dy < (blob.size + other.size) ** 2) {
586 collisions.push([blob, other]);
587 }
588 }
589 }
590 return collisions;
591 }
592 function handleCollision(blobStore, blob1, blob2) {
593 const winner = blob1.mass > blob2.mass ? blob1 : blob2;
594 const loser = blob1.mass > blob2.mass ? blob2 : blob1;
595
596 if (blob1.pdsHostname === blob2.pdsHostname) {
597 const massToTransfer = loser.mass * 0.25;
598 blobStore.updateBlob(winner.id, {
599 mass: winner.mass + massToTransfer,
600 size: Math.sqrt(winner.mass + massToTransfer),
601 pulseStartTime: Date.now()
602 });
603 const remainingMass = loser.mass - massToTransfer;
604 if (remainingMass < CONFIG.minBlobMass) {
605 blobStore.removeBlob(loser.id);
606 } else {
607 blobStore.updateBlob(loser.id, {
608 mass: remainingMass,
609 size: Math.sqrt(remainingMass)
610 });
611 }
612 return;
613 }
614
615 blobStore.updateBlob(winner.id, {
616 mass: winner.mass + loser.mass,
617 size: Math.sqrt(winner.mass + loser.mass),
618 pulseStartTime: Date.now()
619 });
620 blobStore.removeBlob(loser.id);
621 }
622
623 /**
624 * Handles all rendering to the HTML canvas and updating UI panel information.
625 */
626 function renderFrame(ctx, canvas, blobStore, showLabels) {
627 canvas.width = canvas.clientWidth;
628 canvas.height = canvas.clientHeight;
629 ctx.fillStyle = '#111827';
630 ctx.fillRect(0, 0, canvas.width, canvas.height);
631 drawGrid(ctx, canvas.width, canvas.height);
632 const allBlobs = blobStore.getAllBlobs();
633 allBlobs.sort((a, b) => a.mass - b.mass);
634 for (const blob of allBlobs) drawBlob(ctx, blob, showLabels);
635 }
636 function drawGrid(ctx, width, height) {
637 ctx.strokeStyle = '#374151';
638 ctx.lineWidth = 0.5;
639 ctx.beginPath();
640 for (let x = 0; x <= width; x += 40) { ctx.moveTo(x, 0); ctx.lineTo(x, height); }
641 for (let y = 0; y <= height; y += 40) { ctx.moveTo(0, y); ctx.lineTo(width, y); }
642 ctx.stroke();
643 }
644 function drawBlob(ctx, blob, showLabels) {
645 if (blob.size < 1) return;
646
647 let currentSize = blob.size;
648 const now = Date.now();
649
650 ctx.save();
651
652 if (blob.isFiltered) {
653 ctx.globalAlpha = 0.2;
654 }
655
656 if (blob.shakeUntil && now < blob.shakeUntil) {
657 const shakeIntensity = 4;
658 ctx.translate(Math.random() * shakeIntensity - shakeIntensity / 2, Math.random() * shakeIntensity - shakeIntensity / 2);
659 }
660
661 if (blob.pulseStartTime) {
662 const pulseDuration = 500;
663 const pulseAge = now - blob.pulseStartTime;
664 if (pulseAge < pulseDuration) {
665 const pulseProgress = pulseAge / pulseDuration;
666 const pulseFactor = 1 + Math.sin(pulseProgress * Math.PI) * 0.3;
667 currentSize *= pulseFactor;
668 }
669 }
670
671 const lighterColor = lightenHslColor(blob.color, 20);
672 const gradient = ctx.createRadialGradient(blob.x, blob.y, 0, blob.x, blob.y, currentSize);
673 gradient.addColorStop(0, lighterColor);
674 gradient.addColorStop(1, blob.color);
675
676 ctx.fillStyle = gradient;
677 ctx.beginPath();
678 ctx.arc(blob.x, blob.y, currentSize, 0, Math.PI * 2);
679 ctx.fill();
680
681 if (showLabels && !blob.isFiltered) {
682 const fontSize = Math.max(8, Math.min(14, blob.size / 4));
683 ctx.font = `bold ${fontSize}px sans-serif`;
684 ctx.textAlign = 'center';
685 ctx.textBaseline = 'middle';
686 ctx.fillStyle = 'white';
687 ctx.strokeStyle = 'black';
688 ctx.lineWidth = 2;
689 ctx.strokeText(blob.pdsHostname, blob.x, blob.y);
690 ctx.fillText(blob.pdsHostname, blob.x, blob.y);
691 }
692
693 if (blob.isBlueskyPDS && blob.mass > CONFIG.mushroomThreshold) {
694 ctx.font = `${blob.size * 1.2}px sans-serif`;
695 ctx.textAlign = 'center';
696 ctx.textBaseline = 'middle';
697 ctx.fillText('🍄', blob.x, blob.y);
698 } else if (blob.emoji && blob.mass > CONFIG.emojiThreshold) {
699 ctx.font = `${blob.size * 1.2}px sans-serif`;
700 ctx.textAlign = 'center';
701 ctx.textBaseline = 'middle';
702 ctx.fillText(blob.emoji, blob.x, blob.y);
703 }
704
705 if (blob.shimmerUntil && now < blob.shimmerUntil) {
706 ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
707 ctx.fill();
708 }
709
710 if (blob.glow && !blob.isFiltered) {
711 ctx.strokeStyle = lighterColor;
712 ctx.lineWidth = 3;
713 ctx.globalAlpha = ctx.globalAlpha * 0.7;
714 ctx.stroke();
715 }
716 ctx.restore();
717 }
718 function lightenHslColor(hsl, percent) {
719 const parts = hsl.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
720 if (!parts) return hsl;
721 const l = Math.min(100, parseInt(parts[3]) + percent);
722 return `hsl(${parts[1]}, ${parts[2]}%, ${l}%)`;
723 }
724 function updateStatsUI(stats, eventCounts) {
725 const el = document.getElementById('stats');
726 if (el) {
727 el.innerHTML = `
728 <div><strong>Blobs:</strong> ${stats.blobCount.toLocaleString()}</div>
729 <div><strong>Events:</strong> ${stats.eventCount.toLocaleString()}</div>
730 <div><strong>FPS:</strong> ${stats.fps.toFixed(1)}</div>
731 <div><strong>Frame:</strong> ${stats.frameTime.toFixed(2)}ms</div>
732 <div id="connection-indicator"></div>
733 <div id="event-types"></div>`;
734 updateEventCountersUI(eventCounts);
735 }
736 }
737 function updateEventCountersUI(eventCounts) {
738 const el = document.getElementById('event-types');
739 if (el) {
740 el.innerHTML = Object.entries(eventCounts)
741 .sort(([, a], [, b]) => b - a)
742 .map(([type, count]) => `<div><span>${type}:</span> <strong>${count.toLocaleString()}</strong></div>`)
743 .join('');
744 }
745 }
746 function updatePdsLegendUI(blobStore) {
747 const el = document.getElementById('pds-legend-list');
748 if (!el) return;
749
750 const pdsMasses = new Map();
751 const activeBlobs = blobStore.getAllBlobs().filter(blob => !blob.isFiltered);
752
753 for (const blob of activeBlobs) {
754 if (blob.pdsHostname) {
755 const current = pdsMasses.get(blob.pdsHostname) || { mass: 0, color: blob.color };
756 current.mass += blob.mass;
757 pdsMasses.set(blob.pdsHostname, current);
758 }
759 }
760
761 const topPDS = Array.from(pdsMasses.entries())
762 .sort(([, a], [, b]) => b.mass - a.mass)
763 .slice(0, 5);
764
765 el.innerHTML = topPDS.map(([hostname, { color }]) => `
766 <div class="legend-item">
767 <div class="legend-color" style="background-color: ${color};"></div>
768 <span>${hostname}</span>
769 </div>
770 `).join('');
771 }
772 function updateConnectionIndicator(isConnected) {
773 const el = document.getElementById('connection-indicator');
774 if (el) {
775 el.innerHTML = isConnected ? `<strong style="color: var(--accent-color-live);">● Live</strong>` : `<strong style="color: var(--accent-color-reconnecting);">◌ Reconnecting...</strong>`;
776 }
777 }
778
779 /**
780 * Networking/api code
781 * Handles network requests for the application.
782 */
783 const didToPdsCache = new Map();
784
785 async function resolveDID(did) {
786 if (didToPdsCache.has(did)) {
787 return didToPdsCache.get(did);
788 }
789 if (!did || !did.startsWith('did:')) {
790 didToPdsCache.set(did, null);
791 return null;
792 }
793
794 try {
795 const res = await fetch(`https://plc.directory/${did}`);
796 if (!res.ok) {
797 logger.warn('DID Resolver', `Failed to resolve ${did}, status: ${res.status}`);
798 didToPdsCache.set(did, null);
799 return null;
800 }
801 const data = await res.json();
802 const service = data.service?.find(s => s.id === '#atproto_pds');
803 if (service?.serviceEndpoint) {
804 const hostname = new URL(service.serviceEndpoint).hostname;
805 didToPdsCache.set(did, hostname);
806 return hostname;
807 }
808 didToPdsCache.set(did, null);
809 return null;
810 } catch (e) {
811 logger.error('DID Resolver', `Error resolving ${did}`, e);
812 didToPdsCache.set(did, null);
813 return null;
814 }
815 }
816 async function* subscribeToSpacedust() {
817 let reconnectAttempts = 0;
818 while (true) {
819 let ws;
820 try {
821 logger.info('Spacedust', 'Connecting...');
822 ws = new WebSocket(CONFIG.firehoseUrl);
823 const messageQueue = [];
824 let resolvePromise = null;
825
826 ws.onopen = () => { logger.info('Spacedust', 'Connection established.'); updateConnectionIndicator(true); reconnectAttempts = 0; };
827 ws.onmessage = (event) => {
828 try {
829 const msg = JSON.parse(event.data);
830 if (msg.kind === 'link' && msg.link) {
831 if (resolvePromise) { resolvePromise(msg.link); resolvePromise = null; }
832 else { messageQueue.push(msg.link); }
833 }
834 } catch (e) { logger.warn('Spacedust', 'Failed to parse message', e); }
835 };
836 ws.onerror = (err) => { logger.error('Spacedust', 'WebSocket error', err); };
837 ws.onclose = () => { logger.warn('Spacedust', 'Connection closed.'); updateConnectionIndicator(false); };
838
839 while (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
840 if (messageQueue.length > 0) { yield messageQueue.shift(); }
841 else { yield new Promise(resolve => { resolvePromise = resolve; }); }
842 await new Promise(res => setTimeout(res, 10)); // Prevent tight loop
843 }
844
845 } catch (e) { logger.error('Spacedust', 'Subscription error', e);
846 } finally {
847 if (ws && ws.readyState !== WebSocket.CLOSED) ws.close();
848 updateConnectionIndicator(false);
849 const delay = Math.min(30000, Math.pow(2, reconnectAttempts) * 1000);
850 logger.warn('Spacedust', `Reconnecting in ${delay / 1000}s`);
851 await new Promise(res => setTimeout(res, delay));
852 reconnectAttempts++;
853 }
854 }
855 }
856
857 /**
858 * Spacedust event processing
859 * Contains the core logic for processing a single event from the firehose.
860 */
861 const pdsEmojiMap = [
862 { keywords: ['atproto.brid.gy'], emoji: '🌉' }, { keywords: ['ripperoni'], emoji: '🍕' }, { keywords: ['lab.martianbase.net'], emoji: '🇵🇱' }, { keywords: ['keik.info'], emoji: '🇯🇵' }, { keywords: ['tngl.sh'], emoji: '🧶' }, { keywords: ['blacksky.app', 'witchcraft.systems', 'altq.net', 'selfhosted.social', 'peedee.es'], emoji: '☀️' }, { keywords: ['wafrn.net'], emoji: '👽' }, { keywords: ['sprk.so'], emoji: '📱' }, { keywords: ['esnoticia.online', 'marta.fail', 'climateai.org', 'dev-frosting.social', 'akizuki.best', 'shendrick.net'], emoji: '🤖' }, { keywords: ['gay', 'lgbt', 'queer', 'tgirl', 'trans', 'barbequeer'], emoji: '🏳️⚧️' }, { keywords: ['cat', 'meow', 'mraow', 'kat'], emoji: '🐈' }, { keywords: ['dog', 'doggos', 'puppy', 'pup'], emoji: '🐕' }, { keywords: ['fox', 'vixen', 'vulpine'], emoji: '🦊' }, { keywords: ['witch'], emoji: '🧙' }, { keywords: ['climate'], emoji: '🌍' }, { keywords: ['computer', 'dev', 'tech', 'systems', 'codes', 'cloud', 'host'], emoji: '💻' }, { keywords: ['art', 'design', 'photo'], emoji: '🎨' }, { keywords: ['social'], emoji: '👥' }, { keywords: ['blue', 'sky'], emoji: '🌌' }, { keywords: ['cafe', 'coffee'], emoji: '☕' }, { keywords: ['garden'], emoji: '🌱' }, { keywords: ['party'], emoji: '🎉' }, { keywords: ['lol', 'wtf', 'fail'], emoji: '😂' }, { keywords: ['fish'], emoji: '🐠' }, { keywords: ['mom'], emoji: '👩👧' }, { keywords: ['geese', 'bird'], emoji: '🦢' }, { keywords: ['dead'], emoji: '💀' }, { keywords: ['evil'], emoji: '😈' }, { keywords: ['moe'], emoji: '🌸' },
863 ];
864
865 function getEmojiForPDS(hostname) {
866 if (!hostname) return null;
867 for (const rule of pdsEmojiMap) {
868 if (rule.keywords.some(kw => hostname.includes(kw))) {
869 return rule.emoji;
870 }
871 }
872 return null;
873 }
874
875 function isBlueskyPDS(hostname) {
876 if (typeof hostname !== 'string' || hostname.length === 0) {
877 return false;
878 }
879 const lowerHostname = hostname.toLowerCase().trim();
880 return lowerHostname === 'bsky.social' || lowerHostname.endsWith('.bsky.network');
881 }
882
883 function createPDSBlob(pdsHostname, canvas, isFiltered) {
884 const hash = pdsHostname.split('').reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0);
885 const color = `hsl(${hash % 360}, 70%, 50%)`;
886 return {
887 id: `${pdsHostname}-${crypto.randomUUID()}`,
888 pdsHostname: pdsHostname,
889 dids: [],
890 x: Math.random() * canvas.clientWidth,
891 y: Math.random() * canvas.clientHeight,
892 vx: 0,
893 vy: 0,
894 mass: CONFIG.minBlobMass,
895 size: Math.sqrt(CONFIG.minBlobMass),
896 color: color,
897 lastActive: Date.now(),
898 glow: false,
899 isBlueskyPDS: isBlueskyPDS(pdsHostname),
900 isFiltered: isFiltered,
901 emoji: getEmojiForPDS(pdsHostname),
902 };
903 }
904
905 function getEventTypeFromSource(sourceString) {
906 if (!sourceString) return EventType.Other;
907 if (sourceString.includes('bsky.feed.like')) return EventType.Like;
908 if (sourceString.includes('bsky.feed.post')) return EventType.Post;
909 if (sourceString.includes('bsky.feed.repost')) return EventType.Repost;
910 if (sourceString.includes('bsky.graph.follow')) return EventType.Follow;
911 if (sourceString.includes('bsky.graph.block')) return EventType.Block;
912 if (sourceString.includes('bsky.actor.profile')) return EventType.Profile;
913 if (sourceString.includes('bsky.graph.list')) return EventType.List;
914 if (sourceString.includes('bsky.feed.threadgate')) return EventType.Threadgate;
915 if (sourceString.includes('xyz.statusphere.status')) return EventType.Statusphere;
916 if (sourceString.includes('sh.tangled.')) return EventType.Tangled;
917 if (sourceString.includes('art.pinksea.art')) return EventType.Pinksea;
918 if (sourceString.includes('net.anisota.thread')) return EventType.Anisota;
919 if (sourceString.includes('place.stream.broadcast')) return EventType.Place;
920 if (sourceString.includes('pub.leaflet.publication')) return EventType.Leaflet;
921 if (sourceString.includes('com.whtwnd.blog.entry')) return EventType.Blog;
922 if (sourceString.includes('fyi.unravel.frontpage.story')) return EventType.Unravel;
923 if (sourceString.includes('events.smokesignal.signal')) return EventType.Smokesignal;
924 if (sourceString.includes('my.skylights.review')) return EventType.Skylights;
925 if (sourceString.includes('exchange.recipe.recipe')) return EventType.Recipe;
926 return EventType.Other;
927 }
928
929 function calculateGrowth(eventType) {
930 const factors = {
931 [EventType.Post]: 2.0, [EventType.Follow]: 2.5, [EventType.Like]: 1.0, [EventType.Repost]: 1.5, [EventType.Block]: 3.0, [EventType.Profile]: 1.0, [EventType.List]: 3.5, [EventType.Threadgate]: 4.0, [EventType.Statusphere]: 2.2, [EventType.Tangled]: 2.8, [EventType.Pinksea]: 3.0, [EventType.Anisota]: 2.7, [EventType.Place]: 2.9, [EventType.Leaflet]: 2.6, [EventType.Blog]: 2.4, [EventType.Unravel]: 3.2, [EventType.Smokesignal]: 3.1, [EventType.Skylights]: 2.3, [EventType.Recipe]: 2.1, [EventType.Other]: 0.2
932 };
933 return (factors[eventType] || 0.5) * CONFIG.baseGrowthMultiplier;
934 }
935
936 async function handleSpacedustEvent(event, blobStore, canvas, isBlueskyFilterEnabled) {
937 const eventType = getEventTypeFromSource(event.source);
938 try {
939 const atUri = event.source_record;
940 if (!atUri || !atUri.startsWith('at://')) return eventType;
941
942 const didMatch = atUri.match(/at:\/\/(did:[^/]+)/);
943 if (!didMatch) return eventType;
944 const did = didMatch[1];
945
946 const pdsHostname = await resolveDID(did);
947 if (!pdsHostname) return eventType;
948
949 if (isBlueskyFilterEnabled && isBlueskyPDS(pdsHostname)) {
950 return eventType;
951 }
952
953 let blobs = blobStore.getBlobsByHostname(pdsHostname);
954
955 if (blobs.length === 0) {
956 if (blobStore.size() >= CONFIG.maxActiveBlobs) {
957 let oldestBlob = null;
958 for (const b of blobStore.getAllBlobs()) {
959 if (!oldestBlob || b.lastActive < oldestBlob.lastActive) oldestBlob = b;
960 }
961 if (oldestBlob) blobStore.removeBlob(oldestBlob.id);
962 }
963 const newBlob = createPDSBlob(pdsHostname, canvas, isBlueskyFilterEnabled && isBlueskyPDS(pdsHostname));
964 blobStore.addBlob(newBlob);
965 blobs = [newBlob];
966 }
967
968 for (const blob of blobs) {
969 const blobToUpdate = blobStore.getBlobById(blob.id);
970
971 if (blobToUpdate) {
972 const growth = calculateGrowth(eventType);
973 const newMass = Math.min(blobToUpdate.mass + growth, CONFIG.maxBlobMass);
974
975 const angle = Math.random() * Math.PI * 2;
976 const impulse = growth * 0.5;
977
978 const updates = {
979 mass: newMass,
980 size: Math.sqrt(newMass),
981 lastActive: Date.now(),
982 vx: blobToUpdate.vx + Math.cos(angle) * impulse,
983 vy: blobToUpdate.vy + Math.sin(angle) * impulse,
984 glow: true,
985 pulseStartTime: Date.now(),
986 shakeUntil: 0,
987 shimmerUntil: 0,
988 isMushroom: false,
989 };
990
991 if (blobToUpdate.isBlueskyPDS && newMass > CONFIG.mushroomThreshold) {
992 updates.isMushroom = true;
993 }
994
995 if (eventType === EventType.Block) updates.shakeUntil = Date.now() + 300;
996 if (eventType === EventType.List || eventType === EventType.Threadgate) updates.shimmerUntil = Date.now() + 500;
997
998 const updatedBlob = blobStore.updateBlob(blobToUpdate.id, updates);
999
1000 if (updatedBlob) {
1001 setTimeout(() => {
1002 const currentBlob = blobStore.getBlobById(updatedBlob.id);
1003 if (currentBlob && currentBlob.glow) blobStore.updateBlob(updatedBlob.id, { glow: false });
1004 }, 500);
1005
1006 if(CONFIG.enableBlobSplitting) splitBlobIfNeeded(blobStore, updatedBlob.id);
1007 }
1008 }
1009 }
1010 return eventType;
1011 } catch (e) {
1012 logger.error('Event Handler', 'Failed to process event', e);
1013 return eventType;
1014 }
1015 }
1016
1017 /**
1018 * The main entry point for the application. It orchestrates the animation loop,
1019 * physics updates, event handling, and UI rendering.
1020 */
1021 class Application {
1022 blobStore = new BlobStore();
1023 uiManager = new UIStateManager();
1024 uiUpdater = new OptimizedUIUpdater(this.uiManager);
1025 canvas;
1026 ctx;
1027 isPaused = false;
1028 isBlueskyFilterEnabled = false;
1029 showLabels = false;
1030 eventCount = 0;
1031 eventTypeCounts = {};
1032 lastFrameTime = performance.now();
1033 hoveredBlob = null;
1034 mouseX = 0;
1035 mouseY = 0;
1036
1037 async init() {
1038 logger.info('Application', 'Initializing Spacedust v2.0...');
1039 this.canvas = document.getElementById('canvas');
1040 this.ctx = this.canvas.getContext('2d');
1041
1042 const setupCanvas = () => {
1043 const container = this.canvas.parentElement;
1044 this.canvas.width = container.clientWidth;
1045 this.canvas.height = container.clientHeight;
1046 };
1047 setupCanvas();
1048 window.addEventListener('resize', setupCanvas);
1049
1050 this.canvas.addEventListener('mousemove', (e) => {
1051 const rect = this.canvas.getBoundingClientRect();
1052 const canvasX = e.clientX - rect.left;
1053 const canvasY = e.clientY - rect.top;
1054 this.updateHoveredBlob(canvasX, canvasY);
1055 this.mouseX = e.clientX;
1056 this.mouseY = e.clientY;
1057 });
1058 this.canvas.addEventListener('mouseleave', () => { this.hoveredBlob = null; });
1059
1060 document.getElementById('pause-resume')?.addEventListener('click', () => {
1061 this.isPaused = !this.isPaused;
1062 document.getElementById('pause-resume').textContent = this.isPaused ? 'Resume' : 'Pause';
1063 if (!this.isPaused) requestAnimationFrame(this.animate.bind(this));
1064 });
1065
1066 const toggleUI = () => document.body.classList.toggle('ui-hidden');
1067 document.getElementById('toggle-ui')?.addEventListener('click', toggleUI);
1068 window.addEventListener('keydown', (e) => {
1069 if(e.key === 'h') toggleUI();
1070 });
1071
1072 document.getElementById('filter-bluesky')?.addEventListener('change', (e) => {
1073 this.isBlueskyFilterEnabled = e.target.checked;
1074 this.blobStore.getAllBlobs().forEach(blob => {
1075 if (blob.isBlueskyPDS) {
1076 this.blobStore.updateBlob(blob.id, { isFiltered: this.isBlueskyFilterEnabled });
1077 }
1078 });
1079 });
1080
1081 document.getElementById('toggle-labels')?.addEventListener('change', (e) => {
1082 this.showLabels = e.target.checked;
1083 });
1084
1085 this.startEventSubscription();
1086 requestAnimationFrame(this.animate.bind(this));
1087 }
1088
1089 updateHoveredBlob(canvasX, canvasY) {
1090 this.hoveredBlob = null;
1091 const blobs = this.blobStore.getAllBlobs().sort((a,b) => b.mass - a.mass);
1092 for (const blob of blobs) {
1093 if (blob.isFiltered) continue;
1094 const dx = canvasX - blob.x;
1095 const dy = canvasY - blob.y;
1096 if (dx * dx + dy * dy < blob.size * blob.size) {
1097 this.hoveredBlob = blob;
1098 break;
1099 }
1100 }
1101 }
1102
1103 updateTooltip() {
1104 const tooltip = document.getElementById('tooltip');
1105 if (this.hoveredBlob) {
1106 tooltip.style.display = 'block';
1107 tooltip.style.left = `${this.mouseX + 15}px`;
1108 tooltip.style.top = `${this.mouseY + 15}px`;
1109 tooltip.innerHTML = `
1110 <strong>${this.hoveredBlob.pdsHostname}</strong><br>
1111 Mass: ${Math.round(this.hoveredBlob.mass)}
1112 `;
1113 } else {
1114 tooltip.style.display = 'none';
1115 }
1116 }
1117
1118 async startEventSubscription() {
1119 for await (const event of subscribeToSpacedust()) {
1120 this.eventCount++;
1121 this.uiManager.invalidateStats();
1122 this.uiManager.invalidatePdsLegend();
1123 const eventType = await handleSpacedustEvent(event, this.blobStore, this.canvas, this.isBlueskyFilterEnabled);
1124 this.eventTypeCounts[eventType] = (this.eventTypeCounts[eventType] || 0) + 1;
1125 }
1126 }
1127
1128 updatePhysics() {
1129 for (const blob of this.blobStore.getAllBlobs()) {
1130 if (blob.isFiltered) continue;
1131
1132 let newX = blob.x + blob.vx, newY = blob.y + blob.vy;
1133 let newVx = blob.vx * CONFIG.friction, newVy = blob.vy * CONFIG.friction;
1134
1135 if (newX - blob.size < 0 || newX + blob.size > this.canvas.width) newVx *= -CONFIG.boundaryBounceDamping;
1136 if (newY - blob.size < 0 || newY + blob.size > this.canvas.height) newVy *= -CONFIG.boundaryBounceDamping;
1137
1138 newX = Math.max(blob.size, Math.min(this.canvas.width - blob.size, newX));
1139 newY = Math.max(blob.size, Math.min(this.canvas.height - blob.size, newY));
1140
1141 this.blobStore.updateBlob(blob.id, { x: newX, y: newY, vx: newVx, vy: newVy });
1142 }
1143 }
1144
1145 animate(currentTime) {
1146 if (this.isPaused) return;
1147
1148 this.updatePhysics();
1149 if (CONFIG.enableDecay) applyDecay(this.blobStore);
1150 cleanupDeadBlobs(this.blobStore);
1151
1152 if (CONFIG.enableCollisionDetection) {
1153 const collisions = findCollisions(this.blobStore, this.canvas.width, this.canvas.height);
1154 collisions.forEach(([b1, b2]) => handleCollision(this.blobStore, b1, b2));
1155 }
1156 renderFrame(this.ctx, this.canvas, this.blobStore, this.showLabels);
1157
1158 this.updateTooltip();
1159
1160 const frameTime = performance.now() - this.lastFrameTime;
1161 this.lastFrameTime = performance.now();
1162 perfMonitor.record('frame-time', frameTime);
1163
1164 this.uiUpdater.tryUpdateStats(currentTime, () => {
1165 const perfStats = perfMonitor.getStats('frame-time');
1166 const stats = {
1167 blobCount: this.blobStore.size(),
1168 eventCount: this.eventCount,
1169 fps: perfStats ? 1000 / perfStats.avg : 0,
1170 frameTime: perfStats ? perfStats.avg : 0,
1171 };
1172 updateStatsUI(stats, this.eventTypeCounts);
1173 });
1174
1175 this.uiUpdater.tryUpdatePdsLegend(currentTime, () => {
1176 updatePdsLegendUI(this.blobStore);
1177 });
1178
1179 requestAnimationFrame(this.animate.bind(this));
1180 }
1181 }
1182
1183 document.addEventListener('DOMContentLoaded', () => {
1184 new Application().init();
1185 });
1186
1187 </script>
1188</body>
1189</html>