Chunte HF Staff commited on
Commit
b42409c
Β·
verified Β·
1 Parent(s): b3a8ef8

Upload index.html

Browse files
Files changed (1) hide show
  1. index.html +1565 -18
index.html CHANGED
@@ -1,19 +1,1566 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </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>Download Gravity Field</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500&display=swap" rel="stylesheet">
10
+ <style>
11
+ * { margin: 0; padding: 0; box-sizing: border-box; }
12
+ html, body { width: 100%; height: 100%; overflow: hidden; background: #F6F4EF; }
13
+ canvas { display: block; }
14
+ #tooltip {
15
+ position: absolute;
16
+ pointer-events: none;
17
+ font: 14px/1.4 'SF Mono', 'Menlo', 'Consolas', monospace;
18
+ color: #161513;
19
+ text-align: center;
20
+ white-space: nowrap;
21
+ opacity: 0;
22
+ transition: opacity 0.2s;
23
+ }
24
+ #tooltip.visible {
25
+ opacity: 1;
26
+ pointer-events: auto;
27
+ }
28
+ #tooltip a {
29
+ color: #161513;
30
+ text-decoration: none;
31
+ }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <canvas id="c"></canvas>
36
+ <div id="tooltip"></div>
37
+ <script>
38
+ (async function () {
39
+
40
+ // ── Constants ──────────────────────────────────────────────
41
+ const PARTICLE_COUNT = 4000;
42
+ const G = 1.5;
43
+ const BASE_ANGULAR = 0.02;
44
+ const COLOR = '#161513';
45
+ const BG = '#F6F4EF';
46
+ const CORE_RADIUS = 5;
47
+ const PARTICLE_SIZE = 1.2;
48
+ const MAX_OMEGA = 0.02; // max angular velocity in orbit (rad/frame)
49
+ const MAX_OMEGA_ABSORB = 0.04; // max angular velocity during absorption spiral
50
+ const TARGET_CAPTURE_OMEGA = 0.015; // desired angular velocity at capture
51
+
52
+ // Phase 0: Free Fall
53
+ const DRAG = 0.9998;
54
+ const SPEED_CAP = 6;
55
+ const NEAR_BOOST_RADIUS = 300;
56
+ const NEAR_BOOST_FACTOR = 1.2;
57
+
58
+ // Phase 1: Outer Orbit
59
+ const SPIRAL_FRICTION = 0.0002;
60
+ const SPIRAL_FRICTION_VAR = 0.0001;
61
+ const OUTER_ORBIT_DURATION_BASE = 200;
62
+ const OUTER_ORBIT_DURATION_MASS = 400;
63
+
64
+ // Phase 3: Fade at Ring
65
+ const CONSUMPTION_SIZE_RATE = 0.03;
66
+
67
+ // Phase 4: Absorption Spiral (inward to core)
68
+ const ABSORB_CHANCE_BASE = 0.04;
69
+ const ABSORB_CHANCE_MASS = 0.06;
70
+ const ABSORB_RADIAL_DRAIN = 0.01; // angular momentum drain per frame (fraction of L)
71
+ const ABSORB_INWARD_FORCE = 0.03; // extra inward radial pull per frame
72
+ const ABSORB_MIN_RADIUS = 8;
73
+
74
+ // Capture blending (Phase 0 β†’ Phase 1 smooth transition)
75
+ const CAPTURE_BLEND_FRAMES = 30; // frames to blend cartesian β†’ polar
76
+
77
+ // Physics effects
78
+ const PRECESSION_RATE = 0.003;
79
+ const SLINGSHOT_BOOST = 1.15;
80
+ const SUBSTEP_SPEED_THRESHOLD = 3;
81
+
82
+ // Pre-simulation
83
+ const PRE_SIM_FRAMES = 300;
84
+
85
+ // Layout
86
+ const SEPARATION_PAD = 60;
87
+ const EDGE_MARGIN = 30;
88
+
89
+ // Elastic pull (iOS-style spring)
90
+ const ELASTIC_ZONE_MULT = 1.8; // elastic zone = captureRadius Γ— this
91
+ const ELASTIC_STIFFNESS = 0.06; // spring stiffness (0.05-0.12 = iOS feel)
92
+ const ELASTIC_DAMPING = 0.79; // velocity damping (lower = bouncier)
93
+ const ELASTIC_MAX_DISP = 30; // max px displacement from rest
94
+ const ELASTIC_PULL_STRENGTH = 0.35; // how strongly cursor pulls (0-1)
95
+ const ELASTIC_FALLOFF_POW = 2; // distance falloff exponent
96
+ const ELASTIC_SECONDARY = 0.3; // secondary pull for non-closest models
97
+
98
+ // ── Canvas ─────────────────────────────────────────────────
99
+ const canvas = document.getElementById('c');
100
+ const ctx = canvas.getContext('2d');
101
+
102
+ function resize() {
103
+ canvas.width = window.innerWidth;
104
+ canvas.height = window.innerHeight;
105
+ }
106
+ window.addEventListener('resize', resize);
107
+ resize();
108
+
109
+ // ── Data Source (trending models, sorted by downloads) ─────
110
+ const FALLBACK = [
111
+ { id: 'hexgrad/Kokoro-82M', downloads: 8427252, task: 'text-to-speech' },
112
+ { id: 'openai/gpt-oss-20b', downloads: 5541163, task: 'text-generation' },
113
+ { id: 'openai/gpt-oss-120b', downloads: 3477982, task: 'text-generation' },
114
+ { id: 'Lightricks/LTX-2', downloads: 2021784, task: 'image-to-video' },
115
+ { id: 'zai-org/GLM-4.7-Flash', downloads: 1751035, task: 'text-generation' },
116
+ { id: 'zai-org/GLM-OCR', downloads: 1240960, task: 'image-to-text' },
117
+ { id: 'moonshotai/Kimi-K2.5', downloads: 1006690, task: 'image-text-to-text' },
118
+ { id: 'Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice', downloads: 877971, task: 'text-to-speech' },
119
+ { id: 'nvidia/personaplex-7b-v1', downloads: 509647, task: 'audio-to-audio' },
120
+ { id: 'unsloth/GLM-4.7-Flash-GGUF', downloads: 395137, task: 'text-generation' }
121
+ ];
122
+
123
+ let models;
124
+ try {
125
+ // Fetch trending models, then pick the top 10 by downloads
126
+ const res = await fetch('https://huggingface.co/api/models?sort=trendingScore&limit=50');
127
+ if (!res.ok) throw new Error(res.status);
128
+ const data = await res.json();
129
+ const all = data.map(m => {
130
+ const fullId = m.modelId || m.id;
131
+ const author = fullId.includes('/') ? fullId.split('/')[0] : '';
132
+ return {
133
+ id: fullId,
134
+ downloads: m.downloads || 1,
135
+ task: m.pipeline_tag || 'unknown',
136
+ author: author,
137
+ avatarUrl: ''
138
+ };
139
+ });
140
+ // Sort by downloads descending and take top 10
141
+ all.sort((a, b) => b.downloads - a.downloads);
142
+ models = all.slice(0, 10);
143
+ } catch (e) {
144
+ models = FALLBACK.map(m => {
145
+ const author = m.id.includes('/') ? m.id.split('/')[0] : '';
146
+ return { ...m, author: author, avatarUrl: '' };
147
+ });
148
+ }
149
+
150
+ // Fetch author avatars in parallel (non-blocking)
151
+ const uniqueAuthors = [...new Set(models.map(m => m.author).filter(Boolean))];
152
+ const avatarMap = {};
153
+ await Promise.all(uniqueAuthors.map(async (author) => {
154
+ try {
155
+ const r = await fetch('https://huggingface.co/api/organizations/' + author + '/avatar');
156
+ if (r.ok) {
157
+ const json = await r.json();
158
+ if (json.avatarUrl) avatarMap[author] = json.avatarUrl;
159
+ }
160
+ } catch (_) {}
161
+ }));
162
+ for (const m of models) {
163
+ if (avatarMap[m.author]) m.avatarUrl = avatarMap[m.author];
164
+ }
165
+
166
+ // ── Mass Mapping ───────────────────────────────────────────
167
+ const D = models.map(m => m.downloads);
168
+ const D1 = D[0];
169
+ const D2 = D[1];
170
+ const dominanceRatio = D1 / D2;
171
+
172
+ const massBase = D.map(d => Math.log(d));
173
+ const exponent = dominanceRatio >= 1.25 ? 1.15 : 0.95;
174
+ const massArr = massBase.map(mb => Math.pow(mb, exponent));
175
+
176
+ const massMin = Math.min(...massArr);
177
+ const massMax = Math.max(...massArr);
178
+ const massRange = massMax - massMin || 1;
179
+ const massNorm = massArr.map(m => (m - massMin) / massRange);
180
+
181
+ // ── Per-Model Derived Values ───────────────────────────────
182
+ const MODEL_COUNT = models.length;
183
+ const modelOrbitBase = new Float64Array(MODEL_COUNT);
184
+ const modelCaptureRadius = new Float64Array(MODEL_COUNT);
185
+ for (let i = 0; i < MODEL_COUNT; i++) {
186
+ modelOrbitBase[i] = 30 + 180 * massNorm[i];
187
+ modelCaptureRadius[i] = modelOrbitBase[i] * 1.2;
188
+ }
189
+
190
+ // Spawn weighting β€” dynamic rebalancing
191
+ const totalMassNorm = massNorm.reduce((a, b) => a + b, 0);
192
+ const modelOrbitCount = new Float64Array(MODEL_COUNT);
193
+ const modelTargetPop = new Float64Array(MODEL_COUNT);
194
+ const spawnWeight = new Float64Array(MODEL_COUNT);
195
+ let totalSpawnWeight = totalMassNorm;
196
+ let rebalanceTimer = 0;
197
+
198
+ const TARGET_ORBIT_FRACTION = 0.7;
199
+ const totalTarget = PARTICLE_COUNT * TARGET_ORBIT_FRACTION;
200
+ for (let j = 0; j < MODEL_COUNT; j++) {
201
+ modelTargetPop[j] = totalTarget * (massNorm[j] / totalMassNorm);
202
+ spawnWeight[j] = massNorm[j];
203
+ }
204
+
205
+ // ── Layout: Download-Weighted Central Placement ─────────────
206
+ function computePositions() {
207
+ const cx = canvas.width / 2;
208
+ const cy = canvas.height / 2;
209
+ const maxR = Math.min(cx, cy) * 0.72;
210
+ const positions = [];
211
+
212
+ // Golden angle for even angular spread
213
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5)); // ~137.5Β°
214
+
215
+ for (let i = 0; i < MODEL_COUNT; i++) {
216
+ // massNorm[i] ranges 0..1 β€” higher = more downloads
217
+ // Radius: heaviest near center, lightest near edge
218
+ // Use sqrt to spread inner models out (avoid central clumping)
219
+ const radialT = 1 - massNorm[i]; // 0 for heaviest, 1 for lightest
220
+ const r = maxR * (0.05 + 0.95 * Math.sqrt(radialT));
221
+ // Golden angle spiral for angular spread
222
+ const angle = goldenAngle * i;
223
+
224
+ positions.push({
225
+ x: cx + r * Math.cos(angle),
226
+ y: cy + r * Math.sin(angle)
227
+ });
228
+ }
229
+
230
+ // Iterative separation pass β€” prevent overlapping capture zones
231
+ for (let iter = 0; iter < 200; iter++) {
232
+ let moved = false;
233
+ for (let a = 0; a < MODEL_COUNT; a++) {
234
+ for (let b = a + 1; b < MODEL_COUNT; b++) {
235
+ const dx = positions[b].x - positions[a].x;
236
+ const dy = positions[b].y - positions[a].y;
237
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
238
+ const minDist = modelCaptureRadius[a] + modelCaptureRadius[b] + SEPARATION_PAD;
239
+ if (dist < minDist) {
240
+ const overlap = (minDist - dist) / 2;
241
+ const nx = dx / dist;
242
+ const ny = dy / dist;
243
+ // Heavier models resist displacement more
244
+ const wA = 1 - massNorm[a] * 0.7; // heavy=0.3, light=1.0
245
+ const wB = 1 - massNorm[b] * 0.7;
246
+ const total = wA + wB;
247
+ positions[a].x -= nx * overlap * (wA / total);
248
+ positions[a].y -= ny * overlap * (wA / total);
249
+ positions[b].x += nx * overlap * (wB / total);
250
+ positions[b].y += ny * overlap * (wB / total);
251
+ moved = true;
252
+ }
253
+ }
254
+ }
255
+
256
+ // Pull models back toward their ideal radius from center
257
+ // Prevents separation pass from pushing heavy models to the edge
258
+ for (let i = 0; i < MODEL_COUNT; i++) {
259
+ const dx = positions[i].x - cx;
260
+ const dy = positions[i].y - cy;
261
+ const currentR = Math.sqrt(dx * dx + dy * dy) || 1;
262
+ const radialT = 1 - massNorm[i];
263
+ const idealR = maxR * (0.05 + 0.95 * Math.sqrt(radialT));
264
+ const pullStrength = 0.1; // gentle pull back toward ideal radius
265
+ const targetR = currentR + (idealR - currentR) * pullStrength;
266
+ if (currentR > 0.1) {
267
+ positions[i].x = cx + (dx / currentR) * targetR;
268
+ positions[i].y = cy + (dy / currentR) * targetR;
269
+ }
270
+ }
271
+
272
+ // Edge clamping
273
+ for (let i = 0; i < MODEL_COUNT; i++) {
274
+ const r = modelCaptureRadius[i];
275
+ positions[i].x = Math.max(r + EDGE_MARGIN, Math.min(canvas.width - r - EDGE_MARGIN, positions[i].x));
276
+ positions[i].y = Math.max(r + EDGE_MARGIN, Math.min(canvas.height - r - EDGE_MARGIN, positions[i].y));
277
+ }
278
+ if (!moved) break;
279
+ }
280
+
281
+ return positions;
282
+ }
283
+
284
+ let positions = computePositions();
285
+
286
+ // ── Model Data Array ───────────────────────────────────────
287
+ const md = [];
288
+ for (let i = 0; i < MODEL_COUNT; i++) {
289
+ md.push({
290
+ x: positions[i].x,
291
+ y: positions[i].y,
292
+ massNorm: massNorm[i],
293
+ captureRadius: modelCaptureRadius[i]
294
+ });
295
+ }
296
+
297
+ function syncModelData() {
298
+ for (let i = 0; i < MODEL_COUNT; i++) {
299
+ md[i].x = positions[i].x;
300
+ md[i].y = positions[i].y;
301
+ }
302
+ }
303
+
304
+ // ── Elastic Pull State ──────────────────────────────────────
305
+ const elasticDx = new Float64Array(MODEL_COUNT);
306
+ const elasticDy = new Float64Array(MODEL_COUNT);
307
+ const elasticVx = new Float64Array(MODEL_COUNT);
308
+ const elasticVy = new Float64Array(MODEL_COUNT);
309
+
310
+ let cursorX = -9999;
311
+ let cursorY = -9999;
312
+ let cursorOnCanvas = false;
313
+
314
+ function updateElasticPull() {
315
+ for (let j = 0; j < MODEL_COUNT; j++) {
316
+ let targetDx = 0, targetDy = 0;
317
+
318
+ if (cursorOnCanvas) {
319
+ const restX = positions[j].x, restY = positions[j].y;
320
+ const dx = cursorX - restX, dy = cursorY - restY;
321
+ const dist = Math.sqrt(dx * dx + dy * dy);
322
+ const zone = modelCaptureRadius[j] * ELASTIC_ZONE_MULT;
323
+
324
+ if (dist < zone && dist > 1) {
325
+ const t = dist / zone;
326
+ const falloff = 1 - Math.pow(t, ELASTIC_FALLOFF_POW);
327
+ const nx = dx / dist, ny = dy / dist;
328
+ let pull = ELASTIC_PULL_STRENGTH * falloff * dist;
329
+
330
+ // iOS dock effect: only closest model gets full strength
331
+ let isClosest = true;
332
+ for (let k = 0; k < MODEL_COUNT; k++) {
333
+ if (k === j) continue;
334
+ const dxk = cursorX - positions[k].x, dyk = cursorY - positions[k].y;
335
+ if (dxk * dxk + dyk * dyk < dist * dist) { isClosest = false; break; }
336
+ }
337
+ if (!isClosest) pull *= ELASTIC_SECONDARY;
338
+
339
+ pull = Math.min(pull, ELASTIC_MAX_DISP);
340
+ targetDx = nx * pull;
341
+ targetDy = ny * pull;
342
+ }
343
+ }
344
+
345
+ // Damped spring physics
346
+ elasticVx[j] = elasticVx[j] * ELASTIC_DAMPING + (targetDx - elasticDx[j]) * ELASTIC_STIFFNESS;
347
+ elasticVy[j] = elasticVy[j] * ELASTIC_DAMPING + (targetDy - elasticDy[j]) * ELASTIC_STIFFNESS;
348
+ elasticDx[j] += elasticVx[j];
349
+ elasticDy[j] += elasticVy[j];
350
+
351
+ // Settle threshold β€” kill micro-oscillations
352
+ if (Math.abs(elasticVx[j]) < 0.01 && Math.abs(elasticDx[j]) < 0.1) {
353
+ elasticVx[j] = 0;
354
+ if (targetDx === 0) elasticDx[j] = 0;
355
+ }
356
+ if (Math.abs(elasticVy[j]) < 0.01 && Math.abs(elasticDy[j]) < 0.1) {
357
+ elasticVy[j] = 0;
358
+ if (targetDy === 0) elasticDy[j] = 0;
359
+ }
360
+
361
+ // Apply displacement to model position
362
+ md[j].x = positions[j].x + elasticDx[j];
363
+ md[j].y = positions[j].y + elasticDy[j];
364
+ }
365
+ }
366
+
367
+ // ── Model Neighbors (for perturbation) ─────────────────────
368
+ let modelNeighbors = [];
369
+ function computeNeighbors() {
370
+ modelNeighbors = [];
371
+ for (let j = 0; j < MODEL_COUNT; j++) {
372
+ const neighbors = [];
373
+ for (let k = 0; k < MODEL_COUNT; k++) {
374
+ if (k === j) continue;
375
+ const dx = md[j].x - md[k].x;
376
+ const dy = md[j].y - md[k].y;
377
+ const dist = Math.sqrt(dx * dx + dy * dy);
378
+ if (dist < modelCaptureRadius[j] + modelCaptureRadius[k] + 200) {
379
+ neighbors.push(k);
380
+ }
381
+ }
382
+ modelNeighbors.push(neighbors);
383
+ }
384
+ }
385
+ computeNeighbors();
386
+
387
+ // Frame-dragging: per-model rotation bias
388
+ const modelRotBias = new Float64Array(MODEL_COUNT);
389
+ // Fixed rotation direction per model (set after pre-sim, Β±1)
390
+ const modelRotDir = new Int8Array(MODEL_COUNT);
391
+
392
+ // ── Resize Handler ─────────────────────────────────────────
393
+ window.addEventListener('resize', () => {
394
+ positions = computePositions();
395
+ syncModelData();
396
+ computeNeighbors();
397
+ // Reset elastic state β€” rest positions have changed
398
+ elasticDx.fill(0); elasticDy.fill(0);
399
+ elasticVx.fill(0); elasticVy.fill(0);
400
+ });
401
+
402
+ // ── Particles ──────────────────────────────────────────────
403
+ const particles = new Array(PARTICLE_COUNT);
404
+
405
+ function spawnAtEdge(p) {
406
+ const w = canvas.width;
407
+ const h = canvas.height;
408
+ const edge = Math.random() * 4 | 0;
409
+
410
+ if (edge === 0) { p.x = Math.random() * w; p.y = 0; }
411
+ else if (edge === 1) { p.x = Math.random() * w; p.y = h; }
412
+ else if (edge === 2) { p.x = 0; p.y = Math.random() * h; }
413
+ else { p.x = w; p.y = Math.random() * h; }
414
+
415
+ // Aim at a dynamically-weighted random model with Β±8Β° spread
416
+ let r = Math.random() * totalSpawnWeight;
417
+ let target = 0;
418
+ for (let j = 0; j < MODEL_COUNT; j++) {
419
+ r -= spawnWeight[j];
420
+ if (r <= 0) { target = j; break; }
421
+ }
422
+
423
+ const dx = md[target].x - p.x;
424
+ const dy = md[target].y - p.y;
425
+ const dist = Math.sqrt(dx * dx + dy * dy) || 1;
426
+ const speed = 1.0 + Math.random() * 1.0;
427
+
428
+ const spread = (Math.random() - 0.5) * 0.28;
429
+ const cosS = Math.cos(spread);
430
+ const sinS = Math.sin(spread);
431
+ const ndx = dx / dist;
432
+ const ndy = dy / dist;
433
+ p.vx = (ndx * cosS - ndy * sinS) * speed;
434
+ p.vy = (ndx * sinS + ndy * cosS) * speed;
435
+
436
+ p.size = PARTICLE_SIZE;
437
+ p.phase = 0;
438
+ p.attractorIdx = -1;
439
+ p.orbitRadius = 0;
440
+ p.angle = 0;
441
+ p.angularMomentum = 0;
442
+ p.spiralFriction = 0;
443
+ p.orbitTimer = 0;
444
+ p.orbitDuration = 0;
445
+ p.radialVel = 0;
446
+ p.blendTimer = 0;
447
+ }
448
+
449
+ // Initialize all particles at edges with staggered spawn frames
450
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
451
+ particles[i] = {
452
+ x: 0, y: 0, vx: 0, vy: 0, size: PARTICLE_SIZE,
453
+ phase: 0, attractorIdx: -1,
454
+ orbitRadius: 0, angle: 0,
455
+ angularMomentum: 0, spiralFriction: 0,
456
+ orbitTimer: 0, orbitDuration: 0,
457
+ radialVel: 0, blendTimer: 0,
458
+ _spawnFrame: Math.floor(Math.random() * 200)
459
+ };
460
+ spawnAtEdge(particles[i]);
461
+ }
462
+
463
+ // ── Capture Helper ─────────────────────────────────────────
464
+ function captureParticle(p, j, dist) {
465
+ const m = md[j];
466
+ p.phase = 1;
467
+ p.attractorIdx = j;
468
+ p.orbitRadius = dist;
469
+ p.angle = Math.atan2(p.y - m.y, p.x - m.x);
470
+
471
+ // Angular momentum from incoming velocity
472
+ const radX = (p.x - m.x) / dist;
473
+ const radY = (p.y - m.y) / dist;
474
+ const vRad = p.vx * radX + p.vy * radY;
475
+ const vTanX = p.vx - vRad * radX;
476
+ const vTanY = p.vy - vRad * radY;
477
+ const vTan = Math.sqrt(vTanX * vTanX + vTanY * vTanY);
478
+
479
+ // Force orbit direction to match model's locked rotation
480
+ const sign = modelRotDir[j] || 1;
481
+
482
+ // Compute L that produces TARGET_CAPTURE_OMEGA at this radius
483
+ // Blend with incoming velocity for organic variation
484
+ const targetL = TARGET_CAPTURE_OMEGA * dist * dist;
485
+ const incomingL = dist * vTan;
486
+ const blendedL = targetL * 0.7 + Math.min(incomingL, targetL * 1.5) * 0.3;
487
+ p.angularMomentum = blendedL * sign;
488
+
489
+ // Minimum angular momentum floor
490
+ const minL = dist * BASE_ANGULAR * 0.5;
491
+ if (Math.abs(p.angularMomentum) < minL) {
492
+ p.angularMomentum = minL * sign;
493
+ }
494
+
495
+ // Radial velocity preserved for smooth blending (will decay during blend)
496
+ p.radialVel = vRad;
497
+ p.blendTimer = CAPTURE_BLEND_FRAMES;
498
+
499
+ p.spiralFriction = SPIRAL_FRICTION + (Math.random() - 0.5) * SPIRAL_FRICTION_VAR * 2;
500
+
501
+ // Orbit duration: heavier models hold particles longer β†’ denser rings
502
+ p.orbitTimer = 0;
503
+ p.orbitDuration = OUTER_ORBIT_DURATION_BASE + OUTER_ORBIT_DURATION_MASS * m.massNorm;
504
+ }
505
+
506
+ // ── Label Bounce Deflection ────────────────────────────────
507
+ const BOUNCE_RESTITUTION = 0.85; // energy retained β€” snappy cartoon bounce
508
+ const BOUNCE_MIN_KICK = 2.25; // outward kick on bounce (75%)
509
+ const BOUNCE_ORBIT_PUNCH = 3.375; // radial punch for orbiting particles (75%)
510
+
511
+ function deflectFromLabel(p) {
512
+ if (!labelBounceRect || labelBounceAlpha <= 0) return;
513
+ const b = labelBounceRect;
514
+ const alpha = labelBounceAlpha;
515
+
516
+ // Effective zone scales with animation alpha
517
+ const ehw = b.hw * alpha;
518
+ const ehh = b.hh * alpha;
519
+
520
+ // Signed distance from particle to rect center
521
+ const dx = p.x - b.cx;
522
+ const dy = p.y - b.cy;
523
+
524
+ // Quick bounding-box rejection
525
+ const ax = Math.abs(dx);
526
+ const ay = Math.abs(dy);
527
+ if (ax > ehw || ay > ehh) return;
528
+
529
+ // Inside the bounding box β€” find shortest exit wall
530
+ const overlapX = ehw - ax;
531
+ const overlapY = ehh - ay;
532
+
533
+ // Wall normal direction (points outward from label center)
534
+ let nx = 0, ny = 0;
535
+ if (overlapX < overlapY) {
536
+ nx = dx >= 0 ? 1 : -1;
537
+ p.x += nx * (overlapX + 1);
538
+ } else {
539
+ ny = dy >= 0 ? 1 : -1;
540
+ p.y += ny * (overlapY + 1);
541
+ }
542
+
543
+ if (p.phase === 0) {
544
+ // Free-fall: reflect velocity off the wall normal with cartoon punch
545
+ const vDotN = p.vx * nx + p.vy * ny;
546
+ if (vDotN < 0) {
547
+ p.vx -= 2 * vDotN * nx;
548
+ p.vy -= 2 * vDotN * ny;
549
+ p.vx *= BOUNCE_RESTITUTION;
550
+ p.vy *= BOUNCE_RESTITUTION;
551
+ }
552
+ // Strong outward kick
553
+ const outV = p.vx * nx + p.vy * ny;
554
+ if (outV < BOUNCE_MIN_KICK) {
555
+ p.vx += (BOUNCE_MIN_KICK - outV) * nx;
556
+ p.vy += (BOUNCE_MIN_KICK - outV) * ny;
557
+ }
558
+ } else if ((p.phase === 1 || p.phase === 3 || p.phase === 4) && p.attractorIdx >= 0) {
559
+ // Orbiting particle: convert orbital state to cartesian, reflect, convert back
560
+ const m = md[p.attractorIdx];
561
+ const r = p.orbitRadius || 1;
562
+ const rawOmega = p.angularMomentum / (r * r);
563
+ const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA);
564
+
565
+ const cosA = Math.cos(p.angle);
566
+ const sinA = Math.sin(p.angle);
567
+ const vTang = omega * r;
568
+ const vRad = p.radialVel || 0;
569
+ let ovx = -sinA * vTang + cosA * vRad;
570
+ let ovy = cosA * vTang + sinA * vRad;
571
+
572
+ // Reflect velocity off wall normal
573
+ const vDotN = ovx * nx + ovy * ny;
574
+ if (vDotN < 0) {
575
+ ovx -= 2 * vDotN * nx;
576
+ ovy -= 2 * vDotN * ny;
577
+ ovx *= BOUNCE_RESTITUTION;
578
+ ovy *= BOUNCE_RESTITUTION;
579
+ }
580
+
581
+ // Cartoon punch: strong outward kick along the wall normal
582
+ ovx += nx * BOUNCE_ORBIT_PUNCH;
583
+ ovy += ny * BOUNCE_ORBIT_PUNCH;
584
+
585
+ // Recompute polar coordinates from new position
586
+ const ndx = p.x - m.x;
587
+ const ndy = p.y - m.y;
588
+ const newR = Math.sqrt(ndx * ndx + ndy * ndy) || 1;
589
+ const newAngle = Math.atan2(ndy, ndx);
590
+ const newCosA = Math.cos(newAngle);
591
+ const newSinA = Math.sin(newAngle);
592
+
593
+ // Decompose reflected velocity back to tangential + radial
594
+ const newVRad = ovx * newCosA + ovy * newSinA;
595
+ const newVTan = -ovx * newSinA + ovy * newCosA;
596
+
597
+ // Update polar state β€” the big radial kick creates a visible outward arc
598
+ p.orbitRadius = newR;
599
+ p.angle = newAngle;
600
+ p.angularMomentum = newVTan * newR;
601
+ p.radialVel = newVRad;
602
+
603
+ // Longer blend so the bounce arc plays out before orbit reasserts
604
+ if (p.phase === 1 && p.blendTimer <= 0) {
605
+ p.blendTimer = 30;
606
+ }
607
+ }
608
+ }
609
+
610
+ // ── Particle Update ────────────────────────────────────────
611
+ function updateParticle(p, w, h) {
612
+ // Phase 0: Free Fall
613
+ if (p.phase === 0) {
614
+ let captured = false;
615
+
616
+ // Accumulate gravity from all models
617
+ for (let j = 0; j < MODEL_COUNT; j++) {
618
+ const m = md[j];
619
+ const dx = m.x - p.x;
620
+ const dy = m.y - p.y;
621
+ const distSq = dx * dx + dy * dy;
622
+ const dist = Math.sqrt(distSq);
623
+ if (dist < 2) continue;
624
+
625
+ // Gravity: inverse-square near, softened at range
626
+ // At long range, use 1/(dist*softDist) instead of 1/distΒ² to prevent stalling
627
+ const softDist = Math.max(dist, 200);
628
+ let force = m.massNorm * G / (dist * softDist);
629
+ if (dist < NEAR_BOOST_RADIUS) {
630
+ force *= 1 + NEAR_BOOST_FACTOR * (1 - dist / NEAR_BOOST_RADIUS);
631
+ }
632
+
633
+ p.vx += (dx / dist) * force;
634
+ p.vy += (dy / dist) * force;
635
+ }
636
+
637
+ // Drag + speed cap
638
+ p.vx *= DRAG;
639
+ p.vy *= DRAG;
640
+ const speedSq = p.vx * p.vx + p.vy * p.vy;
641
+ if (speedSq > SPEED_CAP * SPEED_CAP) {
642
+ const s = Math.sqrt(speedSq);
643
+ p.vx = (p.vx / s) * SPEED_CAP;
644
+ p.vy = (p.vy / s) * SPEED_CAP;
645
+ }
646
+
647
+ // Sub-stepping for fast particles
648
+ const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy);
649
+ const steps = speed > SUBSTEP_SPEED_THRESHOLD ? 2 : 1;
650
+ const svx = p.vx / steps;
651
+ const svy = p.vy / steps;
652
+
653
+ for (let s = 0; s < steps; s++) {
654
+ p.x += svx;
655
+ p.y += svy;
656
+
657
+ // Check capture at each sub-step
658
+ for (let j = 0; j < MODEL_COUNT; j++) {
659
+ const m = md[j];
660
+ const dx = m.x - p.x;
661
+ const dy = m.y - p.y;
662
+ const dist = Math.sqrt(dx * dx + dy * dy);
663
+ if (dist <= m.captureRadius) {
664
+ captureParticle(p, j, dist);
665
+ captured = true;
666
+ break;
667
+ }
668
+ }
669
+ if (captured) break;
670
+ }
671
+
672
+ if (!captured) {
673
+ // Slingshot: if near 2+ models, boost velocity
674
+ let nearCount = 0;
675
+ for (let j = 0; j < MODEL_COUNT; j++) {
676
+ const dx = md[j].x - p.x;
677
+ const dy = md[j].y - p.y;
678
+ if (dx * dx + dy * dy < md[j].captureRadius * md[j].captureRadius * 2.25) {
679
+ nearCount++;
680
+ }
681
+ }
682
+ if (nearCount >= 2) {
683
+ const spd = Math.sqrt(p.vx * p.vx + p.vy * p.vy) || 1;
684
+ const boost = Math.min(SLINGSHOT_BOOST, SPEED_CAP / spd);
685
+ p.vx *= boost;
686
+ p.vy *= boost;
687
+ }
688
+
689
+ // Out of bounds respawn
690
+ if (p.x < -50 || p.x > w + 50 || p.y < -50 || p.y > h + 50) {
691
+ spawnAtEdge(p);
692
+ }
693
+ }
694
+ return;
695
+ }
696
+
697
+ // Phase 1: Outer Orbit
698
+ if (p.phase === 1) {
699
+ const m = md[p.attractorIdx];
700
+ const r = p.orbitRadius;
701
+
702
+ // Angular velocity from conserved angular momentum
703
+ const rawOmega = p.angularMomentum / (r * r);
704
+ const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA);
705
+
706
+ // Advance angle with precession
707
+ p.angle += omega + PRECESSION_RATE * Math.sign(omega);
708
+
709
+ // ── Capture blending: smooth cartesianβ†’polar transition ──
710
+ // During blend, the particle still has residual radial velocity
711
+ // from its free-fall trajectory. Decay it over CAPTURE_BLEND_FRAMES.
712
+ if (p.blendTimer > 0) {
713
+ p.blendTimer--;
714
+ // Radial velocity decays smoothly toward zero
715
+ p.radialVel *= 0.88;
716
+ // Apply residual radial motion to orbit radius
717
+ p.orbitRadius += p.radialVel;
718
+ } else {
719
+ // Very slow inward drift (ring thickness, not spiral to core)
720
+ p.orbitRadius -= r * p.spiralFriction;
721
+ }
722
+
723
+ // Position from polar coordinates
724
+ p.x = m.x + p.orbitRadius * Math.cos(p.angle);
725
+ p.y = m.y + p.orbitRadius * Math.sin(p.angle);
726
+
727
+ // Gravitational perturbation from neighboring models
728
+ const neighbors = modelNeighbors[p.attractorIdx];
729
+ for (let ni = 0; ni < neighbors.length; ni++) {
730
+ const k = neighbors[ni];
731
+ const mk = md[k];
732
+ const dx = mk.x - p.x;
733
+ const dy = mk.y - p.y;
734
+ const distSq = dx * dx + dy * dy;
735
+ const dist = Math.sqrt(distSq);
736
+ if (dist < 2) continue;
737
+
738
+ const pertForce = mk.massNorm * G * 0.3 / distSq;
739
+
740
+ const cr = p.orbitRadius || 1;
741
+ const radX = (p.x - m.x) / cr;
742
+ const radY = (p.y - m.y) / cr;
743
+ const forceX = (dx / dist) * pertForce;
744
+ const forceY = (dy / dist) * pertForce;
745
+ const tangForce = -forceX * radY + forceY * radX;
746
+
747
+ p.angularMomentum += tangForce * cr * 0.5;
748
+ }
749
+
750
+ // Ejection check
751
+ if (p.orbitRadius > modelCaptureRadius[p.attractorIdx]) {
752
+ const rawEOmega = p.angularMomentum / (p.orbitRadius * p.orbitRadius);
753
+ const eOmega = Math.sign(rawEOmega) * Math.min(Math.abs(rawEOmega), MAX_OMEGA);
754
+ p.vx = -Math.sin(p.angle) * eOmega * p.orbitRadius;
755
+ p.vy = Math.cos(p.angle) * eOmega * p.orbitRadius;
756
+ p.phase = 0;
757
+ p.attractorIdx = -1;
758
+ return;
759
+ }
760
+
761
+ // Timer: transition to Phase 3 (fade at ring) or Phase 4 (absorption spiral)
762
+ p.orbitTimer++;
763
+ if (p.orbitTimer >= p.orbitDuration) {
764
+ const absorbChance = ABSORB_CHANCE_BASE + ABSORB_CHANCE_MASS * md[p.attractorIdx].massNorm;
765
+ if (Math.random() < absorbChance) {
766
+ // Phase 4: begin absorption β€” continuous from current orbital state
767
+ p.phase = 4;
768
+ p.radialVel = 0; // will build naturally from inward force
769
+ } else {
770
+ p.phase = 3;
771
+ }
772
+ }
773
+ return;
774
+ }
775
+
776
+ // Phase 3: Fade at Ring (particles stay near outer edge)
777
+ if (p.phase === 3) {
778
+ const m = md[p.attractorIdx];
779
+
780
+ // Continue orbiting at current radius (no inward spiral)
781
+ const rawOmega = p.angularMomentum / (p.orbitRadius * p.orbitRadius);
782
+ const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA);
783
+ p.angle += omega + PRECESSION_RATE * Math.sign(omega);
784
+
785
+ p.x = m.x + p.orbitRadius * Math.cos(p.angle);
786
+ p.y = m.y + p.orbitRadius * Math.sin(p.angle);
787
+
788
+ // Mass-dependent fade: lighter models shed particles faster
789
+ const fadeRate = CONSUMPTION_SIZE_RATE * (1 + 0.5 * (1 - m.massNorm));
790
+ p.size -= fadeRate;
791
+
792
+ if (p.size <= 0) {
793
+ spawnAtEdge(p);
794
+ }
795
+ return;
796
+ }
797
+
798
+ // Phase 4: Absorption Spiral (inward to core β€” black hole consumption)
799
+ // Continuous from Phase 1: same orbital physics but with energy drain
800
+ {
801
+ const m = md[p.attractorIdx];
802
+
803
+ // Drain angular momentum β€” the particle loses orbital energy
804
+ p.angularMomentum *= (1 - ABSORB_RADIAL_DRAIN);
805
+
806
+ // Radial inward velocity builds up from gravitational pull
807
+ p.radialVel -= ABSORB_INWARD_FORCE;
808
+ p.radialVel *= 0.96; // friction prevents runaway plunge
809
+ // Apply radial motion β€” orbit radius shrinks naturally
810
+ p.orbitRadius += p.radialVel;
811
+
812
+ // Clamp minimum radius
813
+ if (p.orbitRadius < 1) p.orbitRadius = 1;
814
+
815
+ // Angular velocity from (draining) angular momentum β€” higher cap for absorption spiral
816
+ const rawOmega = p.angularMomentum / (p.orbitRadius * p.orbitRadius);
817
+ const omega = Math.sign(rawOmega) * Math.min(Math.abs(rawOmega), MAX_OMEGA_ABSORB);
818
+ p.angle += omega + PRECESSION_RATE * Math.sign(omega);
819
+
820
+ // Position from polar coordinates (continuous from Phase 1)
821
+ p.x = m.x + p.orbitRadius * Math.cos(p.angle);
822
+ p.y = m.y + p.orbitRadius * Math.sin(p.angle);
823
+
824
+ // Size stays full until near core, then shrinks rapidly
825
+ if (p.orbitRadius < ABSORB_MIN_RADIUS * 4) {
826
+ p.size -= 0.05;
827
+ }
828
+
829
+ // Die when reaching core or size gone
830
+ if (p.orbitRadius < ABSORB_MIN_RADIUS || p.size <= 0) {
831
+ spawnAtEdge(p);
832
+ }
833
+ }
834
+ }
835
+
836
+ // ── Pre-Simulation ─────────────────────────────────────────
837
+ for (let pre = 0; pre < PRE_SIM_FRAMES; pre++) {
838
+ modelRotBias.fill(0);
839
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
840
+ const p = particles[i];
841
+ if ((p.phase === 1 || p.phase === 4) && p.attractorIdx >= 0) {
842
+ modelRotBias[p.attractorIdx] += Math.sign(p.angularMomentum);
843
+ }
844
+ }
845
+ for (let j = 0; j < MODEL_COUNT; j++) {
846
+ modelRotBias[j] = Math.sign(modelRotBias[j]);
847
+ }
848
+
849
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
850
+ if (pre >= particles[i]._spawnFrame) {
851
+ updateParticle(particles[i], canvas.width, canvas.height);
852
+ }
853
+ }
854
+ }
855
+
856
+ // Lock each model's rotation direction based on pre-sim consensus
857
+ for (let j = 0; j < MODEL_COUNT; j++) {
858
+ modelRotDir[j] = modelRotBias[j] !== 0 ? modelRotBias[j] : (Math.random() < 0.5 ? 1 : -1);
859
+ }
860
+
861
+ // ── Task Icons (filled, monochrome, with BG pill) ───────────
862
+ const ICON_SIZE = 11.5; // icon half-size (+15%)
863
+ const ICON_PAD = 7; // padding around icon for BG pill (+15%)
864
+ const ICON_CORNER = 6; // rounded corner radius for BG pill (+15%)
865
+
866
+ function drawTaskIcon(ctx, x, y, task) {
867
+ const s = ICON_SIZE;
868
+ const pad = ICON_PAD;
869
+
870
+ // Draw BG pill (same color as screen background) to mask particles behind icon
871
+ ctx.save();
872
+ ctx.fillStyle = BG;
873
+ const pillW = (s + pad) * 2;
874
+ const pillH = (s + pad) * 2;
875
+ const px = x - s - pad;
876
+ const py = y - s - pad;
877
+ ctx.beginPath();
878
+ ctx.roundRect(px, py, pillW, pillH, ICON_CORNER);
879
+ ctx.fill();
880
+ ctx.restore();
881
+
882
+ // Draw filled icon
883
+ ctx.save();
884
+ ctx.translate(x, y);
885
+ ctx.fillStyle = COLOR;
886
+
887
+ switch (task) {
888
+ case 'sentence-similarity':
889
+ // Filled chat bubble
890
+ ctx.beginPath();
891
+ ctx.moveTo(-s * 0.8, -s * 0.55);
892
+ ctx.quadraticCurveTo(-s * 0.8, -s * 0.85, -s * 0.4, -s * 0.85);
893
+ ctx.lineTo(s * 0.4, -s * 0.85);
894
+ ctx.quadraticCurveTo(s * 0.8, -s * 0.85, s * 0.8, -s * 0.55);
895
+ ctx.lineTo(s * 0.8, -s * 0.05);
896
+ ctx.quadraticCurveTo(s * 0.8, s * 0.25, s * 0.4, s * 0.25);
897
+ ctx.lineTo(-s * 0.1, s * 0.25);
898
+ ctx.lineTo(-s * 0.45, s * 0.65);
899
+ ctx.lineTo(-s * 0.35, s * 0.25);
900
+ ctx.lineTo(-s * 0.4, s * 0.25);
901
+ ctx.quadraticCurveTo(-s * 0.8, s * 0.25, -s * 0.8, -s * 0.05);
902
+ ctx.closePath();
903
+ ctx.fill();
904
+ // Three dots inside
905
+ ctx.fillStyle = BG;
906
+ ctx.beginPath();
907
+ ctx.arc(-s * 0.3, -s * 0.3, s * 0.1, 0, Math.PI * 2);
908
+ ctx.arc(0, -s * 0.3, s * 0.1, 0, Math.PI * 2);
909
+ ctx.arc(s * 0.3, -s * 0.3, s * 0.1, 0, Math.PI * 2);
910
+ ctx.fill();
911
+ break;
912
+
913
+ case 'fill-mask':
914
+ // Filled document with mask gap
915
+ ctx.beginPath();
916
+ ctx.roundRect(-s * 0.7, -s * 0.85, s * 1.4, s * 1.7, s * 0.15);
917
+ ctx.fill();
918
+ // Text lines (negative space)
919
+ ctx.fillStyle = BG;
920
+ ctx.fillRect(-s * 0.45, -s * 0.55, s * 0.9, s * 0.12);
921
+ ctx.fillRect(-s * 0.45, -s * 0.25, s * 0.3, s * 0.12);
922
+ // Mask gap highlight
923
+ ctx.fillRect(s * 0.0, -s * 0.25, s * 0.45, s * 0.12);
924
+ ctx.fillRect(-s * 0.45, s * 0.05, s * 0.65, s * 0.12);
925
+ ctx.fillRect(-s * 0.45, s * 0.35, s * 0.5, s * 0.12);
926
+ // Cursor blink in mask gap
927
+ ctx.fillStyle = COLOR;
928
+ ctx.fillRect(-s * 0.08, -s * 0.3, s * 0.04, s * 0.22);
929
+ break;
930
+
931
+ case 'image-classification':
932
+ // Filled picture frame
933
+ ctx.beginPath();
934
+ ctx.roundRect(-s * 0.85, -s * 0.75, s * 1.7, s * 1.5, s * 0.12);
935
+ ctx.fill();
936
+ // Mountain landscape (negative space)
937
+ ctx.fillStyle = BG;
938
+ // Sun
939
+ ctx.beginPath();
940
+ ctx.arc(s * 0.35, -s * 0.3, s * 0.2, 0, Math.PI * 2);
941
+ ctx.fill();
942
+ // Mountains
943
+ ctx.beginPath();
944
+ ctx.moveTo(-s * 0.7, s * 0.55);
945
+ ctx.lineTo(-s * 0.2, -s * 0.05);
946
+ ctx.lineTo(s * 0.05, s * 0.2);
947
+ ctx.lineTo(s * 0.3, 0);
948
+ ctx.lineTo(s * 0.7, s * 0.55);
949
+ ctx.closePath();
950
+ ctx.fill();
951
+ break;
952
+
953
+ case 'image-text-to-text':
954
+ // Filled eye (vision)
955
+ ctx.beginPath();
956
+ ctx.moveTo(-s * 0.9, 0);
957
+ ctx.quadraticCurveTo(-s * 0.45, -s * 0.7, 0, -s * 0.7);
958
+ ctx.quadraticCurveTo(s * 0.45, -s * 0.7, s * 0.9, 0);
959
+ ctx.quadraticCurveTo(s * 0.45, s * 0.7, 0, s * 0.7);
960
+ ctx.quadraticCurveTo(-s * 0.45, s * 0.7, -s * 0.9, 0);
961
+ ctx.fill();
962
+ // Pupil (negative space)
963
+ ctx.fillStyle = BG;
964
+ ctx.beginPath();
965
+ ctx.arc(0, 0, s * 0.32, 0, Math.PI * 2);
966
+ ctx.fill();
967
+ // Inner pupil
968
+ ctx.fillStyle = COLOR;
969
+ ctx.beginPath();
970
+ ctx.arc(0, 0, s * 0.16, 0, Math.PI * 2);
971
+ ctx.fill();
972
+ break;
973
+
974
+ case 'audio-classification':
975
+ // Filled speaker with sound waves
976
+ // Speaker body
977
+ ctx.beginPath();
978
+ ctx.moveTo(-s * 0.35, -s * 0.25);
979
+ ctx.lineTo(-s * 0.7, -s * 0.25);
980
+ ctx.lineTo(-s * 0.7, s * 0.25);
981
+ ctx.lineTo(-s * 0.35, s * 0.25);
982
+ ctx.lineTo(0, s * 0.6);
983
+ ctx.lineTo(0, -s * 0.6);
984
+ ctx.closePath();
985
+ ctx.fill();
986
+ // Sound arcs (thick filled arcs)
987
+ ctx.lineWidth = s * 0.15;
988
+ ctx.strokeStyle = COLOR;
989
+ ctx.lineCap = 'round';
990
+ ctx.beginPath();
991
+ ctx.arc(s * 0.1, 0, s * 0.32, -Math.PI * 0.32, Math.PI * 0.32);
992
+ ctx.stroke();
993
+ ctx.beginPath();
994
+ ctx.arc(s * 0.1, 0, s * 0.58, -Math.PI * 0.32, Math.PI * 0.32);
995
+ ctx.stroke();
996
+ break;
997
+
998
+ case 'text-generation':
999
+ // Filled pencil
1000
+ ctx.beginPath();
1001
+ ctx.moveTo(s * 0.55, -s * 0.85);
1002
+ ctx.lineTo(s * 0.85, -s * 0.55);
1003
+ ctx.lineTo(-s * 0.45, s * 0.45);
1004
+ ctx.lineTo(-s * 0.85, s * 0.55);
1005
+ ctx.lineTo(-s * 0.75, s * 0.85);
1006
+ ctx.lineTo(-s * 0.55, s * 0.45);
1007
+ ctx.closePath();
1008
+ ctx.fill();
1009
+ // Eraser line (negative space)
1010
+ ctx.strokeStyle = BG;
1011
+ ctx.lineWidth = s * 0.08;
1012
+ ctx.beginPath();
1013
+ ctx.moveTo(s * 0.32, -s * 0.52);
1014
+ ctx.lineTo(s * 0.52, -s * 0.32);
1015
+ ctx.stroke();
1016
+ break;
1017
+
1018
+ case 'text-classification':
1019
+ // Filled tag
1020
+ ctx.beginPath();
1021
+ ctx.moveTo(-s * 0.75, -s * 0.5);
1022
+ ctx.lineTo(s * 0.15, -s * 0.5);
1023
+ ctx.lineTo(s * 0.75, 0);
1024
+ ctx.lineTo(s * 0.15, s * 0.5);
1025
+ ctx.lineTo(-s * 0.75, s * 0.5);
1026
+ ctx.closePath();
1027
+ ctx.fill();
1028
+ // Hole (negative space)
1029
+ ctx.fillStyle = BG;
1030
+ ctx.beginPath();
1031
+ ctx.arc(s * 0.2, 0, s * 0.13, 0, Math.PI * 2);
1032
+ ctx.fill();
1033
+ break;
1034
+
1035
+ case 'feature-extraction':
1036
+ // Filled funnel
1037
+ ctx.beginPath();
1038
+ ctx.moveTo(-s * 0.75, -s * 0.7);
1039
+ ctx.lineTo(s * 0.75, -s * 0.7);
1040
+ ctx.lineTo(s * 0.15, s * 0.1);
1041
+ ctx.lineTo(s * 0.15, s * 0.7);
1042
+ ctx.lineTo(-s * 0.15, s * 0.7);
1043
+ ctx.lineTo(-s * 0.15, s * 0.1);
1044
+ ctx.closePath();
1045
+ ctx.fill();
1046
+ break;
1047
+
1048
+ case 'token-classification':
1049
+ // Filled brackets with highlight
1050
+ ctx.beginPath();
1051
+ ctx.roundRect(-s * 0.8, -s * 0.6, s * 1.6, s * 1.2, s * 0.12);
1052
+ ctx.fill();
1053
+ ctx.fillStyle = BG;
1054
+ ctx.fillRect(-s * 0.55, -s * 0.3, s * 0.35, s * 0.15);
1055
+ ctx.fillRect(-s * 0.55, s * 0.0, s * 0.6, s * 0.15);
1056
+ ctx.fillRect(-s * 0.55, s * 0.3, s * 0.45, s * 0.15);
1057
+ break;
1058
+
1059
+ case 'question-answering':
1060
+ // Filled question mark bubble
1061
+ ctx.beginPath();
1062
+ ctx.arc(0, -s * 0.1, s * 0.7, 0, Math.PI * 2);
1063
+ ctx.fill();
1064
+ ctx.beginPath();
1065
+ ctx.moveTo(-s * 0.15, s * 0.5);
1066
+ ctx.lineTo(0, s * 0.85);
1067
+ ctx.lineTo(s * 0.15, s * 0.5);
1068
+ ctx.fill();
1069
+ ctx.fillStyle = BG;
1070
+ ctx.font = 'bold ' + (s * 1.0) + 'px sans-serif';
1071
+ ctx.textAlign = 'center';
1072
+ ctx.textBaseline = 'middle';
1073
+ ctx.fillText('?', 0, -s * 0.12);
1074
+ break;
1075
+
1076
+ case 'text-to-speech':
1077
+ // Filled waveform / speaker with sound
1078
+ // Mouth/megaphone shape
1079
+ ctx.beginPath();
1080
+ ctx.moveTo(-s * 0.5, -s * 0.2);
1081
+ ctx.lineTo(-s * 0.75, -s * 0.2);
1082
+ ctx.lineTo(-s * 0.75, s * 0.2);
1083
+ ctx.lineTo(-s * 0.5, s * 0.2);
1084
+ ctx.lineTo(-s * 0.15, s * 0.55);
1085
+ ctx.lineTo(-s * 0.15, -s * 0.55);
1086
+ ctx.closePath();
1087
+ ctx.fill();
1088
+ // Sound waves (three arcs)
1089
+ ctx.strokeStyle = COLOR;
1090
+ ctx.lineCap = 'round';
1091
+ ctx.lineWidth = s * 0.12;
1092
+ ctx.beginPath();
1093
+ ctx.arc(-s * 0.05, 0, s * 0.3, -Math.PI * 0.35, Math.PI * 0.35);
1094
+ ctx.stroke();
1095
+ ctx.beginPath();
1096
+ ctx.arc(-s * 0.05, 0, s * 0.52, -Math.PI * 0.35, Math.PI * 0.35);
1097
+ ctx.stroke();
1098
+ ctx.beginPath();
1099
+ ctx.arc(-s * 0.05, 0, s * 0.74, -Math.PI * 0.35, Math.PI * 0.35);
1100
+ ctx.stroke();
1101
+ break;
1102
+
1103
+ case 'audio-to-audio':
1104
+ // Two waveforms with arrow between
1105
+ // Left waveform
1106
+ ctx.lineWidth = s * 0.12;
1107
+ ctx.strokeStyle = COLOR;
1108
+ ctx.lineCap = 'round';
1109
+ ctx.lineJoin = 'round';
1110
+ ctx.beginPath();
1111
+ ctx.moveTo(-s * 0.85, s * 0.1);
1112
+ ctx.lineTo(-s * 0.7, -s * 0.4);
1113
+ ctx.lineTo(-s * 0.55, s * 0.5);
1114
+ ctx.lineTo(-s * 0.4, -s * 0.2);
1115
+ ctx.lineTo(-s * 0.25, s * 0.1);
1116
+ ctx.stroke();
1117
+ // Arrow
1118
+ ctx.beginPath();
1119
+ ctx.moveTo(-s * 0.1, 0);
1120
+ ctx.lineTo(s * 0.25, 0);
1121
+ ctx.stroke();
1122
+ ctx.beginPath();
1123
+ ctx.moveTo(s * 0.15, -s * 0.15);
1124
+ ctx.lineTo(s * 0.25, 0);
1125
+ ctx.lineTo(s * 0.15, s * 0.15);
1126
+ ctx.fill();
1127
+ // Right waveform
1128
+ ctx.beginPath();
1129
+ ctx.moveTo(s * 0.35, -s * 0.1);
1130
+ ctx.lineTo(s * 0.5, s * 0.35);
1131
+ ctx.lineTo(s * 0.65, -s * 0.5);
1132
+ ctx.lineTo(s * 0.8, s * 0.2);
1133
+ ctx.stroke();
1134
+ break;
1135
+
1136
+ case 'image-to-image':
1137
+ // Two picture frames with arrow
1138
+ // Left frame
1139
+ ctx.beginPath();
1140
+ ctx.roundRect(-s * 0.9, -s * 0.5, s * 0.7, s * 0.7, s * 0.08);
1141
+ ctx.fill();
1142
+ ctx.fillStyle = BG;
1143
+ // Mini mountain in left frame
1144
+ ctx.beginPath();
1145
+ ctx.moveTo(-s * 0.82, s * 0.12);
1146
+ ctx.lineTo(-s * 0.6, -s * 0.15);
1147
+ ctx.lineTo(-s * 0.3, s * 0.12);
1148
+ ctx.closePath();
1149
+ ctx.fill();
1150
+ // Arrow
1151
+ ctx.fillStyle = COLOR;
1152
+ ctx.fillRect(-s * 0.1, -s * 0.06, s * 0.3, s * 0.12);
1153
+ ctx.beginPath();
1154
+ ctx.moveTo(s * 0.15, -s * 0.2);
1155
+ ctx.lineTo(s * 0.3, 0);
1156
+ ctx.lineTo(s * 0.15, s * 0.2);
1157
+ ctx.fill();
1158
+ // Right frame
1159
+ ctx.beginPath();
1160
+ ctx.roundRect(s * 0.25, -s * 0.5, s * 0.7, s * 0.7, s * 0.08);
1161
+ ctx.fill();
1162
+ ctx.fillStyle = BG;
1163
+ // Sparkle in right frame
1164
+ ctx.beginPath();
1165
+ ctx.moveTo(s * 0.6, -s * 0.25);
1166
+ ctx.lineTo(s * 0.63, -s * 0.1);
1167
+ ctx.lineTo(s * 0.75, -s * 0.07);
1168
+ ctx.lineTo(s * 0.63, -s * 0.04);
1169
+ ctx.lineTo(s * 0.6, s * 0.1);
1170
+ ctx.lineTo(s * 0.57, -s * 0.04);
1171
+ ctx.lineTo(s * 0.45, -s * 0.07);
1172
+ ctx.lineTo(s * 0.57, -s * 0.1);
1173
+ ctx.closePath();
1174
+ ctx.fill();
1175
+ break;
1176
+
1177
+ case 'image-to-video':
1178
+ // Film frame / clapperboard
1179
+ ctx.beginPath();
1180
+ ctx.roundRect(-s * 0.8, -s * 0.65, s * 1.6, s * 1.3, s * 0.12);
1181
+ ctx.fill();
1182
+ // Film strip holes (negative space)
1183
+ ctx.fillStyle = BG;
1184
+ ctx.fillRect(-s * 0.65, -s * 0.5, s * 0.12, s * 0.15);
1185
+ ctx.fillRect(-s * 0.65, s * 0.2, s * 0.12, s * 0.15);
1186
+ ctx.fillRect(s * 0.53, -s * 0.5, s * 0.12, s * 0.15);
1187
+ ctx.fillRect(s * 0.53, s * 0.2, s * 0.12, s * 0.15);
1188
+ // Play triangle (negative space)
1189
+ ctx.beginPath();
1190
+ ctx.moveTo(-s * 0.2, -s * 0.3);
1191
+ ctx.lineTo(s * 0.3, 0);
1192
+ ctx.lineTo(-s * 0.2, s * 0.3);
1193
+ ctx.closePath();
1194
+ ctx.fill();
1195
+ break;
1196
+
1197
+ case 'image-to-text':
1198
+ // Image frame + text lines (OCR)
1199
+ // Frame
1200
+ ctx.beginPath();
1201
+ ctx.roundRect(-s * 0.85, -s * 0.65, s * 0.9, s * 0.9, s * 0.08);
1202
+ ctx.fill();
1203
+ ctx.fillStyle = BG;
1204
+ // Mountain in frame
1205
+ ctx.beginPath();
1206
+ ctx.moveTo(-s * 0.75, s * 0.1);
1207
+ ctx.lineTo(-s * 0.45, -s * 0.2);
1208
+ ctx.lineTo(-s * 0.1, s * 0.1);
1209
+ ctx.closePath();
1210
+ ctx.fill();
1211
+ // Text lines
1212
+ ctx.fillStyle = COLOR;
1213
+ ctx.fillRect(s * 0.15, -s * 0.5, s * 0.65, s * 0.12);
1214
+ ctx.fillRect(s * 0.15, -s * 0.2, s * 0.5, s * 0.12);
1215
+ ctx.fillRect(s * 0.15, s * 0.1, s * 0.6, s * 0.12);
1216
+ break;
1217
+
1218
+ case 'text-to-image':
1219
+ // Text lines + sparkle/brush (generation)
1220
+ // Text lines on left
1221
+ ctx.fillRect(-s * 0.85, -s * 0.5, s * 0.6, s * 0.12);
1222
+ ctx.fillRect(-s * 0.85, -s * 0.2, s * 0.45, s * 0.12);
1223
+ ctx.fillRect(-s * 0.85, s * 0.1, s * 0.55, s * 0.12);
1224
+ // Arrow
1225
+ ctx.fillRect(-s * 0.15, -s * 0.06, s * 0.25, s * 0.12);
1226
+ ctx.beginPath();
1227
+ ctx.moveTo(s * 0.05, -s * 0.2);
1228
+ ctx.lineTo(s * 0.2, 0);
1229
+ ctx.lineTo(s * 0.05, s * 0.2);
1230
+ ctx.fill();
1231
+ // Star / sparkle on right
1232
+ ctx.beginPath();
1233
+ ctx.moveTo(s * 0.55, -s * 0.55);
1234
+ ctx.lineTo(s * 0.6, -s * 0.2);
1235
+ ctx.lineTo(s * 0.85, -s * 0.1);
1236
+ ctx.lineTo(s * 0.6, 0);
1237
+ ctx.lineTo(s * 0.55, s * 0.35);
1238
+ ctx.lineTo(s * 0.5, 0);
1239
+ ctx.lineTo(s * 0.25, -s * 0.1);
1240
+ ctx.lineTo(s * 0.5, -s * 0.2);
1241
+ ctx.closePath();
1242
+ ctx.fill();
1243
+ break;
1244
+
1245
+ case 'any-to-any': {
1246
+ // Four arrows pointing outward from center (versatility)
1247
+ ctx.beginPath();
1248
+ ctx.arc(0, 0, s * 0.25, 0, Math.PI * 2);
1249
+ ctx.fill();
1250
+ // Four directional arrows
1251
+ const dirs = [[0, -1], [1, 0], [0, 1], [-1, 0]];
1252
+ for (const [adx, ady] of dirs) {
1253
+ // Arrow shaft
1254
+ ctx.fillRect(
1255
+ (adx === 0 ? -s * 0.06 : adx > 0 ? s * 0.3 : -s * 0.3 - s * 0.35),
1256
+ (ady === 0 ? -s * 0.06 : ady > 0 ? s * 0.3 : -s * 0.3 - s * 0.35),
1257
+ adx === 0 ? s * 0.12 : s * 0.35,
1258
+ ady === 0 ? s * 0.12 : s * 0.35
1259
+ );
1260
+ // Arrow head
1261
+ ctx.beginPath();
1262
+ ctx.moveTo(adx * s * 0.5 - ady * s * 0.18, ady * s * 0.5 - (-adx) * s * 0.18);
1263
+ ctx.lineTo(adx * s * 0.8, ady * s * 0.8);
1264
+ ctx.lineTo(adx * s * 0.5 + ady * s * 0.18, ady * s * 0.5 + (-adx) * s * 0.18);
1265
+ ctx.fill();
1266
+ }
1267
+ break;
1268
+ }
1269
+
1270
+ case 'automatic-speech-recognition':
1271
+ // Microphone icon
1272
+ // Mic body
1273
+ ctx.beginPath();
1274
+ ctx.roundRect(-s * 0.25, -s * 0.7, s * 0.5, s * 0.8, s * 0.25);
1275
+ ctx.fill();
1276
+ // Mic stand arc
1277
+ ctx.strokeStyle = COLOR;
1278
+ ctx.lineWidth = s * 0.12;
1279
+ ctx.lineCap = 'round';
1280
+ ctx.beginPath();
1281
+ ctx.arc(0, -s * 0.1, s * 0.45, 0, Math.PI);
1282
+ ctx.stroke();
1283
+ // Stand
1284
+ ctx.beginPath();
1285
+ ctx.moveTo(0, s * 0.35);
1286
+ ctx.lineTo(0, s * 0.65);
1287
+ ctx.stroke();
1288
+ ctx.beginPath();
1289
+ ctx.moveTo(-s * 0.25, s * 0.65);
1290
+ ctx.lineTo(s * 0.25, s * 0.65);
1291
+ ctx.stroke();
1292
+ break;
1293
+
1294
+ default: {
1295
+ // Fallback: filled gear
1296
+ ctx.beginPath();
1297
+ const teeth = 6;
1298
+ const outerR = s * 0.75;
1299
+ const innerR = s * 0.5;
1300
+ for (let i = 0; i < teeth; i++) {
1301
+ const a1 = (i / teeth) * Math.PI * 2;
1302
+ const a2 = ((i + 0.3) / teeth) * Math.PI * 2;
1303
+ const a3 = ((i + 0.5) / teeth) * Math.PI * 2;
1304
+ const a4 = ((i + 0.8) / teeth) * Math.PI * 2;
1305
+ if (i === 0) ctx.moveTo(Math.cos(a1) * outerR, Math.sin(a1) * outerR);
1306
+ else ctx.lineTo(Math.cos(a1) * outerR, Math.sin(a1) * outerR);
1307
+ ctx.lineTo(Math.cos(a2) * outerR, Math.sin(a2) * outerR);
1308
+ ctx.lineTo(Math.cos(a3) * innerR, Math.sin(a3) * innerR);
1309
+ ctx.lineTo(Math.cos(a4) * innerR, Math.sin(a4) * innerR);
1310
+ }
1311
+ ctx.closePath();
1312
+ ctx.fill();
1313
+ // Center hole (negative space)
1314
+ ctx.fillStyle = BG;
1315
+ ctx.beginPath();
1316
+ ctx.arc(0, 0, s * 0.2, 0, Math.PI * 2);
1317
+ ctx.fill();
1318
+ break;
1319
+ }
1320
+ }
1321
+
1322
+ ctx.restore();
1323
+ }
1324
+
1325
+ // ── Render Loop ────────────────────────────────────────────
1326
+ const PS2 = PARTICLE_SIZE * 2;
1327
+
1328
+ function frame() {
1329
+ const w = canvas.width;
1330
+ const h = canvas.height;
1331
+
1332
+ // Clear
1333
+ ctx.fillStyle = BG;
1334
+ ctx.fillRect(0, 0, w, h);
1335
+
1336
+ // Compute per-model rotation bias
1337
+ modelRotBias.fill(0);
1338
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
1339
+ const p = particles[i];
1340
+ if ((p.phase === 1 || p.phase === 4) && p.attractorIdx >= 0) {
1341
+ modelRotBias[p.attractorIdx] += Math.sign(p.angularMomentum);
1342
+ }
1343
+ }
1344
+ for (let j = 0; j < MODEL_COUNT; j++) {
1345
+ modelRotBias[j] = Math.sign(modelRotBias[j]);
1346
+ }
1347
+
1348
+ // Rebalance spawn weights every 60 frames (~1 second)
1349
+ rebalanceTimer++;
1350
+ if (rebalanceTimer >= 60) {
1351
+ rebalanceTimer = 0;
1352
+ modelOrbitCount.fill(0);
1353
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
1354
+ const p = particles[i];
1355
+ if (p.attractorIdx >= 0 && (p.phase === 1 || p.phase === 3 || p.phase === 4)) {
1356
+ modelOrbitCount[p.attractorIdx]++;
1357
+ }
1358
+ }
1359
+ let tw = 0;
1360
+ for (let j = 0; j < MODEL_COUNT; j++) {
1361
+ const deficit = modelTargetPop[j] - modelOrbitCount[j];
1362
+ spawnWeight[j] = Math.max(0.01, massNorm[j] + 0.3 * (deficit / totalTarget));
1363
+ tw += spawnWeight[j];
1364
+ }
1365
+ totalSpawnWeight = tw;
1366
+ }
1367
+
1368
+ // Animate label bounce zone
1369
+ if (hoveredModel >= 0) {
1370
+ labelBounceAlpha = Math.min(1, labelBounceAlpha + 0.08);
1371
+ } else {
1372
+ labelBounceAlpha = Math.max(0, labelBounceAlpha - 0.06);
1373
+ if (labelBounceAlpha <= 0) labelBounceRect = null;
1374
+ }
1375
+
1376
+ // Update elastic pull (before particles read md[j].x/y)
1377
+ updateElasticPull();
1378
+
1379
+ // Update all particles
1380
+ const doDeflect = labelBounceAlpha > 0;
1381
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
1382
+ updateParticle(particles[i], w, h);
1383
+ if (doDeflect) deflectFromLabel(particles[i]);
1384
+ }
1385
+
1386
+ // ── Draw ──
1387
+ ctx.fillStyle = COLOR;
1388
+ ctx.strokeStyle = COLOR;
1389
+ ctx.lineWidth = 1;
1390
+
1391
+ // Batch velocity streaks into one path
1392
+ ctx.beginPath();
1393
+ let hasStreaks = false;
1394
+
1395
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
1396
+ const p = particles[i];
1397
+ if (p.size <= 0) continue;
1398
+
1399
+ // Velocity streaking for fast free-fall particles
1400
+ if (p.phase === 0) {
1401
+ const speedSq = p.vx * p.vx + p.vy * p.vy;
1402
+ if (speedSq > 9) {
1403
+ const s = Math.sqrt(speedSq);
1404
+ const len = Math.min(s * 1.5, 6);
1405
+ ctx.moveTo(p.x, p.y);
1406
+ ctx.lineTo(p.x - (p.vx / s) * len, p.y - (p.vy / s) * len);
1407
+ hasStreaks = true;
1408
+ continue;
1409
+ }
1410
+ }
1411
+
1412
+ // Shrinking particles during fade (Phase 3) or final absorption (Phase 4 near core)
1413
+ if ((p.phase === 3 || p.phase === 4) && p.size < PARTICLE_SIZE) {
1414
+ const s2 = p.size * 2;
1415
+ ctx.fillRect(p.x - p.size, p.y - p.size, s2, s2);
1416
+ } else {
1417
+ ctx.fillRect(p.x - PARTICLE_SIZE, p.y - PARTICLE_SIZE, PS2, PS2);
1418
+ }
1419
+ }
1420
+
1421
+ if (hasStreaks) ctx.stroke();
1422
+
1423
+ // Draw model cores last β€” task icons
1424
+ for (let j = 0; j < MODEL_COUNT; j++) {
1425
+ drawTaskIcon(ctx, md[j].x, md[j].y, models[j].task);
1426
+ }
1427
+
1428
+ requestAnimationFrame(frame);
1429
+ }
1430
+
1431
+ requestAnimationFrame(frame);
1432
+
1433
+ // ── Hover Tooltip ──────────────────────────────────────────
1434
+ function fmtTask(tag) {
1435
+ const names = {
1436
+ 'sentence-similarity': 'Sentence Similarity',
1437
+ 'fill-mask': 'Fill-Mask',
1438
+ 'image-classification': 'Image Classification',
1439
+ 'image-text-to-text': 'Image-Text-to-Text',
1440
+ 'audio-classification': 'Audio Classification',
1441
+ 'text-generation': 'Text Generation',
1442
+ 'text-classification': 'Text Classification',
1443
+ 'feature-extraction': 'Feature Extraction',
1444
+ 'token-classification': 'Token Classification',
1445
+ 'question-answering': 'Question Answering',
1446
+ 'text2text-generation': 'Text-to-Text',
1447
+ 'automatic-speech-recognition': 'Speech Recognition',
1448
+ 'translation': 'Translation',
1449
+ 'summarization': 'Summarization',
1450
+ 'object-detection': 'Object Detection',
1451
+ 'zero-shot-classification': 'Zero-Shot Classification',
1452
+ 'text-to-speech': 'Text-to-Speech',
1453
+ 'audio-to-audio': 'Audio-to-Audio',
1454
+ 'image-to-image': 'Image-to-Image',
1455
+ 'image-to-video': 'Image-to-Video',
1456
+ 'image-to-text': 'Image-to-Text',
1457
+ 'text-to-image': 'Text-to-Image',
1458
+ 'any-to-any': 'Any-to-Any',
1459
+ };
1460
+ return names[tag] || tag.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
1461
+ }
1462
+
1463
+ function fmtDownloads(n) {
1464
+ if (n >= 1e9) return (n / 1e9).toFixed(1) + 'B';
1465
+ if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M';
1466
+ if (n >= 1e3) return (n / 1e3).toFixed(1) + 'K';
1467
+ return n.toString();
1468
+ }
1469
+
1470
+ const tooltip = document.getElementById('tooltip');
1471
+ let hoveredModel = -1;
1472
+ let labelBounceRect = null; // {cx, cy, hw, hh} β€” center + half-dims of bounce pill
1473
+ let labelBounceAlpha = 0; // 0..1 animation progress (0=off, 1=full bounce)
1474
+
1475
+ canvas.addEventListener('mousemove', (e) => {
1476
+ // Global cursor state for elastic pull
1477
+ cursorX = e.clientX;
1478
+ cursorY = e.clientY;
1479
+ cursorOnCanvas = true;
1480
+
1481
+ const mx = e.clientX;
1482
+ const my = e.clientY;
1483
+ let found = -1;
1484
+
1485
+ for (let j = 0; j < MODEL_COUNT; j++) {
1486
+ const dx = mx - md[j].x;
1487
+ const dy = my - md[j].y;
1488
+ if (dx * dx + dy * dy <= md[j].captureRadius * md[j].captureRadius) {
1489
+ found = j;
1490
+ break;
1491
+ }
1492
+ }
1493
+
1494
+ if (found !== hoveredModel) {
1495
+ hoveredModel = found;
1496
+ if (found >= 0) {
1497
+ const m = models[found];
1498
+ const mpos = md[found];
1499
+ const url = 'https://huggingface.co/' + m.id;
1500
+ const shortName = m.id.includes('/') ? m.id.split('/')[1] : m.id;
1501
+ const authorUrl = 'https://huggingface.co/' + m.author;
1502
+ const avatarHtml = m.avatarUrl
1503
+ ? '<img src="' + m.avatarUrl + '" width="16" height="16" style="border-radius:3px;vertical-align:-2px;margin-right:4px">'
1504
+ : '';
1505
+ const authorLine = m.author
1506
+ ? avatarHtml + '<a href="' + authorUrl + '" target="_blank" style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.55;color:#161513;text-decoration:none">'
1507
+ + m.author + '</a><span style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.35;margin:0 2px">/</span>'
1508
+ : '';
1509
+ tooltip.innerHTML = authorLine + '<a href="' + url + '" target="_blank">'
1510
+ + shortName + '</a><br><span style="font-family:Inter,sans-serif;font-weight:400;font-size:13px;opacity:0.7">' + fmtTask(m.task) + '</span><br><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#161513" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:3px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>' + fmtDownloads(m.downloads);
1511
+ tooltip.classList.add('visible');
1512
+
1513
+ // Position tooltip so we can measure it
1514
+ tooltip.style.left = mpos.x + 'px';
1515
+ tooltip.style.top = (mpos.y + CORE_RADIUS + 12) + 'px';
1516
+ tooltip.style.transform = 'translateX(-50%)';
1517
+
1518
+ // Compute bounce zone from tooltip dimensions
1519
+ const rect = tooltip.getBoundingClientRect();
1520
+ const pad = 14; // breathing room around text
1521
+ labelBounceRect = {
1522
+ cx: mpos.x,
1523
+ cy: mpos.y + CORE_RADIUS + 12 + rect.height / 2,
1524
+ hw: rect.width / 2 + pad,
1525
+ hh: rect.height / 2 + pad + 8, // extra top padding to cover core-tooltip gap
1526
+ };
1527
+ } else {
1528
+ tooltip.classList.remove('visible');
1529
+ // labelBounceRect stays set β€” will animate out via labelBounceAlpha
1530
+ }
1531
+ }
1532
+
1533
+ if (hoveredModel >= 0) {
1534
+ const model = md[hoveredModel];
1535
+ tooltip.style.left = model.x + 'px';
1536
+ tooltip.style.top = (model.y + CORE_RADIUS + 12) + 'px';
1537
+ tooltip.style.transform = 'translateX(-50%)';
1538
+ }
1539
+
1540
+ canvas.style.cursor = hoveredModel >= 0 ? 'pointer' : 'default';
1541
+ });
1542
+
1543
+ canvas.addEventListener('click', (e) => {
1544
+ const mx = e.clientX;
1545
+ const my = e.clientY;
1546
+ for (let j = 0; j < MODEL_COUNT; j++) {
1547
+ const dx = mx - md[j].x;
1548
+ const dy = my - md[j].y;
1549
+ if (dx * dx + dy * dy <= md[j].captureRadius * md[j].captureRadius) {
1550
+ window.open('https://huggingface.co/' + models[j].id, '_blank');
1551
+ break;
1552
+ }
1553
+ }
1554
+ });
1555
+
1556
+ canvas.addEventListener('mouseleave', () => {
1557
+ hoveredModel = -1;
1558
+ tooltip.classList.remove('visible');
1559
+ canvas.style.cursor = 'default';
1560
+ cursorOnCanvas = false; // triggers spring snapback
1561
+ });
1562
+
1563
+ })();
1564
+ </script>
1565
+ </body>
1566
  </html>