atproto plays agar.io
petri-dish.html
1189 lines 49 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>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>