@bongos/core 1.19.92 → 1.19.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1848 +0,0 @@
1
- /* ═══════════════════════════════════════════════════════════════════════
2
- Cloud Bongos — "space" scene controller (tasks 1721, 1722)
3
-
4
- The landing flow, top to bottom:
5
- ENTRY — every visit lands on the "type bongos" terminal, set in space.
6
- SPACE — typing bongos reveals the pannable sky of project circles
7
- (size = builders, drifting around anchors). Hover for details;
8
- a SHIFT tap searches what's in view; click empty sky to add one.
9
- DOWNLOAD— clicking the Cloud Bongos orb zooms straight into its download
10
- page (search + tiles, over the temple world) — no gate between.
11
- DETAIL — clicking any other orb opens a themed "story coming soon" page.
12
- ESC — rises from download/detail back to the circles.
13
-
14
- No framework, no build step. Project data is INLINED (Caddy 404s *.json on
15
- staging); swap in a registry by changing PROJECTS + loadProjects(). The
16
- download scene + its drag physics live in the inline script in index.html;
17
- this file talks to it via cb:show-download / cb:hide-download events.
18
- ═══════════════════════════════════════════════════════════════════════ */
19
- (function () {
20
- 'use strict';
21
-
22
- /* ─── project data ──────────────────────────────────────────────────────
23
- type: 'self' → dives into the download page (Cloud Bongos itself)
24
- 'link' → a real, live instance from the platform's project
25
- catalog, loaded at boot by loadProjects() from
26
- /api/bongos/projects/featured; clicking opens the site.
27
- The Cloud Bongos self-orb is the ONLY seeded entry — every other orb is a
28
- genuinely-live project fetched from the catalog (task 1002260). No sample
29
- fiction any more: an empty catalog simply means a sparser sky. */
30
- var PROJECTS = [
31
- { id: 'cloud-bongos', name: 'Cloud Bongos', type: 'self', builders: 24,
32
- accent: '#f97316', preview: 'previews/cloud-bongos.jpg',
33
- tagline: 'The AI-first build platform',
34
- description: 'Building software is laying down a beat. AI agents claim tasks and ship the code; you set the direction and the gates.',
35
- tags: ['platform', 'ai', 'build system', 'bongos'] }
36
- ];
37
-
38
- /* ─── tunables ─────────────────────────────────────────────────────────── */
39
- var DEADZONE = 5; // px of movement before a press becomes a pan
40
- var ZMIN = 0.22, ZMAX = 2.4; // zoom clamp (ZMIN lowered task 1897 — pull way back for a wide overview + more sky to create in)
41
- var R_BASE = 30, R_K = 11; // radius = R_BASE + R_K*sqrt(builders)
42
- var SPIRAL_STEP = 235; // spacing of the golden-angle START scatter
43
-
44
- /* ─── gravity ("living cluster") — real n-body physics ────────────────────
45
- The orbs are planets/suns pulling on each other by their SIZE. Verified
46
- stable + lively + bounded over a 60-min headless sim (scratchpad/sim.js).
47
- TUNING — livelier → SWIRL↑ or THERMO_LO↑ · tighter cluster → CONTAIN_R↓ ·
48
- stronger pull → G↑ · softer near-misses (fewer hard slings) → SOFTEN↑ ·
49
- bouncier contacts → REST↑ (→1 = elastic). Mass = (r/R_REF)^3, so a big orb
50
- pulls much harder than a small one — that is "gravity based on their size." */
51
- var GRAV = {
52
- R_REF: 50, // mass = (radius / R_REF)^3
53
- G: 5200, // gravitational constant (px^3 / mass·s^2)
54
- SOFTEN: 70, // softening length (px) — tames close encounters, no singularities
55
- SWIRL: 0.09, // initial angular velocity of the whole field (rad/s) — low = gentle, un-chaotic start
56
- SWIRL_JITTER: 0.15, // random spread on the initial velocities (0 = a clean disk)
57
- REST: 0.92, // restitution when two discs actually touch (rare)
58
- CONTAIN_R: 520, // soft-well radius (px) — gentle pull-back keeps it on screen
59
- CONTAIN_K: 6.0, // inward accel per px beyond CONTAIN_R
60
- MAXV: 260, // hard velocity cap (px/s) — last-resort safety
61
- THERMO_LO: 0.55, // KE may slosh freely down to this ×target before a gentle nudge up
62
- THERMO_HI: 1.9, // ...and up to this ×target before a nudge down (pure physics between)
63
- THERMO_MAX: 0.01, // max per-step velocity nudge when outside the band
64
- PHYS_HZ: 120 // physics steps per second (fixed timestep; render interpolates)
65
- };
66
-
67
- /* ─── space-time sheet — a blueprint grid that warps with gravity ──────────
68
- A faint cyan grid drawn behind the orbs (own canvas, z1). Every grid vertex
69
- is pulled toward each orb by a mass-weighted well — mass = (r/R_REF)^3, the
70
- SAME law the n-body physics uses — so a big orb dents the sheet deeply, a
71
- small one barely, and the dimples ride the live motion. Kept a whisper:
72
- the warp is real but the ink is faint, and its alpha ramps with zoom so it
73
- reads as a blueprint underlay that only truly shows when you lean in.
74
- TUNING — deeper wells → DEPTH↑ · wider wells → WELL_R↑ · more visible →
75
- the *_A_HI alphas↑ · denser grid → BASE_WORLD↓ · smoother curves → SEG↑. */
76
- var GRID = {
77
- ENABLED: true,
78
- BASE_WORLD: 60, // world px between minor grid lines (smaller = denser grid)
79
- SEG: 10, // samples per cell along a line (curve smoothness near a well)
80
- MAJOR_EVERY: 4, // every Nth line is a brighter "major" rule (blueprint read)
81
- RGB: '158,202,248', // blueprint cyan-blue ink
82
- MINOR_A_LO: 0.055, MINOR_A_HI: 0.150, // minor-line alpha at ZMIN → ZMAX
83
- MAJOR_A_LO: 0.110, MAJOR_A_HI: 0.270, // major-line alpha at ZMIN → ZMAX
84
- LINE_PX: 1, // stroke width in screen px (a crisp hairline at any zoom)
85
- // The well: a Gaussian-weighted pull toward each orb. Displacement per orb =
86
- // (orb − point) · k · exp(−d²/2σ²), with σ = orb.r·WELL_R and k = DEPTH·(r/R_REF)².
87
- // This VANISHES at the centre and far away and peaks near the rim — a smooth
88
- // basin (C∞, no hard clamp → no creases, and for k<1 it can never fold over).
89
- // Depth grows with orb size (∝ r², a legible stand-in for mass), so a big orb
90
- // dents a wider, deeper region — reads as heavier. (Use it.m in render() for
91
- // strict mass ∝ size³ gravity, at the cost of tiny orbs barely denting.)
92
- WELL_R: 1.9, // well scale σ = orb.r * WELL_R (world px) — bigger orb, wider dip
93
- DEPTH: 0.20, // pull coefficient (k = DEPTH·(r/R_REF)²); higher = deeper dips
94
- CUT: 4.0, // ignore a well past CUT × σ (Gaussian is negligible there; saves work)
95
- MARGIN: 1.4, // extra world coverage beyond the viewport, in cells
96
- // Glow: the grid lines light up where the sheet is most pinched. Each segment
97
- // is stroked a second time, additively, with an alpha that tracks how far its
98
- // vertices were displaced — so a deep gravity well haloes the lines around it,
99
- // and a shallow one barely does. Deliberately subtle (few builders today, so
100
- // even the big orbs' dips are gentle); raise GLOW_MAX_A / lower GLOW_REF later
101
- // as orbs grow. Set GLOW_MAX_A: 0 to switch the glow off entirely.
102
- GLOW_RGB: '198,232,255', // brighter, whiter blueprint cyan for the glow
103
- GLOW_MAX_A: 0.07, // max ADDED alpha at full pinch (additive) — keep small = subtle
104
- GLOW_REF: 40, // world-px of displacement mapped to full glow (lower = glows sooner)
105
- GLOW_POW: 1.5, // >1 concentrates the glow into the most-pinched zones
106
- GLOW_LEVELS: 8, // alpha quantization buckets (keeps the stroke count low)
107
- GLOW_WIDTH: 1.4, // glow stroke width (screen px)
108
- GLOW_BLUR: 2 // shadowBlur bloom (screen px); 0 = crisp (cheaper)
109
- };
110
-
111
- /* ─── element refs ─────────────────────────────────────────────────────── */
112
- var space = document.getElementById('space');
113
- var canvas = document.getElementById('space-canvas');
114
- var gridCanvas = document.getElementById('space-grid');
115
- var gctx = gridCanvas ? gridCanvas.getContext('2d') : null;
116
- var gridDPR = 1;
117
- var starfield = document.getElementById('starfield');
118
- var hovercard = document.getElementById('hovercard');
119
- var flash = document.getElementById('dive-flash');
120
- var searchbar = document.getElementById('searchbar');
121
- var searchInput = document.getElementById('search-input');
122
- var searchCtxCount = document.getElementById('search-ctx-count');
123
- var scrim = document.getElementById('create-scrim');
124
- var createName = document.getElementById('create-name');
125
- var createGo = document.getElementById('create-go');
126
- var createCancel = document.getElementById('create-cancel');
127
- var detail = document.getElementById('detail');
128
- var hallDoor = document.getElementById('cb-hall');
129
- // entry gate (the relocated type-"bongos" terminal, now over the space sky)
130
- var entryEl = document.getElementById('entry');
131
- var eTyped = document.getElementById('typed'), eCaret = document.getElementById('caret'),
132
- eHint = document.getElementById('hint'), eCli = document.getElementById('cli'),
133
- eEnter = document.getElementById('enter');
134
- // dive cinematic (task 1729): clicking the Cloud Bongos orb flies you from
135
- // space, down through the clouds, into the Cloud Bongos world (download page)
136
- var descent = document.getElementById('descent');
137
- var DESCENT_MS = 2750; // deep in the cloud (still zooming) → zoom-through crossfade
138
- var diveLanded = false, descentTimer = null;
139
- var prefersReduced = false;
140
- try { prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
141
-
142
- /* ─── host-aware brand (task 1746) ───────────────────────────────────────
143
- ONE gate bundle serves all three domains; only location.hostname differs.
144
- claudebongos.com / geminibongos.com are vanity DOORS: they mirror the
145
- Cloud Bongos entry gate with just the wordmark + terminal word changed
146
- (you still type "bongos"), and on enter they hand off to cloudbongos.com,
147
- landing straight on the orbs via ?entered=1 (which cloudbongos.com honors
148
- to skip its own entry). cloudbongos.com / staging / localhost keep the
149
- default "cloud" behavior and reveal the orbs in place. Because it is one
150
- bundle + a runtime switch, any later change to this gate shows up on all
151
- three automatically — there is no forked copy to keep in sync. */
152
- var CLOUDBONGOS_ORIGIN = 'https://cloudbongos.com';
153
- function brandForHost(host) {
154
- host = (host || '').toLowerCase();
155
- // Redirect straight to the gate's SERVED path (/gate/), not the apex "/", so
156
- // the ?entered=1 flag survives: the apex "/" -> "/gate/" 302 drops the query,
157
- // which made a door visitor land on the entry gate and type "bongos" again
158
- // (task 1977 sibling fix). /gate/ is served directly, so the flag reaches
159
- // wantsDirectOrbs() and the orbs open straight away.
160
- if (/(^|\.)claudebongos\.com$/.test(host)) return { word: 'claude', display: 'Claude', redirect: CLOUDBONGOS_ORIGIN + '/gate/?entered=1' };
161
- if (/(^|\.)geminibongos\.com$/.test(host)) return { word: 'gemini', display: 'Gemini', redirect: CLOUDBONGOS_ORIGIN + '/gate/?entered=1' };
162
- return { word: 'cloud', display: 'Cloud', redirect: null };
163
- }
164
- var BRAND = brandForHost(location.hostname);
165
-
166
- // Swap the one brand word into the static markup the door visitor sees: the
167
- // terminal prefix (`> claude`) and every "Cloud Bongos" wordmark, plus the
168
- // tab title. The default bundle ships "cloud", so this is a no-op on
169
- // cloudbongos.com and only rewrites on a door.
170
- function applyBrand() {
171
- if (BRAND.word === 'cloud') return;
172
- if (eCli) { var c = eCli.querySelector('.c'); if (c) c.textContent = BRAND.word; }
173
- var marks = document.querySelectorAll('.mark, .space-mark, .tv-mark');
174
- for (var i = 0; i < marks.length; i++) {
175
- var nodes = marks[i].childNodes;
176
- for (var j = 0; j < nodes.length; j++) {
177
- if (nodes[j].nodeType === 3 && /\bCloud\b/.test(nodes[j].nodeValue)) {
178
- nodes[j].nodeValue = nodes[j].nodeValue.replace(/\bCloud\b/, BRAND.display);
179
- }
180
- }
181
- }
182
- try { document.title = BRAND.display + ' Bongos — landing gate'; } catch (_) {}
183
- }
184
- applyBrand();
185
-
186
- // Did a door redirect us here asking to open the orbs straight away? Honored
187
- // ONLY where we are not ourselves a door, so a door still shows its branded
188
- // entry even if it somehow carries the flag.
189
- function wantsDirectOrbs() {
190
- if (BRAND.redirect) return false;
191
- try { return new URLSearchParams(location.search).get('entered') === '1'; }
192
- catch (_) { return /[?&]entered=1(?:&|$)/.test(location.search || ''); }
193
- }
194
- // view toggle + table directory (task 1731)
195
- var vtCircles = document.getElementById('vt-circles');
196
- var vtTable = document.getElementById('vt-table');
197
- var tvBody = document.getElementById('tv-body');
198
- var tvSearchInput = document.getElementById('tv-search-input');
199
- var tvCount = document.getElementById('tv-count');
200
- var tvCreate = document.getElementById('tv-create');
201
-
202
- /* ─── view state ───────────────────────────────────────────────────────── */
203
- var panX = 0, panY = 0, zoom = 1;
204
- var saved = null; // pan/zoom snapshot captured at dive time
205
- var W = innerWidth, H = innerHeight;
206
- var items = []; // one per circle
207
- var byId = {};
208
- var searchOpen = false, createOpen = false, diving = false, entering = false;
209
- var userTouched = false; // has the user panned/zoomed? (stop auto-refit)
210
- var createAt = { x: 0, y: 0 };
211
- var browseView = 'space'; // which project view: 'space' (circles) | 'table'
212
- var tvSort = { key: 'builders', dir: -1 }; // table sort: dir -1 desc, 1 asc
213
-
214
- function radiusFor(builders) { return R_BASE + R_K * Math.sqrt(Math.max(1, builders)); }
215
- function massFor(r) { return Math.pow(r / GRAV.R_REF, 3); } // mass grows with size
216
- function project(id) { return byId[id]; }
217
- function anyOverlay() { return searchOpen || createOpen || signInOpen; }
218
- function isSpace() { return document.body.classList.contains('view-space'); }
219
-
220
- /* ─── build a circle ───────────────────────────────────────────────────── */
221
- function makeCircle(proj, ax, ay) {
222
- var r = radiusFor(proj.builders), d = r * 2;
223
- var el = document.createElement('div');
224
- el.className = 'circle' + (proj.type === 'self' ? ' is-self' : '');
225
- el.dataset.id = proj.id;
226
- el.style.left = ax + 'px';
227
- el.style.top = ay + 'px';
228
- el.style.width = d + 'px';
229
- el.style.height = d + 'px';
230
- el.style.setProperty('--acc', proj.accent);
231
- el.style.setProperty('--glow', hexGlow(proj.accent));
232
-
233
- var disc = document.createElement('div');
234
- disc.className = 'disc';
235
- el.appendChild(disc);
236
-
237
- // the world lives INSIDE the orb — a peek revealed on hover (real instances
238
- // that carry a preview image; samples stay their accent-coloured sphere).
239
- if (proj.preview) {
240
- var world = document.createElement('div');
241
- world.className = 'disc-world';
242
- world.style.backgroundImage = "url('" + proj.preview + "')";
243
- disc.appendChild(world);
244
- }
245
-
246
- if (proj.type === 'self') {
247
- var badge = document.createElement('div');
248
- badge.className = 'self-badge';
249
- badge.textContent = 'this platform';
250
- el.appendChild(badge);
251
- }
252
-
253
- var label = document.createElement('div');
254
- label.className = 'label';
255
- label.innerHTML = '<span class="name"></span><span class="count"></span>';
256
- label.querySelector('.name').textContent = proj.name;
257
- // Catalog projects carry no builder count (the public feed has none) — show
258
- // a neutral 'live' status rather than invent a number (task 1002260).
259
- label.querySelector('.count').textContent =
260
- (typeof proj.builders === 'number' && proj.builders > 0)
261
- ? (proj.builders + (proj.builders === 1 ? ' builder' : ' builders'))
262
- : 'live';
263
- el.appendChild(label);
264
-
265
- canvas.appendChild(el);
266
-
267
- el.addEventListener('mouseenter', function () { showHover(proj, el); });
268
- el.addEventListener('mouseleave', hideHover);
269
-
270
- return {
271
- // ax/ay = the DOM anchor (el.style.left/top, set once). x/y = the live
272
- // physics position; the render offset is (x-ax, y-ay). px/py = previous
273
- // step position (for frame interpolation). m = gravitational mass.
274
- proj: proj, el: el, disc: disc, ax: ax, ay: ay, r: r,
275
- x: ax, y: ay, px: ax, py: ay, vx: 0, vy: 0, m: massFor(r), fx: 0, fy: 0
276
- };
277
- }
278
-
279
- function hexGlow(hex) {
280
- var h = hex.replace('#', '');
281
- var n = parseInt(h.length === 3 ? h.replace(/(.)/g, '$1$1') : h, 16);
282
- var r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
283
- return 'rgba(' + r + ',' + g + ',' + b + ',.55)';
284
- }
285
-
286
- /* ─── layout: scatter anchors, Cloud Bongos in the middle ────────────────── */
287
- function layout() {
288
- var CX = 0, CY = 0, GA = 2.399963, STEP = SPIRAL_STEP;
289
- var others = PROJECTS.filter(function (p) { return p.type !== 'self'; });
290
- var self = PROJECTS.filter(function (p) { return p.type === 'self'; });
291
- var placed = [];
292
-
293
- self.forEach(function (p) { placed.push({ p: p, x: CX, y: CY }); });
294
- others.forEach(function (p, i) {
295
- var k = i + 1;
296
- var rad = STEP * Math.sqrt(k);
297
- var ang = k * GA;
298
- placed.push({ p: p, x: CX + Math.cos(ang) * rad, y: CY + Math.sin(ang) * rad * 0.82 });
299
- });
300
-
301
- placed.forEach(function (pl) {
302
- var it = makeCircle(pl.p, pl.x, pl.y);
303
- items.push(it);
304
- byId[pl.p.id] = pl.p;
305
- });
306
- initOrbits();
307
- }
308
-
309
- /* ─── fit the whole constellation into view ──────────────────────────────── */
310
- function fitToView() {
311
- if (!items.length) return;
312
- W = innerWidth || document.documentElement.clientWidth || 1200;
313
- H = innerHeight || document.documentElement.clientHeight || 800;
314
- var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
315
- items.forEach(function (it) {
316
- minX = Math.min(minX, it.x - it.r - 30); minY = Math.min(minY, it.y - it.r - 30);
317
- maxX = Math.max(maxX, it.x + it.r + 30); maxY = Math.max(maxY, it.y + it.r + 70);
318
- });
319
- var cw = maxX - minX, ch = maxY - minY;
320
- var pad = 70;
321
- var z = Math.min((W - pad * 2) / cw, (H - pad * 2) / ch, 1.3);
322
- zoom = Math.max(ZMIN, Math.min(ZMAX, z));
323
- var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
324
- panX = W / 2 - cx * zoom;
325
- panY = H / 2 - cy * zoom;
326
- applyCanvas();
327
- }
328
-
329
- function applyCanvas() {
330
- canvas.style.transform = 'translate(' + panX.toFixed(2) + 'px,' + panY.toFixed(2) + 'px) scale(' + zoom.toFixed(4) + ')';
331
- }
332
-
333
- function clampPan() {
334
- var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
335
- items.forEach(function (it) {
336
- minX = Math.min(minX, it.x); minY = Math.min(minY, it.y);
337
- maxX = Math.max(maxX, it.x); maxY = Math.max(maxY, it.y);
338
- });
339
- var m = 220;
340
- var sxMin = panX + minX * zoom, sxMax = panX + maxX * zoom;
341
- var syMin = panY + minY * zoom, syMax = panY + maxY * zoom;
342
- if (sxMax < m) panX += m - sxMax; else if (sxMin > W - m) panX -= sxMin - (W - m);
343
- if (syMax < m) panY += m - syMax; else if (syMin > H - m) panY -= syMin - (H - m);
344
- }
345
-
346
- /* ═══ gravity: a living n-body cluster ═════════════════════════════════════
347
- Every orb attracts every other by its mass (∝ size³), softened so close
348
- passes sling instead of exploding. A fixed-timestep leapfrog integrator
349
- (accurate + energy-preserving) advances the sim; the frame loop interpolates
350
- between steps for smoothness. A soft well + a band thermostat keep the swirl
351
- bounded and lively forever without freezing the physics in between. Verified
352
- over 60 sim-minutes: no NaN, stays centered, never interpenetrates. */
353
- var keTarget = 0;
354
-
355
- function barycenter() {
356
- var M = 0, cx = 0, cy = 0;
357
- for (var i = 0; i < items.length; i++) { var it = items[i]; M += it.m; cx += it.m * it.x; cy += it.m * it.y; }
358
- return M > 0 ? { x: cx / M, y: cy / M, M: M } : { x: 0, y: 0, M: 0 };
359
- }
360
- function kineticEnergy() {
361
- var ke = 0;
362
- for (var i = 0; i < items.length; i++) { var it = items[i]; ke += 0.5 * it.m * (it.vx * it.vx + it.vy * it.vy); }
363
- return ke;
364
- }
365
- function killDrift() {
366
- // remove uniform bulk velocity so the cluster never sails off-screen.
367
- // Galilean-neutral: the internal swirl is untouched.
368
- var M = 0, px = 0, py = 0;
369
- for (var i = 0; i < items.length; i++) { var it = items[i]; M += it.m; px += it.m * it.vx; py += it.m * it.vy; }
370
- if (M <= 0) return;
371
- var dvx = px / M, dvy = py / M;
372
- for (var j = 0; j < items.length; j++) { items[j].vx -= dvx; items[j].vy -= dvy; }
373
- }
374
-
375
- // seed a slow overall swirl + remember the lively energy the thermostat targets
376
- function initOrbits() {
377
- var c = barycenter();
378
- var jit = GRAV.SWIRL * 60 * GRAV.SWIRL_JITTER;
379
- for (var i = 0; i < items.length; i++) {
380
- var it = items[i], dx = it.x - c.x, dy = it.y - c.y;
381
- it.vx = -dy * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
382
- it.vy = dx * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
383
- }
384
- killDrift();
385
- keTarget = kineticEnergy();
386
- }
387
-
388
- function computeForces(c) {
389
- var n = items.length, soft2 = GRAV.SOFTEN * GRAV.SOFTEN, i, j;
390
- for (i = 0; i < n; i++) { items[i].fx = 0; items[i].fy = 0; }
391
- for (i = 0; i < n; i++) {
392
- for (j = i + 1; j < n; j++) {
393
- var a = items[i], b = items[j];
394
- var dx = b.x - a.x, dy = b.y - a.y;
395
- var d2 = dx * dx + dy * dy + soft2;
396
- var f = GRAV.G / (d2 * Math.sqrt(d2));
397
- a.fx += f * b.m * dx; a.fy += f * b.m * dy;
398
- b.fx -= f * a.m * dx; b.fy -= f * a.m * dy;
399
- }
400
- }
401
- // soft containment well — a gentle inward pull only beyond CONTAIN_R
402
- for (i = 0; i < n; i++) {
403
- var it = items[i], ex = it.x - c.x, ey = it.y - c.y, d = Math.hypot(ex, ey);
404
- if (d > GRAV.CONTAIN_R && d > 1e-6) {
405
- var k = -GRAV.CONTAIN_K * (d - GRAV.CONTAIN_R) / d;
406
- it.fx += k * ex; it.fy += k * ey;
407
- }
408
- }
409
- }
410
-
411
- function resolveCollisions() {
412
- var n = items.length;
413
- for (var i = 0; i < n; i++) {
414
- for (var j = i + 1; j < n; j++) {
415
- var a = items[i], b = items[j];
416
- var dx = b.x - a.x, dy = b.y - a.y;
417
- var dist = Math.hypot(dx, dy) || 1e-6, min = a.r + b.r;
418
- if (dist >= min) continue;
419
- var nx = dx / dist, ny = dy / dist;
420
- var im1 = 1 / a.m, im2 = 1 / b.m, ims = im1 + im2;
421
- var corr = (min - dist) / ims; // positional push-apart by inverse mass
422
- a.x -= nx * corr * im1; a.y -= ny * corr * im1;
423
- b.x += nx * corr * im2; b.y += ny * corr * im2;
424
- var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny; // velocity exchange along the normal
425
- if (vn < 0) {
426
- var imp = -(1 + GRAV.REST) * vn / ims;
427
- a.vx -= imp * im1 * nx; a.vy -= imp * im1 * ny;
428
- b.vx += imp * im2 * nx; b.vy += imp * im2 * ny;
429
- }
430
- }
431
- }
432
- }
433
-
434
- function thermostat() {
435
- var lo = keTarget * GRAV.THERMO_LO, hi = keTarget * GRAV.THERMO_HI, ke = kineticEnergy();
436
- if (ke < 1e-6) return;
437
- var target = ke < lo ? lo : (ke > hi ? hi : 0);
438
- if (!target) return; // inside the band → pure physics
439
- var f = Math.sqrt(target / ke);
440
- f = Math.max(1 - GRAV.THERMO_MAX, Math.min(1 + GRAV.THERMO_MAX, f));
441
- for (var i = 0; i < items.length; i++) { items[i].vx *= f; items[i].vy *= f; }
442
- }
443
-
444
- function clampVelocities() {
445
- for (var i = 0; i < items.length; i++) {
446
- var it = items[i], sp = Math.hypot(it.vx, it.vy);
447
- if (sp > GRAV.MAXV) { var f = GRAV.MAXV / sp; it.vx *= f; it.vy *= f; }
448
- }
449
- }
450
-
451
- function physStep(dt) {
452
- var i, it, c = barycenter();
453
- for (i = 0; i < items.length; i++) { it = items[i]; it.px = it.x; it.py = it.y; }
454
- computeForces(c); // leapfrog kick–drift–kick
455
- for (i = 0; i < items.length; i++) { it = items[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
456
- for (i = 0; i < items.length; i++) { it = items[i]; it.x += it.vx * dt; it.y += it.vy * dt; }
457
- computeForces(c);
458
- for (i = 0; i < items.length; i++) { it = items[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
459
- resolveCollisions();
460
- thermostat();
461
- killDrift();
462
- clampVelocities();
463
- }
464
-
465
- /* ═══ space-time sheet: a gravity-warped blueprint grid ════════════════════
466
- Drawn each frame on its own canvas (behind the orbs) in screen space, using
467
- the live pan/zoom so it lines up with the orb canvas exactly. Each vertex is
468
- displaced toward every nearby orb by a softened, mass-weighted well, so the
469
- lines funnel into each gravity dimple and the whole sheet breathes with the
470
- n-body motion. See the GRID tuning block up top. */
471
- var gridWells = [];
472
-
473
- function sizeGrid() {
474
- if (!gridCanvas) return;
475
- gridDPR = window.devicePixelRatio || 1;
476
- gridCanvas.width = Math.max(1, Math.round(W * gridDPR));
477
- gridCanvas.height = Math.max(1, Math.round(H * gridDPR));
478
- gridCanvas.style.width = W + 'px';
479
- gridCanvas.style.height = H + 'px';
480
- }
481
-
482
- // Displace a world point toward each orb by a Gaussian-weighted pull that
483
- // vanishes at the centre + far away and peaks near the rim — a smooth basin
484
- // (no clamp → no creases; can't fold). Writes the warped point onto `out`.
485
- function warpPoint(wx, wy, out) {
486
- var ox = wx, oy = wy;
487
- for (var i = 0; i < gridWells.length; i++) {
488
- var wl = gridWells[i];
489
- var dx = wl.x - wx, dy = wl.y - wy, d2 = dx * dx + dy * dy;
490
- if (d2 > wl.cut2) continue;
491
- var e = wl.k * Math.exp(-0.5 * d2 * wl.isig2); // fraction of the way to pull toward the orb
492
- ox += dx * e; oy += dy * e;
493
- }
494
- out.x = ox; out.y = oy;
495
- }
496
-
497
- function drawGrid() {
498
- if (!gctx || !GRID.ENABLED || !isSpace()) return;
499
- gctx.setTransform(gridDPR, 0, 0, gridDPR, 0, 0);
500
- gctx.clearRect(0, 0, W, H);
501
- if (!gridWells.length) return;
502
-
503
- var S = GRID.BASE_WORLD, step = S / GRID.SEG, m = GRID.MARGIN * S;
504
- // visible world rect (+ margin) so warped lines enter from off-screen cleanly
505
- var x0 = (0 - panX) / zoom - m, x1 = (W - panX) / zoom + m;
506
- var y0 = (0 - panY) / zoom - m, y1 = (H - panY) / zoom + m;
507
- var gx0 = Math.floor(x0 / S) * S, gy0 = Math.floor(y0 / S) * S;
508
-
509
- // alpha ramps with zoom: a whisper zoomed out, legible zoomed in
510
- var zf = (zoom - ZMIN) / (ZMAX - ZMIN); if (zf < 0) zf = 0; else if (zf > 1) zf = 1;
511
- var minorA = GRID.MINOR_A_LO + zf * (GRID.MINOR_A_HI - GRID.MINOR_A_LO);
512
- var majorA = GRID.MAJOR_A_LO + zf * (GRID.MAJOR_A_HI - GRID.MAJOR_A_LO);
513
- // fade minor lines out as their on-screen cell shrinks, so a zoomed-out grid
514
- // never turns into noise (only the major rules survive there)
515
- var cellPx = S * zoom;
516
- if (cellPx < 30) minorA *= Math.max(0, (cellPx - 14) / 16);
517
-
518
- gctx.lineWidth = GRID.LINE_PX;
519
- gctx.lineJoin = 'round'; gctx.lineCap = 'round';
520
- var p = { x: 0, y: 0 }, x, y, n, i;
521
- var xs = [], ys = []; // reused per-line screen-point buffers
522
-
523
- // draw a smooth curve through the n collected points with midpoint-quadratics
524
- // (Catmull-Rom-style) so the warped lines read as curves, not faceted chains
525
- function emitSmooth(n) {
526
- if (n < 2) return;
527
- gctx.moveTo(xs[0], ys[0]);
528
- if (n === 2) { gctx.lineTo(xs[1], ys[1]); return; }
529
- for (i = 1; i < n - 2; i++) {
530
- gctx.quadraticCurveTo(xs[i], ys[i], (xs[i] + xs[i + 1]) / 2, (ys[i] + ys[i + 1]) / 2);
531
- }
532
- gctx.quadraticCurveTo(xs[i], ys[i], xs[i + 1], ys[i + 1]); // last span (i = n-2)
533
- }
534
-
535
- // one batched path per tier (minor / major) — 2 strokes total
536
- function sweep(major, alpha) {
537
- if (alpha < 0.004) return;
538
- gctx.beginPath();
539
- for (x = gx0; x <= x1; x += S) { // vertical lines (constant world x)
540
- if ((Math.round(x / S) % GRID.MAJOR_EVERY === 0) !== major) continue;
541
- n = 0;
542
- for (y = y0; y <= y1; y += step) { warpPoint(x, y, p); xs[n] = panX + p.x * zoom; ys[n] = panY + p.y * zoom; n++; }
543
- emitSmooth(n);
544
- }
545
- for (y = gy0; y <= y1; y += S) { // horizontal lines (constant world y)
546
- if ((Math.round(y / S) % GRID.MAJOR_EVERY === 0) !== major) continue;
547
- n = 0;
548
- for (x = x0; x <= x1; x += step) { warpPoint(x, y, p); xs[n] = panX + p.x * zoom; ys[n] = panY + p.y * zoom; n++; }
549
- emitSmooth(n);
550
- }
551
- gctx.strokeStyle = 'rgba(' + GRID.RGB + ',' + alpha.toFixed(4) + ')';
552
- gctx.stroke();
553
- }
554
-
555
- sweep(false, minorA); // minor rules first, majors drawn over them
556
- sweep(true, majorA);
557
-
558
- // ── glow pass: lines brighten where the sheet is most pinched ──
559
- // A vertex's displacement magnitude IS the local "gravity" — stroke each
560
- // segment additively with an alpha that tracks it, so deep wells halo the
561
- // grid around them and shallow ones barely do. Bucketed into GLOW_LEVELS so
562
- // the whole pass is a handful of strokes, not thousands.
563
- if (GRID.GLOW_MAX_A > 0.001) drawGlow(S, step, x0, x1, y0, y1, gx0, gy0, zf);
564
- }
565
-
566
- // is a grid line (constant world x if vertical, else y) close enough to any
567
- // well to pick up glow? Cheap perpendicular-distance prune so the glow pass
568
- // only samples lines that actually pass near an orb.
569
- function lineNearWell(c, isVert, reachSig) {
570
- for (var i = 0; i < gridWells.length; i++) {
571
- var wl = gridWells[i];
572
- if (Math.abs(c - (isVert ? wl.x : wl.y)) < reachSig * wl.sig) return true;
573
- }
574
- return false;
575
- }
576
-
577
- var glowBuckets = null; // reused per-frame flat [x0,y0,x1,y1,…] arrays, one per alpha level
578
-
579
- function drawGlow(S, step, x0, x1, y0, y1, gx0, gy0, zf) {
580
- var L = GRID.GLOW_LEVELS, ref = GRID.GLOW_REF, pw = GRID.GLOW_POW, reach = 3;
581
- if (!glowBuckets || glowBuckets.length !== L + 1) { glowBuckets = []; for (var b = 0; b <= L; b++) glowBuckets.push([]); }
582
- for (b = 0; b <= L; b++) glowBuckets[b].length = 0;
583
-
584
- var p = { x: 0, y: 0 }, x, y, sx, sy, dx, dy, g, gAvg, bi, prevG, psx, psy, first;
585
- // vertical lines
586
- for (x = gx0; x <= x1; x += S) {
587
- if (!lineNearWell(x, true, reach)) continue;
588
- prevG = 0; psx = 0; psy = 0; first = true;
589
- for (y = y0; y <= y1; y += step) {
590
- warpPoint(x, y, p);
591
- dx = p.x - x; dy = p.y - y;
592
- g = Math.pow(Math.min(1, Math.sqrt(dx * dx + dy * dy) / ref), pw);
593
- sx = panX + p.x * zoom; sy = panY + p.y * zoom;
594
- if (!first) { gAvg = (g + prevG) * 0.5; bi = Math.round(gAvg * L); if (bi > 0) glowBuckets[bi].push(psx, psy, sx, sy); }
595
- prevG = g; psx = sx; psy = sy; first = false;
596
- }
597
- }
598
- // horizontal lines
599
- for (y = gy0; y <= y1; y += S) {
600
- if (!lineNearWell(y, false, reach)) continue;
601
- prevG = 0; psx = 0; psy = 0; first = true;
602
- for (x = x0; x <= x1; x += step) {
603
- warpPoint(x, y, p);
604
- dx = p.x - x; dy = p.y - y;
605
- g = Math.pow(Math.min(1, Math.sqrt(dx * dx + dy * dy) / ref), pw);
606
- sx = panX + p.x * zoom; sy = panY + p.y * zoom;
607
- if (!first) { gAvg = (g + prevG) * 0.5; bi = Math.round(gAvg * L); if (bi > 0) glowBuckets[bi].push(psx, psy, sx, sy); }
608
- prevG = g; psx = sx; psy = sy; first = false;
609
- }
610
- }
611
-
612
- // stroke the buckets additively (dim → bright), so overlaps accumulate glow
613
- var prevOp = gctx.globalCompositeOperation;
614
- gctx.globalCompositeOperation = 'lighter';
615
- gctx.lineWidth = GRID.GLOW_WIDTH;
616
- if (GRID.GLOW_BLUR > 0) { gctx.shadowColor = 'rgba(' + GRID.GLOW_RGB + ',1)'; gctx.shadowBlur = GRID.GLOW_BLUR; }
617
- var glowZ = 0.4 + 0.6 * zf; // present but stronger zoomed in
618
- for (var lv = 1; lv <= L; lv++) {
619
- var segs = glowBuckets[lv]; if (!segs.length) continue;
620
- var a = GRID.GLOW_MAX_A * glowZ * (lv / L);
621
- if (a < 0.003) continue;
622
- gctx.beginPath();
623
- for (var i = 0; i < segs.length; i += 4) { gctx.moveTo(segs[i], segs[i + 1]); gctx.lineTo(segs[i + 2], segs[i + 3]); }
624
- gctx.strokeStyle = 'rgba(' + GRID.GLOW_RGB + ',' + a.toFixed(4) + ')';
625
- gctx.stroke();
626
- }
627
- gctx.shadowBlur = 0;
628
- gctx.globalCompositeOperation = prevOp;
629
- }
630
-
631
- function render(alpha) {
632
- // snap the moving offset to the device-pixel grid: on a HiDPI screen the
633
- // motion is still smooth (sub-pixel steps) but glyphs + edges rasterize the
634
- // same every frame, so labels don't shimmer as an orb drifts.
635
- var dpr = window.devicePixelRatio || 1;
636
- gridWells.length = 0;
637
- for (var k = 0; k < items.length; k++) {
638
- var it = items[k];
639
- var ix = it.px + (it.x - it.px) * alpha, iy = it.py + (it.y - it.py) * alpha;
640
- var tx = Math.round((ix - it.ax) * dpr) / dpr, ty = Math.round((iy - it.ay) * dpr) / dpr;
641
- it.el.style.transform = 'translate(-50%,-50%) translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px)';
642
- // a gravity well per orb, sampled at the SAME interpolated centre it renders
643
- // at, so the dimple sits dead under the orb. sig = well width, k = pull
644
- // strength (∝ r² — bigger orb, deeper+wider dip), isig2 = 1/sig² (precomputed).
645
- var sig = it.r * GRID.WELL_R, rr = it.r / GRAV.R_REF;
646
- gridWells.push({ x: ix, y: iy, sig: sig, k: GRID.DEPTH * rr * rr, isig2: 1 / (sig * sig), cut2: (GRID.CUT * sig) * (GRID.CUT * sig) });
647
- }
648
- sampleUnder(); // one throttled element-under-cursor sample shared by the two below
649
- checkHoverExit(); // drop the hovercard if the orb has drifted off the cursor (task 1895)
650
- updateGhost(); // idle create-a-project ghost orb over empty sky (task 1896)
651
- drawMinimap(); // live cluster overview + viewport rect (task 1897)
652
- updateWhale(); // fly the space whale between orbs on updates (task 1898)
653
- drawGrid();
654
- }
655
-
656
- var DT_MS = 1000 / GRAV.PHYS_HZ, physAcc = 0, physLast = performance.now();
657
- function tick(now) {
658
- var ft = now - physLast; physLast = now; if (ft > 250) ft = 250; // clamp tab-switch gaps
659
- physAcc += ft;
660
- var guard = 0;
661
- while (physAcc >= DT_MS && guard < 8) { physStep(DT_MS / 1000); physAcc -= DT_MS; guard++; }
662
- if (guard >= 8) physAcc = 0; // don't spiral if we fell behind
663
- render(Math.max(0, Math.min(1, physAcc / DT_MS)));
664
- requestAnimationFrame(tick);
665
- }
666
-
667
- /* ─── hover card ───────────────────────────────────────────────────────── */
668
- function showHover(proj, el) {
669
- if (diving || searchOpen || createOpen || panning || !isSpace()) return;
670
- var rect = el.querySelector('.disc').getBoundingClientRect();
671
- var sample = proj.type === 'sample' ? '<span class="hc-sample"> · sample</span>' : '';
672
- var go = proj.type === 'self' ? 'click to enter →'
673
- : proj.type === 'link' ? 'click to open its page →' : 'click to preview →';
674
- hovercard.innerHTML =
675
- '<div class="hc-name"><span class="hc-dot"></span>' + esc(proj.name) + '</div>' +
676
- '<div class="hc-tag">' + esc(proj.tagline) + sample + '</div>' +
677
- '<div class="hc-meta"><span>' + proj.builders + (proj.builders === 1 ? ' builder' : ' builders') + '</span><span class="chip">' + esc((proj.tags[0] || '')) + '</span></div>' +
678
- '<div class="hc-go">' + go + '</div>';
679
- hovercard.style.setProperty('--acc', proj.accent);
680
- var cardW = 250;
681
- var x = Math.max(12, Math.min(W - cardW - 12, rect.left + rect.width / 2 - cardW / 2));
682
- var y = rect.top - 12;
683
- hovercard.style.left = x + 'px';
684
- hovercard.style.top = y + 'px';
685
- hovercard.style.transform = 'translateY(-100%)';
686
- hovercard.classList.add('show');
687
- hoverCircle = el;
688
- }
689
- function hideHover() { hovercard.classList.remove('show'); hoverCircle = null; }
690
-
691
- // Shared pointer sampling (tasks 1895/1896 + this follow-up). The orbs drift
692
- // under a live gravity sim, so an orb can slide out from under a STATIONARY
693
- // cursor and the browser never fires mouseleave (hover is only recomputed on
694
- // pointer events). We track the cursor + how long it's been still, and each
695
- // frame sample the element directly under it (throttled — elementFromPoint is a
696
- // layout hit-test). checkHoverExit + updateGhost both read that one sample.
697
- var hoverCircle = null; // the .circle whose card is showing
698
- var ptr = { x: -1, y: -1 };
699
- var lastMoveT = 0; // performance.now() of the last cursor move
700
- var underEl = null, sampleTick = 0;
701
- function sampleUnder() {
702
- if (ptr.x < 0) { underEl = null; return; }
703
- if ((++sampleTick % 4) === 0) underEl = document.elementFromPoint(ptr.x, ptr.y);
704
- }
705
- function underCircle() { return underEl && underEl.closest ? underEl.closest('.circle') : null; }
706
- function checkHoverExit() {
707
- // dismiss the card the moment the cursor is no longer over the hovered orb —
708
- // using the SAME element-under-cursor test the browser would, so the hover
709
- // region is the whole orb (disc + label), not a tight inscribed circle.
710
- if (!hoverCircle || panning || anyOverlay() || diving) return;
711
- if (underCircle() !== hoverCircle) hideHover();
712
- }
713
-
714
- // Idle "create a project" ghost orb (task 1896): a faint dashed circle that
715
- // trails the cursor over EMPTY sky — a light nudge toward the click-to-create
716
- // gesture. Shown only when not panning / no overlay / not over an orb or UI
717
- // chrome. elementFromPoint is a layout hit-test, so we sample it a few times a
718
- // second (not every frame) — enough to also hide when an orb drifts under a
719
- // still cursor.
720
- var ghost = document.getElementById('ghost-orb');
721
- var ghostPlus = ghost ? ghost.querySelector('.ghost-plus') : null;
722
- var SKY_IDS = { 'space': 1, 'space-canvas': 1, 'space-grid': 1, 'starfield': 1 };
723
- var GHOST_DWELL = 1000; // cursor must rest still this long over empty sky before it appears
724
- function updateGhost() {
725
- if (!ghost) return;
726
- var onSky = !!(underEl && SKY_IDS[underEl.id]);
727
- var still = (performance.now() - lastMoveT) > GHOST_DWELL; // gone the moment the cursor moves
728
- if (isSpace() && !panning && !anyOverlay() && !diving && ptr.x >= 0 && onSky && still) {
729
- // size the outline to a STARTING project (builders=1), scaled by zoom, so it
730
- // previews the true size the new orb will be — and shrinks on zoom-out with
731
- // the orbs (task 1911).
732
- var d = radiusFor(1) * 2 * zoom;
733
- ghost.style.width = d + 'px'; ghost.style.height = d + 'px';
734
- ghost.style.marginLeft = (-d / 2) + 'px'; ghost.style.marginTop = (-d / 2) + 'px';
735
- ghost.style.left = ptr.x + 'px'; ghost.style.top = ptr.y + 'px';
736
- if (ghostPlus) ghostPlus.style.fontSize = Math.max(9, d * 0.5) + 'px';
737
- ghost.classList.add('show');
738
- } else {
739
- ghost.classList.remove('show');
740
- }
741
- }
742
-
743
- function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
744
-
745
- /* ─── pan + zoom + press-to-click (space view only) ──────────────────────── */
746
- var down = false, moved = false, panning = false, middlePan = false;
747
- var sx = 0, sy = 0, startPanX = 0, startPanY = 0, startCircle = null;
748
-
749
- // suppress the browser's middle-click autoscroll widget so a wheel-press reads
750
- // purely as our pan gesture.
751
- space.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
752
-
753
- space.addEventListener('pointerdown', function (e) {
754
- if (diving || !isSpace() || anyOverlay()) return;
755
- if (e.button !== 0 && e.button !== 1) return; // left = drag/click, middle = pan-anywhere
756
- down = true; moved = false; panning = false;
757
- middlePan = (e.button === 1);
758
- if (middlePan) {
759
- // a wheel-press is ALWAYS a pan — never a click/dive/create, and it grabs
760
- // even over an orb. Force pan mode up front.
761
- moved = true; panning = true; userTouched = true;
762
- space.classList.add('panning'); hideHover(); e.preventDefault();
763
- }
764
- space.classList.add('grabbing'); // pressing the surface = drag/pan
765
- sx = e.clientX; sy = e.clientY; startPanX = panX; startPanY = panY;
766
- startCircle = middlePan ? null : e.target.closest('.circle');
767
- createAt.x = (e.clientX - panX) / zoom;
768
- createAt.y = (e.clientY - panY) / zoom;
769
- });
770
- window.addEventListener('pointermove', function (e) {
771
- ptr.x = e.clientX; ptr.y = e.clientY; lastMoveT = performance.now(); // track cursor + last-move time
772
- if (!down) return;
773
- if (!moved && Math.hypot(e.clientX - sx, e.clientY - sy) < DEADZONE) return;
774
- if (!moved) { moved = true; panning = true; userTouched = true; space.classList.add('panning'); hideHover(); }
775
- panX = startPanX + (e.clientX - sx);
776
- panY = startPanY + (e.clientY - sy);
777
- clampPan();
778
- applyCanvas();
779
- });
780
- window.addEventListener('pointerup', function () {
781
- if (!down) return;
782
- down = false; space.classList.remove('panning', 'grabbing');
783
- var wasPan = moved || middlePan; panning = false;
784
- if (!wasPan) {
785
- if (startCircle) enterProject(startCircle.dataset.id, startCircle);
786
- else if (!anyOverlay()) openCreate();
787
- }
788
- startCircle = null; middlePan = false;
789
- });
790
-
791
- // Scroll the wheel to ZOOM toward the cursor. To PAN, either drag empty sky
792
- // with the left button or hold the scroll wheel down (middle button) and drag
793
- // anywhere — see the pointer handlers above.
794
- space.addEventListener('wheel', function (e) {
795
- if (diving || anyOverlay() || !isSpace()) return;
796
- e.preventDefault();
797
- userTouched = true;
798
- var cx = e.clientX, cy = e.clientY;
799
- var vx = (cx - panX) / zoom, vy = (cy - panY) / zoom;
800
- var factor = Math.exp(-e.deltaY * 0.0016);
801
- zoom = Math.max(ZMIN, Math.min(ZMAX, zoom * factor));
802
- panX = cx - vx * zoom; panY = cy - vy * zoom;
803
- clampPan(); applyCanvas(); hideHover();
804
- }, { passive: false });
805
-
806
- /* ─── which circles are on-screen right now (search scope) ───────────────── */
807
- function inViewProjects() {
808
- var out = [];
809
- for (var k = 0; k < items.length; k++) {
810
- var it = items[k];
811
- var sxp = panX + it.x * zoom, syp = panY + it.y * zoom;
812
- if (sxp > -it.r * zoom && sxp < W + it.r * zoom && syp > -it.r * zoom && syp < H + it.r * zoom) out.push(it);
813
- }
814
- return out;
815
- }
816
-
817
- /* ─── dive into a project ────────────────────────────────────────────────── */
818
- function enterProject(id, fromEl) {
819
- var proj = project(id);
820
- if (!proj || diving) return;
821
- hideHover();
822
- diving = true;
823
- saved = { panX: panX, panY: panY, zoom: zoom, from: browseView };
824
-
825
- // Cloud Bongos itself (the self orb): fly from space, down through the
826
- // clouds, into its world (the download page) — the #descent cinematic.
827
- if (proj.type === 'self') { diveToWorld(); return; }
828
-
829
- // dive origin: a circle's .disc, else the clicked row, else screen centre
830
- var src = (fromEl && fromEl.querySelector && fromEl.querySelector('.disc')) || fromEl;
831
- var rect = (src && src.getBoundingClientRect) ? src.getBoundingClientRect()
832
- : { left: W / 2, top: H / 2, width: 0, height: 0 };
833
- var fx = rect.left + rect.width / 2, fy = rect.top + rect.height / 2;
834
- flash.style.setProperty('--fx', fx + 'px');
835
- flash.style.setProperty('--fy', fy + 'px');
836
- flash.classList.remove('go'); void flash.offsetWidth; flash.classList.add('go');
837
-
838
- space.classList.add('diving');
839
- var z2 = zoom * 2.7;
840
- var vx = (fx - panX) / zoom, vy = (fy - panY) / zoom;
841
- panX = fx - vx * z2; panY = fy - vy * z2; zoom = z2;
842
- applyCanvas();
843
- canvas.style.opacity = '0';
844
-
845
- setTimeout(function () { finishEnter(proj); }, 700);
846
- }
847
-
848
- // non-self orbs (real instances / samples) settle into a themed detail page.
849
- // The Cloud Bongos self orb never reaches here — it takes the #descent dive.
850
- function finishEnter(proj) {
851
- space.classList.remove('diving');
852
- canvas.style.opacity = '1';
853
- if (saved) { panX = saved.panX; panY = saved.panY; zoom = saved.zoom; applyCanvas(); }
854
- openDetail(proj);
855
- diving = false;
856
- }
857
-
858
- /* ═══ dive cinematic: space constellation → through the clouds → the world ═══
859
- (task 1729) Clicking the Cloud Bongos orb plays #descent as a full-screen
860
- curtain (deep space → sky-island → cloud punch-through → flash), then hands
861
- off to the download page (view-download) under the flash and lifts the
862
- curtain. Replayable (reset each dive); skippable via click / key / scroll;
863
- reduced motion drops straight into the world with no cinematic. */
864
- function makeDescentStars(el, n, big) {
865
- if (!el) return;
866
- var frag = document.createDocumentFragment();
867
- for (var i = 0; i < n; i++) {
868
- var s = document.createElement('div');
869
- s.className = 'star';
870
- var sz = Math.random() * (big ? 2.2 : 1.4) + (big ? 1.1 : 0.5);
871
- s.style.width = sz.toFixed(1) + 'px'; s.style.height = sz.toFixed(1) + 'px';
872
- s.style.left = (Math.random() * 100).toFixed(2) + '%';
873
- s.style.top = (Math.random() * 100).toFixed(2) + '%';
874
- s.style.opacity = (Math.random() * 0.5 + 0.4).toFixed(2);
875
- frag.appendChild(s);
876
- }
877
- el.appendChild(frag);
878
- }
879
-
880
- function resetDescent() {
881
- if (!descent) return;
882
- descent.classList.remove('playing', 'done');
883
- var f = document.getElementById('descent-stars-far'), n = document.getElementById('descent-stars-near');
884
- if (f) { f.innerHTML = ''; makeDescentStars(f, 150, false); }
885
- if (n) { n.innerHTML = ''; makeDescentStars(n, 70, true); }
886
- void descent.offsetWidth; // reflow so the CSS keyframes restart on replay
887
- }
888
-
889
- function diveToWorld() {
890
- diveLanded = false;
891
- if (prefersReduced || !descent) { landInWorld(); return; }
892
- resetDescent();
893
- descent.classList.add('playing');
894
- clearTimeout(descentTimer);
895
- descentTimer = setTimeout(landInWorld, DESCENT_MS);
896
- }
897
-
898
- function landInWorld() {
899
- if (diveLanded) return;
900
- diveLanded = true;
901
- clearTimeout(descentTimer);
902
- // We're deep in the cloud (screen full of cloud). Put the world behind it,
903
- // then let the cloud keep zooming past + dissolve (crossfade) to reveal it.
904
- document.body.className = 'view-download';
905
- document.dispatchEvent(new CustomEvent('cb:show-download'));
906
- if (saved) { panX = saved.panX; panY = saved.panY; zoom = saved.zoom; applyCanvas(); }
907
- space.classList.remove('diving');
908
- canvas.style.opacity = '1';
909
- if (descent) {
910
- descent.classList.add('done'); // cloud zooms past + fades out, revealing the world
911
- setTimeout(function () { descent.classList.remove('playing', 'done'); }, 760);
912
- }
913
- diving = false;
914
- }
915
-
916
- if (descent) {
917
- descent.addEventListener('pointerdown', function () { landInWorld(); });
918
- descent.addEventListener('wheel', function (e) { e.preventDefault(); landInWorld(); }, { passive: false });
919
- }
920
-
921
- // top-left ESC button (task 1913) — same as pressing Esc from download/detail
922
- var escBtn = document.getElementById('esc-btn');
923
- if (escBtn) escBtn.addEventListener('click', function () { exitToSpace(); });
924
-
925
- /* ─── the builders'-hall door (#cb-hall, top-right) ──────────────────────
926
- Project-aware (task 1002676): each project's hall lives at ITS OWN domain
927
- — the apex /builders path canonicalizes to builders.<apex> (#739) — so on
928
- a detail view the door must lead to THAT project's hall, not the platform
929
- hall. Directory views (orbs/table) keep the platform default. */
930
- var HALL_DEFAULT_HREF = 'https://builders.cloudbongos.com';
931
- var HALL_DEFAULT_LABEL = "Enter builders' hall";
932
- function setHallDoor(proj) {
933
- if (!hallDoor) return;
934
- var label = hallDoor.querySelector('.cb-hall__label');
935
- if (proj && proj.url) {
936
- hallDoor.href = String(proj.url).replace(/\/+$/, '') + '/builders';
937
- if (label) label.textContent = 'Enter ' + proj.name + "'s builders' hall";
938
- } else {
939
- hallDoor.href = HALL_DEFAULT_HREF;
940
- if (label) label.textContent = HALL_DEFAULT_LABEL;
941
- }
942
- }
943
-
944
- function openDetail(proj) {
945
- setHallDoor(proj);
946
- detail.style.setProperty('--acc', proj.accent);
947
- detail.style.setProperty('--glow', hexGlow(proj.accent));
948
- var visit = proj.url
949
- ? '<a class="visit" href="' + esc(proj.url) + '" target="_blank" rel="noopener">Visit ' + esc(proj.name) + ' →</a>'
950
- : '';
951
- var sampleNote = proj.type === 'sample' ? '<div class="meta">a sample community instance</div>' : '';
952
- detail.querySelector('.card').innerHTML =
953
- '<div class="halo"></div>' +
954
- '<h2>' + esc(proj.name) + '</h2>' +
955
- '<div class="tag">' + esc(proj.tagline) + '</div>' +
956
- '<div class="desc">' + esc(proj.description) + '</div>' +
957
- '<div class="meta">' + proj.builders + (proj.builders === 1 ? ' builder' : ' builders') + ' · ' + esc(proj.tags.join(' · ')) + '</div>' +
958
- sampleNote +
959
- '<div class="actions">' + visit + '</div>' +
960
- '<div class="soon">Full project story — coming soon</div>';
961
- document.body.className = 'view-detail';
962
- }
963
-
964
- /* ─── rise back up to space ──────────────────────────────────────────────── */
965
- function exitToSpace() {
966
- setHallDoor(null);
967
- var wasDownload = document.body.classList.contains('view-download');
968
- if (wasDownload) document.dispatchEvent(new CustomEvent('cb:hide-download'));
969
- closeSearch();
970
- // return to whichever browse view the dive started from
971
- var back = (saved && saved.from) || browseView || 'space';
972
- if (back === 'table') {
973
- if (saved) { panX = saved.panX; panY = saved.panY; zoom = saved.zoom; applyCanvas(); }
974
- document.body.className = 'view-table';
975
- setBrowseView('table');
976
- renderTable();
977
- return;
978
- }
979
- document.body.className = 'view-space';
980
- setBrowseView('space');
981
- if (!saved) return;
982
- canvas.style.opacity = '1';
983
- var z2 = saved.zoom * 1.5;
984
- var vx = (W / 2 - saved.panX) / saved.zoom, vy = (H / 2 - saved.panY) / saved.zoom;
985
- space.classList.remove('diving');
986
- panX = W / 2 - vx * z2; panY = H / 2 - vy * z2; zoom = z2; applyCanvas();
987
- setTimeout(function () {
988
- space.classList.add('diving');
989
- panX = saved.panX; panY = saved.panY; zoom = saved.zoom; applyCanvas();
990
- }, 20);
991
- setTimeout(function () { space.classList.remove('diving'); }, 560);
992
- }
993
-
994
- /* ─── search overlay ──────────────────────────────────────────────────────── */
995
- function openSearch() {
996
- if (searchOpen || createOpen || diving || !isSpace()) return;
997
- searchOpen = true;
998
- document.body.classList.add('searching'); // darkens the sky; matches spotlight (task 1892)
999
- searchbar.classList.add('show');
1000
- searchInput.value = '';
1001
- updateSearch();
1002
- setTimeout(function () { searchInput.focus(); }, 60);
1003
- }
1004
- function closeSearch() {
1005
- searchOpen = false;
1006
- document.body.classList.remove('searching');
1007
- searchbar.classList.remove('show');
1008
- searchInput.value = '';
1009
- items.forEach(function (it) { it.el.classList.remove('dim', 'match'); });
1010
- }
1011
- function updateSearch() {
1012
- var q = searchInput.value.trim().toLowerCase();
1013
- var scope = inViewProjects();
1014
- var scopeIds = {};
1015
- scope.forEach(function (it) { scopeIds[it.proj.id] = true; });
1016
- searchCtxCount.textContent = scope.length;
1017
-
1018
- items.forEach(function (it) {
1019
- it.el.classList.remove('dim', 'match');
1020
- if (!q) return;
1021
- if (!scopeIds[it.proj.id]) { it.el.classList.add('dim'); return; }
1022
- matches(it.proj, q) ? it.el.classList.add('match') : it.el.classList.add('dim');
1023
- });
1024
- // AI seam — dormant for v1 (owner: "interaction now, AI wired next").
1025
- // aiAnswer(q, scope.map(function(it){ return it.proj; }));
1026
- }
1027
- function matches(proj, q) {
1028
- if (!q) return false;
1029
- var hay = (proj.name + ' ' + proj.tagline + ' ' + proj.description + ' ' + proj.tags.join(' ')).toLowerCase();
1030
- return q.split(/\s+/).every(function (tok) { return hay.indexOf(tok) !== -1; });
1031
- }
1032
- searchInput.addEventListener('input', updateSearch);
1033
-
1034
- /* ─── create a project ────────────────────────────────────────────────────── */
1035
- var pendingOrb = null; // the live preview orb shown while the create panel is open
1036
- var createSeq = 0;
1037
-
1038
- /* Naming an orb now CONTINUES into the real onboarding (goal 35, task 2087 —
1039
- the panel's old "full setup coming soon" note, come due): the name rides
1040
- ?name= into the branded wizard at /create (task 2081), which pre-fills it
1041
- and runs sign-in → name/home/address → provision → live progress. On the
1042
- apex we know the sign-in state from /api/bongos/me (same-origin via the
1043
- Caddy catch-all); a signed-out visitor routes through GitHub sign-in first
1044
- (the name survives in the return path). On a vanity DOOR the API isn't on
1045
- this origin, so we send them to the cloudbongos.com wizard and let its own
1046
- gate handle sign-in. */
1047
- var ME = null; // { github_login, … } when signed in on the apex
1048
- var IS_DOOR = !!BRAND.redirect;
1049
- var signedIn = false; // resolved from /api/bongos/me below (task 2094)
1050
- var signInOpen = false; // the signed-out create-gate dialog is up
1051
- var acctEl = document.getElementById('cb-account');
1052
- var createAuthNote = document.getElementById('create-auth-note');
1053
- var signinScrim = document.getElementById('signin-scrim');
1054
- var signinGo = document.getElementById('signin-go');
1055
- var signinDismiss = document.getElementById('signin-dismiss');
1056
- var GH_MARK = '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
1057
-
1058
- // Where "Sign in with GitHub" points: back to the orbs, signed in. On a vanity
1059
- // DOOR the API + session live on cloudbongos.com, so sign-in happens there.
1060
- function acctSignInHref() {
1061
- var back = '/gate/?entered=1';
1062
- return IS_DOOR ? (CLOUDBONGOS_ORIGIN + back)
1063
- : ('/api/bongos/auth/web/start?return=' + encodeURIComponent(back));
1064
- }
1065
-
1066
- // The identity chip (top-right). Signed in = avatar + login (the visitor's
1067
- // cloudbongos account) + a quiet sign-out; signed out = "Sign in with GitHub".
1068
- function renderAccount() {
1069
- if (!acctEl) return;
1070
- if (signedIn && ME) {
1071
- var avatar = ME.avatar_url ? '<img src="' + esc(ME.avatar_url) + '" alt="">' : '';
1072
- acctEl.innerHTML = '<span class="cb-me">' + avatar +
1073
- '<span class="cb-name">' + esc(ME.github_login || 'signed in') + '</span>' +
1074
- '<a class="cb-out" href="#" id="cb-signout">sign out</a></span>';
1075
- var out = document.getElementById('cb-signout');
1076
- if (out) out.addEventListener('click', function (e) {
1077
- e.preventDefault();
1078
- // Stay on the orbs after signing out (task 1002262): a bare reload of
1079
- // /gate/ with no ?entered=1 drops a visitor who typed "bongos" back on
1080
- // the entry terminal. Force the direct-orbs flag so the reload re-opens
1081
- // the sky, now signed out. location.replace so Back doesn't return to the
1082
- // signed-in view; falls back to a plain reload if URL rewriting throws.
1083
- var stayOnOrbs = function () {
1084
- try {
1085
- var u = new URL(location.href);
1086
- u.searchParams.set('entered', '1');
1087
- location.replace(u.pathname + u.search + u.hash);
1088
- } catch (_) { location.reload(); }
1089
- };
1090
- fetch('/api/bongos/auth/logout', { method: 'POST', credentials: 'same-origin' })
1091
- .then(stayOnOrbs, stayOnOrbs);
1092
- });
1093
- } else {
1094
- acctEl.innerHTML = '<a class="cb-signin" href="' + acctSignInHref() + '">' + GH_MARK + 'Sign in with GitHub</a>';
1095
- }
1096
- }
1097
-
1098
- // Signed-out: gray the create-a-project affordances + relabel the ghost orb.
1099
- function refreshCreateAffordances() {
1100
- if (ghost) ghost.classList.toggle('locked', !signedIn);
1101
- var gl = ghost && ghost.querySelector('.ghost-label');
1102
- if (gl) gl.textContent = signedIn ? 'click to create a project' : 'sign in to create a project';
1103
- if (tvCreate) tvCreate.classList.toggle('locked', !signedIn);
1104
- }
1105
-
1106
- // The signed-out create gate: a click on "create a project" opens this instead
1107
- // of the name field, encouraging GitHub sign-in first (task 2094).
1108
- function openSignInPrompt() {
1109
- if (signInOpen || diving) return;
1110
- signInOpen = true;
1111
- if (signinGo) signinGo.setAttribute('href', acctSignInHref());
1112
- if (signinScrim) signinScrim.classList.add('show');
1113
- }
1114
- function closeSignIn() {
1115
- signInOpen = false;
1116
- if (signinScrim) signinScrim.classList.remove('show');
1117
- }
1118
- if (signinDismiss) signinDismiss.addEventListener('click', closeSignIn);
1119
- if (signinScrim) signinScrim.addEventListener('pointerdown', function (e) { if (e.target === signinScrim) closeSignIn(); });
1120
-
1121
- renderAccount(); // signed-out chip shows immediately
1122
- refreshCreateAffordances();
1123
-
1124
- if (!IS_DOOR) {
1125
- try {
1126
- fetch('/api/bongos/me', { credentials: 'same-origin' })
1127
- .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
1128
- .then(function (data) {
1129
- ME = data.builder || data.me || data;
1130
- signedIn = !!(ME && ME.github_login);
1131
- renderAccount();
1132
- refreshCreateAffordances();
1133
- if (createAuthNote && signedIn) {
1134
- createAuthNote.textContent = 'Continues into the build wizard — signed in as ' + ME.github_login + '.';
1135
- }
1136
- })
1137
- .catch(function () { /* signed out or unreachable: signed-out visuals stand */ });
1138
- } catch (_) { /* very old browsers: the default note + signed-out chip stand */ }
1139
- }
1140
-
1141
- // After the orb joins the sky, carry the name into the wizard. A moment of
1142
- // delay on purpose: the visitor should SEE their circle become part of the
1143
- // world before the wizard takes over.
1144
- function handoffToWizard(name) {
1145
- var base = IS_DOOR ? CLOUDBONGOS_ORIGIN : '';
1146
- var createPath = '/create?name=' + encodeURIComponent(name);
1147
- var target = (ME || IS_DOOR)
1148
- ? base + createPath
1149
- : base + '/api/bongos/auth/web/start?return=' + encodeURIComponent(createPath);
1150
- var toast = document.getElementById('handoff-toast');
1151
- if (toast) {
1152
- toast.textContent = 'Opening the build wizard for “' + name + '”…';
1153
- toast.classList.add('show');
1154
- }
1155
- setTimeout(function () { location.assign(target); }, 950);
1156
- }
1157
-
1158
- function openCreate() {
1159
- if (createOpen || diving) return;
1160
- if (!signedIn) { openSignInPrompt(); return; } // task 2094: sign in with GitHub first
1161
- createOpen = true;
1162
- spawnPendingOrb(); // the orb appears immediately, alongside the popup (task 1909)
1163
- scrim.classList.add('show');
1164
- createName.value = '';
1165
- setTimeout(function () { createName.focus(); }, 60);
1166
- }
1167
-
1168
- // drop a provisional orb at the click point. It is deliberately kept OUT of the
1169
- // physics `items` array (task 1912): it stays stationary and neither exerts nor
1170
- // feels gravity until confirmed. We apply the centering transform ourselves
1171
- // (render() does that for real orbs) so it sits centred on the click point.
1172
- function spawnPendingOrb() {
1173
- var id = 'new-' + (++createSeq);
1174
- var proj = {
1175
- id: id, name: 'New project', type: 'sample', builders: 1, accent: '#fb923c',
1176
- tagline: 'Your new Cloud Bongos project',
1177
- description: 'A brand-new project — set the beat and invite builders.',
1178
- tags: ['new', 'project']
1179
- };
1180
- PROJECTS.push(proj);
1181
- var it = makeCircle(proj, createAt.x, createAt.y);
1182
- it.el.classList.add('pending');
1183
- it.el.style.transform = 'translate(-50%,-50%)'; // static + centred (not driven by render)
1184
- byId[id] = proj;
1185
- pendingOrb = it;
1186
- }
1187
-
1188
- function removePendingOrb() {
1189
- if (!pendingOrb) return;
1190
- var it = pendingOrb; pendingOrb = null;
1191
- if (hoverCircle === it.el) hideHover();
1192
- var p = PROJECTS.indexOf(it.proj); if (p >= 0) PROJECTS.splice(p, 1);
1193
- delete byId[it.proj.id];
1194
- if (it.el && it.el.parentNode) it.el.parentNode.removeChild(it.el);
1195
- // never entered the sim, so nothing to rebalance.
1196
- }
1197
-
1198
- function closeCreate() {
1199
- createOpen = false;
1200
- scrim.classList.remove('show');
1201
- removePendingOrb(); // cancelled (esc / outside click / cancel) → the preview orb goes away
1202
- }
1203
-
1204
- function doCreate() {
1205
- var name = createName.value.trim();
1206
- if (!name) { createName.focus(); return; }
1207
- if (pendingOrb) {
1208
- var it = pendingOrb; pendingOrb = null; // keep it — closeCreate must not remove it now
1209
- it.proj.name = name;
1210
- it.el.classList.remove('pending');
1211
- it.el.style.transform = ''; // hand transform control back to render()
1212
- var nameEl = it.el.querySelector('.label .name');
1213
- if (nameEl) nameEl.textContent = name;
1214
- // NOW it joins the sim as a live orb: give it the field swirl + rebalance.
1215
- items.push(it); byId[it.proj.id] = it.proj;
1216
- var c = barycenter(), jit = GRAV.SWIRL * 60 * GRAV.SWIRL_JITTER;
1217
- it.vx = -(it.y - c.y) * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1218
- it.vy = (it.x - c.x) * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1219
- killDrift(); keTarget = kineticEnergy();
1220
- }
1221
- renderTable(); // keep the directory in sync if it's open
1222
- closeCreate();
1223
- handoffToWizard(name); // the orb is in the sky — now make it real (task 2087)
1224
- }
1225
- createGo.addEventListener('click', doCreate);
1226
- createCancel.addEventListener('click', closeCreate);
1227
- createName.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); doCreate(); } });
1228
- scrim.addEventListener('pointerdown', function (e) { if (e.target === scrim) closeCreate(); });
1229
-
1230
- /* ═══ view toggle + table directory (task 1731) ════════════════════════════
1231
- A scannable, searchable, sortable alternative to the circle sky. Rows are
1232
- built from the same PROJECTS data; clicking a row dives in exactly like an
1233
- orb. Circles stay the default landing (owner's call); Table is a toggle. */
1234
- function setBrowseView(v) {
1235
- browseView = v;
1236
- if (vtCircles) { vtCircles.classList.toggle('active', v === 'space'); vtCircles.setAttribute('aria-selected', v === 'space'); }
1237
- if (vtTable) { vtTable.classList.toggle('active', v === 'table'); vtTable.setAttribute('aria-selected', v === 'table'); }
1238
- }
1239
- function showCircles() {
1240
- if (diving) return;
1241
- document.body.className = 'view-space';
1242
- setBrowseView('space');
1243
- if (!userTouched) fitToView();
1244
- }
1245
- function showTable() {
1246
- if (diving) return;
1247
- hideHover(); closeSearch();
1248
- document.body.className = 'view-table';
1249
- setBrowseView('table');
1250
- renderTable();
1251
- setTimeout(function () { if (tvSearchInput) tvSearchInput.focus(); }, 40);
1252
- }
1253
- if (vtCircles) vtCircles.addEventListener('click', showCircles);
1254
- if (vtTable) vtTable.addEventListener('click', showTable);
1255
- if (tvCreate) tvCreate.addEventListener('click', openCreate);
1256
-
1257
- function typeMeta(t) {
1258
- if (t === 'self') return { label: 'Platform', cls: 'tp-self', act: 'Enter →' };
1259
- if (t === 'link') return { label: 'Live', cls: 'tp-live', act: 'Open →' };
1260
- return { label: 'Sample', cls: 'tp-sample', act: 'Preview →' };
1261
- }
1262
- function tvSortValue(p, key) {
1263
- if (key === 'builders') return (typeof p.builders === 'number' ? p.builders : -1);
1264
- if (key === 'type') return p.type;
1265
- return (p.name || '').toLowerCase();
1266
- }
1267
- function tvRows() {
1268
- var q = ((tvSearchInput && tvSearchInput.value) || '').trim().toLowerCase();
1269
- var rows = PROJECTS.filter(function (p) { return !q || matches(p, q); });
1270
- var key = tvSort.key, dir = tvSort.dir;
1271
- rows.sort(function (a, b) {
1272
- var av = tvSortValue(a, key), bv = tvSortValue(b, key);
1273
- if (av < bv) return -1 * dir;
1274
- if (av > bv) return 1 * dir;
1275
- if ((a.builders || 0) !== (b.builders || 0)) return (b.builders || 0) - (a.builders || 0); // tie-break
1276
- return (a.name || '').localeCompare(b.name || '');
1277
- });
1278
- return { rows: rows, q: q };
1279
- }
1280
- function renderTable() {
1281
- if (!tvBody) return;
1282
- var f = tvRows();
1283
- tvBody.innerHTML = '';
1284
- if (!f.rows.length) {
1285
- var tr0 = document.createElement('tr');
1286
- tr0.innerHTML = '<td class="tv-empty" colspan="5">No projects match “' + esc(f.q) + '”</td>';
1287
- tvBody.appendChild(tr0);
1288
- } else {
1289
- var frag = document.createDocumentFragment();
1290
- f.rows.forEach(function (p) {
1291
- var tm = typeMeta(p.type);
1292
- var chips = (p.tags || []).slice(0, 3).map(function (t) { return '<span class="tv-chip">' + esc(t) + '</span>'; }).join('');
1293
- var tr = document.createElement('tr');
1294
- tr.className = 'tv-row';
1295
- tr.dataset.id = p.id;
1296
- tr.style.setProperty('--acc', p.accent);
1297
- tr.innerHTML =
1298
- '<td class="tv-proj"><span class="tv-dot"></span><span class="tv-proj-txt">' +
1299
- '<span class="tv-name">' + esc(p.name) + (p.type === 'self' ? '<span class="tv-self">this platform</span>' : '') + '</span>' +
1300
- '<span class="tv-tag">' + esc(p.tagline || '') + '</span></span></td>' +
1301
- '<td class="tv-num">' + (typeof p.builders === 'number' ? p.builders : '—') + '</td>' +
1302
- '<td class="col-type"><span class="tv-pill ' + tm.cls + '">' + tm.label + '</span></td>' +
1303
- '<td class="tv-tags col-tags"><span class="tv-tagwrap">' + chips + '</span></td>' +
1304
- '<td class="tv-act col-act"><button class="tv-go" type="button">' + tm.act + '</button></td>';
1305
- frag.appendChild(tr);
1306
- });
1307
- tvBody.appendChild(frag);
1308
- }
1309
- if (tvCount) tvCount.textContent = f.q
1310
- ? (f.rows.length + ' of ' + PROJECTS.length)
1311
- : (PROJECTS.length + ' project' + (PROJECTS.length === 1 ? '' : 's'));
1312
- updateSortHeaders();
1313
- }
1314
- function updateSortHeaders() {
1315
- var ths = document.querySelectorAll('#table-view th.sortable');
1316
- Array.prototype.forEach.call(ths, function (th) {
1317
- var on = th.dataset.sort === tvSort.key;
1318
- th.classList.toggle('sorted', on);
1319
- th.setAttribute('aria-sort', on ? (tvSort.dir === 1 ? 'ascending' : 'descending') : 'none');
1320
- var ind = th.querySelector('.sort-ind');
1321
- if (ind) ind.textContent = on ? (tvSort.dir === 1 ? '▲' : '▼') : '';
1322
- });
1323
- }
1324
- if (tvBody) tvBody.addEventListener('click', function (e) {
1325
- var tr = e.target.closest('.tv-row');
1326
- if (tr) enterProject(tr.dataset.id, tr);
1327
- });
1328
- Array.prototype.forEach.call(document.querySelectorAll('#table-view th.sortable'), function (th) {
1329
- th.addEventListener('click', function () {
1330
- var key = th.dataset.sort;
1331
- if (tvSort.key === key) tvSort.dir *= -1;
1332
- else { tvSort.key = key; tvSort.dir = (key === 'builders') ? -1 : 1; }
1333
- renderTable();
1334
- });
1335
- });
1336
- if (tvSearchInput) tvSearchInput.addEventListener('input', renderTable);
1337
-
1338
- /* ═══ the entry gate — type "bongos" to enter (relocated into space) ═══════ */
1339
- var ENTRY_TARGET = 'bongos', eProg = 0, eDone = false;
1340
- function eRender() { var h = ''; for (var i = 0; i < eProg; i++) h += '<b>' + ENTRY_TARGET[i] + '</b>'; eTyped.innerHTML = h; }
1341
- function eShake() { eCli.classList.add('shake'); setTimeout(function () { eCli.classList.remove('shake'); }, 340); }
1342
- function eFlashHint(msg) {
1343
- eHint.classList.add('flash-x'); if (msg) eHint.innerHTML = msg;
1344
- setTimeout(function () { eHint.classList.remove('flash-x'); if (!eDone && !entering) eHint.innerHTML = 'type <b>bongos</b> to enter'; }, 820);
1345
- }
1346
- function eComplete() { eDone = true; eCaret.style.display = 'none'; eHint.innerHTML = 'press <b>enter</b>'; eEnter.classList.add('lit'); }
1347
- function entryKey(e) {
1348
- if (entering) return;
1349
- if (e.key === 'Enter') { e.preventDefault(); if (eDone) enterSite(); else { eShake(); eFlashHint('finish typing <b>bongos</b>'); } return; }
1350
- if (e.key === 'Backspace') { e.preventDefault(); if (eProg > 0 && !eDone) { eProg--; eRender(); } return; }
1351
- if (e.key && e.key.length === 1 && !eDone) {
1352
- e.preventDefault();
1353
- if (e.key.toLowerCase() === ENTRY_TARGET[eProg]) { eProg++; eRender(); if (eProg === ENTRY_TARGET.length) eComplete(); }
1354
- else eShake();
1355
- }
1356
- }
1357
- if (eEnter) eEnter.addEventListener('click', function () { if (eDone && !entering) enterSite(); });
1358
-
1359
- function enterSite() {
1360
- if (entering) return;
1361
- entering = true;
1362
- flash.style.setProperty('--fx', '50%'); flash.style.setProperty('--fy', '46%');
1363
- flash.classList.remove('go'); void flash.offsetWidth; flash.classList.add('go');
1364
- entryEl.classList.add('leaving');
1365
- // Vanity door (claudebongos.com / geminibongos.com): play the same enter
1366
- // flash, then hand off to cloudbongos.com and land straight on the orbs
1367
- // (?entered=1). A door never reveals a local space of its own — the orbs
1368
- // live on cloudbongos.com.
1369
- if (BRAND.redirect) {
1370
- setTimeout(function () { location.href = BRAND.redirect; }, 420);
1371
- return;
1372
- }
1373
- setTimeout(function () {
1374
- document.body.className = 'view-space';
1375
- setBrowseView('space');
1376
- userTouched = false;
1377
- fitToView();
1378
- canvas.style.opacity = '0';
1379
- canvas.style.transition = 'opacity .55s ease';
1380
- setTimeout(function () { canvas.style.opacity = '1'; }, 30);
1381
- }, 380);
1382
- }
1383
-
1384
- // ESC on the circles page rises back UP to the entry gate — retype bongos.
1385
- function backToEntry() {
1386
- hideHover(); closeSearch();
1387
- setBrowseView('space');
1388
- entering = false; eProg = 0; eDone = false;
1389
- eEnter.classList.remove('lit'); eCaret.style.display = 'inline-block';
1390
- eHint.innerHTML = 'type <b>bongos</b> to enter'; eRender();
1391
- entryEl.classList.remove('leaving');
1392
- flash.style.setProperty('--fx', '50%'); flash.style.setProperty('--fy', '46%');
1393
- flash.classList.remove('go'); void flash.offsetWidth; flash.classList.add('go');
1394
- document.body.className = 'view-entry';
1395
- canvas.style.opacity = '0';
1396
- userTouched = false;
1397
- }
1398
-
1399
- // Open the orbs directly — no type-bongos step, no entry flash. Used when a
1400
- // vanity door already handled the entry and redirected here (?entered=1), so
1401
- // the visitor lands right on the sky instead of typing "bongos" twice.
1402
- function openSpaceDirect() {
1403
- entering = true;
1404
- eCaret.style.display = 'none';
1405
- entryEl.classList.add('leaving');
1406
- document.body.className = 'view-space';
1407
- // The <head> anti-flash rule (html.direct-orbs #entry{display:none}) has done
1408
- // its job — hand off to the view classes and drop it so a later ESC can bring
1409
- // the entry back (backToEntry → view-entry).
1410
- try { document.documentElement.classList.remove('direct-orbs'); } catch (e) {}
1411
- setBrowseView('space');
1412
- userTouched = false;
1413
- fitToView();
1414
- canvas.style.opacity = '0';
1415
- canvas.style.transition = 'opacity .55s ease';
1416
- setTimeout(function () { canvas.style.opacity = '1'; }, 30);
1417
- }
1418
-
1419
- /* ─── starfield (task 1725: actually in space) ───────────────────────────── */
1420
- function makeStars(n) {
1421
- if (!starfield) return;
1422
- var frag = document.createDocumentFragment();
1423
- for (var i = 0; i < n; i++) {
1424
- var s = document.createElement('div');
1425
- s.className = 'star';
1426
- var big = Math.random() < 0.14;
1427
- var sz = big ? (Math.random() * 2.0 + 1.8) : (Math.random() * 1.3 + 0.6);
1428
- s.style.width = sz.toFixed(1) + 'px'; s.style.height = sz.toFixed(1) + 'px';
1429
- s.style.left = (Math.random() * 100).toFixed(2) + '%';
1430
- s.style.top = (Math.random() * 100).toFixed(2) + '%';
1431
- s.style.opacity = (Math.random() * 0.55 + 0.3).toFixed(2);
1432
- if (Math.random() < 0.35) {
1433
- s.style.animation = 'twinkle ' + (2.2 + Math.random() * 3).toFixed(1) + 's ease-in-out infinite';
1434
- s.style.animationDelay = (Math.random() * 3).toFixed(1) + 's';
1435
- }
1436
- frag.appendChild(s);
1437
- }
1438
- starfield.appendChild(frag);
1439
- }
1440
-
1441
- /* ─── Bongo Buddha idle mascot (ADR 0088) ──────────────────────────────────
1442
- Injected + cycled above the download-page search bar. The sprite's own
1443
- motion IS the bongo-play, so under prefers-reduced-motion we hold a single
1444
- frame rather than cycling. 44 webp frames @ 12fps — same pattern as
1445
- anim/mascot.html. Lives inside #land, which only renders in view-download,
1446
- so it costs nothing off that page. */
1447
- function mountBuddha() {
1448
- var fig = document.getElementById('buddha-fig');
1449
- if (!fig) return;
1450
- var N = 44, FPS = 12, frames = [];
1451
- for (var i = 0; i < N; i++) {
1452
- var im = document.createElement('img');
1453
- im.src = '../assets/anim/idle/frame_' + (i < 10 ? '0' + i : i) + '.webp';
1454
- im.alt = ''; im.decoding = 'async';
1455
- im.style.display = i === 0 ? 'block' : 'none';
1456
- fig.appendChild(im); frames.push(im);
1457
- }
1458
- if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1459
- // rAF + time accumulator: locked to FPS regardless of display refresh, and
1460
- // it pauses when the tab is hidden (matches the download scene's tick loop).
1461
- var cur = 0, prev = performance.now(), acc = 0, SPF = 1000 / FPS;
1462
- (function loop(now) {
1463
- acc += now - prev; prev = now;
1464
- if (acc >= SPF) {
1465
- var adv = Math.floor(acc / SPF); acc -= adv * SPF;
1466
- frames[cur].style.display = 'none';
1467
- cur = (cur + adv) % N;
1468
- frames[cur].style.display = 'block';
1469
- }
1470
- requestAnimationFrame(loop);
1471
- })(prev);
1472
- }
1473
-
1474
- /* ─── self-typing logo (task 1902) ─────────────────────────────────────────
1475
- The top-left wordmark idles as the brand "Cloud Bongos" for a while after
1476
- load, then comes alive like a terminal: it swaps the lead word "Cloud" for a
1477
- lowercase platform (claude, gemini, chatgpt, …) and re-TYPES "bongos" after
1478
- it, with a blinking caret — a living nudge that the ritual works in any AI.
1479
- Reduced motion holds the static brand wordmark. */
1480
- function mountLogoTerminal() {
1481
- // Re-entrancy guard: only one typing chain may ever run. Two concurrent
1482
- // cycles mutating #mk-tail would interleave letters into garbled text; the
1483
- // single boot call can't do that today, but a future re-mount (or a
1484
- // tab-throttle timer catch-up) must not be able to spawn a second chain.
1485
- if (mountLogoTerminal._mounted) return;
1486
- mountLogoTerminal._mounted = true;
1487
- var lead = document.getElementById('mk-lead');
1488
- var tail = document.getElementById('mk-tail');
1489
- var caret = document.getElementById('mk-caret');
1490
- if (!lead || !tail) return;
1491
- lead.textContent = BRAND.display.toLowerCase(); // rest: lowercase (terminal format, door-aware)
1492
- tail.textContent = ' bongos'; // lowercase "cloud bongos" at rest, matching the terminal
1493
- if (prefersReduced) return;
1494
-
1495
- var PLATFORMS = ['claude', 'gemini', 'chatgpt', 'copilot', 'grok', 'perplexity'];
1496
- // leading nbsp = the word gap, typed AFTER the pre-type blink so the caret
1497
- // first sits flush against the platform word (no space), like the entry screen.
1498
- var CHARS = ' bongos'.split('');
1499
- var pi = 0;
1500
- var IDLE_START = 8000, TYPE_MS = 210, ERASE_MS = 130, HOLD_MS = 5200, GAP_MS = 1000, BLINK_BEFORE = 2100;
1501
-
1502
- // type/erase one letter at a time as its own popping <b> (the entry terminal's
1503
- // per-letter pop), so the caret always trails the last real character flush.
1504
- function typeWord(done) {
1505
- tail.innerHTML = '';
1506
- var i = 0;
1507
- (function step() {
1508
- if (i >= CHARS.length) { done(); return; }
1509
- var b = document.createElement('b');
1510
- b.className = 'mk-ch';
1511
- b.textContent = CHARS[i];
1512
- tail.appendChild(b);
1513
- i++;
1514
- setTimeout(step, TYPE_MS);
1515
- })();
1516
- }
1517
- function eraseWord(done) {
1518
- (function step() {
1519
- if (!tail.lastChild) { done(); return; }
1520
- tail.removeChild(tail.lastChild);
1521
- setTimeout(step, ERASE_MS);
1522
- })();
1523
- }
1524
- function cycle() {
1525
- lead.classList.add('swap'); // fade the old lead out
1526
- setTimeout(function () {
1527
- lead.textContent = PLATFORMS[pi]; // lowercase platform
1528
- lead.classList.remove('swap');
1529
- tail.innerHTML = ''; // empty → caret sits flush right after the new word
1530
- if (caret) caret.classList.add('on');
1531
- setTimeout(function () { // let the caret blink a couple of times first
1532
- typeWord(function () { // then type " bongos" letter by letter
1533
- setTimeout(function () { // hold on the full "{platform} bongos"
1534
- eraseWord(function () {
1535
- pi = (pi + 1) % PLATFORMS.length;
1536
- setTimeout(cycle, GAP_MS);
1537
- });
1538
- }, HOLD_MS);
1539
- });
1540
- }, BLINK_BEFORE);
1541
- }, 360);
1542
- }
1543
- setTimeout(cycle, IDLE_START);
1544
- }
1545
-
1546
- /* ─── minimap (task 1897) ─────────────────────────────────────────────────
1547
- A live overview of the whole orb cluster in the bottom-right, with a
1548
- viewport rectangle for the current pan/zoom. World is mapped from a fixed
1549
- square centred on the origin (killDrift keeps the cluster there) so the
1550
- view rect doesn't jitter as the bounds breathe. Click/drag it to recentre. */
1551
- var mmEl = document.getElementById('minimap');
1552
- var mmCanvas = document.getElementById('minimap-canvas');
1553
- var mmctx = mmCanvas ? mmCanvas.getContext('2d') : null;
1554
- var mmW = 0, mmH = 0, mmDPR = 1;
1555
- var MM_HALF = 1000; // world half-extent (px) the minimap spans from the origin
1556
-
1557
- function sizeMinimap() {
1558
- if (!mmCanvas || !mmEl) return;
1559
- mmDPR = window.devicePixelRatio || 1;
1560
- var r = mmEl.getBoundingClientRect();
1561
- mmW = r.width; mmH = r.height;
1562
- mmCanvas.width = Math.max(1, Math.round(mmW * mmDPR));
1563
- mmCanvas.height = Math.max(1, Math.round(mmH * mmDPR));
1564
- }
1565
- function mmScale() { return Math.min(mmW, mmH) / (2 * MM_HALF); }
1566
-
1567
- function drawMinimap() {
1568
- if (!mmctx || !isSpace()) return;
1569
- // sizeMinimap() ran once at boot while the minimap was still display:none
1570
- // (body.view-entry), so it measured 0×0 — and it was never re-run on entering
1571
- // the orbs. Re-measure the first frame it's actually on screen: otherwise it
1572
- // renders blank AND mmScale() stays 0, so a click divides by zero and blows
1573
- // panX/panY up to NaN — which kills the grid and freezes every control.
1574
- if (!mmW) sizeMinimap();
1575
- if (!mmW) return; // still not laid out this frame — retry next frame
1576
- mmctx.setTransform(mmDPR, 0, 0, mmDPR, 0, 0);
1577
- mmctx.clearRect(0, 0, mmW, mmH);
1578
- var s = mmScale(), cxp = mmW / 2, cyp = mmH / 2;
1579
- // the viewport rectangle (visible world region → map)
1580
- var vx0 = (0 - panX) / zoom, vy0 = (0 - panY) / zoom;
1581
- var vx1 = (W - panX) / zoom, vy1 = (H - panY) / zoom;
1582
- mmctx.fillStyle = 'rgba(255,217,138,0.10)';
1583
- mmctx.strokeStyle = 'rgba(255,217,138,0.85)';
1584
- mmctx.lineWidth = 1;
1585
- mmctx.fillRect(cxp + vx0 * s, cyp + vy0 * s, (vx1 - vx0) * s, (vy1 - vy0) * s);
1586
- mmctx.strokeRect(cxp + vx0 * s, cyp + vy0 * s, (vx1 - vx0) * s, (vy1 - vy0) * s);
1587
- // the orbs
1588
- for (var i = 0; i < items.length; i++) {
1589
- var it = items[i], mx = cxp + it.x * s, my = cyp + it.y * s, rr = Math.max(1.6, it.r * s);
1590
- mmctx.beginPath(); mmctx.arc(mx, my, rr, 0, Math.PI * 2);
1591
- mmctx.fillStyle = it.proj.accent || '#fb923c';
1592
- mmctx.globalAlpha = it.proj.type === 'self' ? 1 : 0.9; mmctx.fill();
1593
- if (it.proj.type === 'self') { mmctx.globalAlpha = 1; mmctx.lineWidth = 1.5; mmctx.strokeStyle = 'rgba(255,255,255,.9)'; mmctx.stroke(); }
1594
- }
1595
- mmctx.globalAlpha = 1;
1596
- }
1597
-
1598
- if (mmCanvas) {
1599
- var mmDown = false;
1600
- var mmRecenter = function (e) {
1601
- // Measure the minimap LIVE from its on-screen rect rather than the cached
1602
- // mmW/mmH. sizeMinimap() first runs at boot while the minimap is still
1603
- // display:none (0×0) and only re-runs on window resize — so before any resize
1604
- // the cached scale is 0, and a click would divide pan up to ±Infinity → NaN,
1605
- // wiping the grid and freezing every control. The live rect is always correct.
1606
- var r = mmCanvas.getBoundingClientRect();
1607
- var s = Math.min(r.width, r.height) / (2 * MM_HALF);
1608
- if (!s) return; // truly not laid out (hidden / zero-size) — ignore, never /0
1609
- var wx = (e.clientX - r.left - r.width / 2) / s, wy = (e.clientY - r.top - r.height / 2) / s;
1610
- userTouched = true;
1611
- panX = W / 2 - wx * zoom; panY = H / 2 - wy * zoom;
1612
- clampPan(); applyCanvas(); hideHover();
1613
- };
1614
- // stopPropagation so a click on the minimap (a child of #space) never reaches
1615
- // the #space pan/create handler underneath it.
1616
- mmCanvas.addEventListener('pointerdown', function (e) {
1617
- if (diving || anyOverlay()) return;
1618
- mmDown = true; try { mmCanvas.setPointerCapture(e.pointerId); } catch (_) {}
1619
- mmRecenter(e); e.preventDefault(); e.stopPropagation();
1620
- });
1621
- mmCanvas.addEventListener('pointermove', function (e) { if (mmDown) { mmRecenter(e); e.stopPropagation(); } });
1622
- window.addEventListener('pointerup', function () { mmDown = false; });
1623
- }
1624
-
1625
- /* ─── space whale (task 1898) ──────────────────────────────────────────────
1626
- A whale flies from the Cloud Bongos orb to a project when it UPDATES, and
1627
- flies BACK (project → Cloud Bongos) when a project pushes a module. It's
1628
- positioned in screen space each frame from the two orbs' live positions, so
1629
- it tracks the drifting cluster + any pan/zoom. On arrival the target orb
1630
- pings a gold ring. The self-driving loop keeps the sky alive; a real feed can
1631
- drive it through the window.cbWhale seam (the aiAnswer() "wire it later"
1632
- precedent). The art faces left, so a rightward flight flips horizontally. */
1633
- var whaleEl = document.getElementById('whale');
1634
- var whaleImg = document.getElementById('whale-img');
1635
- var WHALE_SRC = '../world/assets/creatures/creature-whale-glide.png'; // cargo pose = ../world/assets/creatures/creature-whale-cargo.png
1636
- var flight = null;
1637
- var WHALE_DUR = 8500; // slow, drifting flight
1638
- var WHALE_LEAD_CAP = 2.2; // seconds — cap the velocity extrapolation so a chaotic orb can't fling the aim point
1639
-
1640
- function itemForId(id) { for (var i = 0; i < items.length; i++) if (items[i].proj.id === id) return items[i]; return null; }
1641
- function minOrbDiam() { var m = Infinity; for (var i = 0; i < items.length; i++) m = Math.min(m, items[i].r * 2); return isFinite(m) ? m : 80; }
1642
-
1643
- function startFlight(fromId, toId, type) {
1644
- if (flight || !whaleEl || prefersReduced) return;
1645
- var a = itemForId(fromId), b = itemForId(toId);
1646
- if (!a || !b || a === b) return;
1647
- if (whaleImg && whaleImg.getAttribute('src') !== WHALE_SRC) whaleImg.src = WHALE_SRC;
1648
- // sx/sy = launch point (source orb, world coords). worldW = whale width in
1649
- // world px — no more than 1/3 of the SMALLEST orb, so it scales with the orbs
1650
- // and reads as a little creature next to a world. dir captured once (no mid-
1651
- // flight flips). Everything lives in world space so it pans/zooms with the sky.
1652
- flight = { sx: a.x, sy: a.y, b: b, t0: performance.now(), dur: WHALE_DUR,
1653
- type: type || 'update', worldW: minOrbDiam() / 3, dir: (b.x >= a.x) ? -1 : 1 };
1654
- // Size + position on THIS tick, before .show fades it in — otherwise the
1655
- // first frame renders at the image's intrinsic ~1280px width (the full-screen
1656
- // "balloon" bug) until the rAF loop's first updateWhale() shrinks it.
1657
- updateWhale();
1658
- whaleEl.classList.add('show');
1659
- }
1660
-
1661
- function endFlight() {
1662
- if (flight && flight.b && flight.b.el) {
1663
- var el = flight.b.el;
1664
- el.classList.remove('pinged'); void el.offsetWidth; el.classList.add('pinged');
1665
- setTimeout((function (e) { return function () { e.classList.remove('pinged'); }; })(el), 950);
1666
- }
1667
- flight = null;
1668
- if (whaleEl) whaleEl.classList.remove('show');
1669
- }
1670
-
1671
- function updateWhale() {
1672
- if (!whaleEl || !flight) return;
1673
- var elapsed = performance.now() - flight.t0;
1674
- var p = elapsed / flight.dur;
1675
- if (p >= 1) { endFlight(); return; }
1676
- var pe = p * p * (3 - 2 * p); // smoothstep ease
1677
- var b = flight.b;
1678
- // INTERCEPT, don't chase — and follow the orb's CURVED orbital path, not a
1679
- // straight line: a 2nd-order prediction using the orb's live acceleration
1680
- // (it.fx/fy, the n-body force per step): pos + vel·t + ½·accel·t². Recomputed
1681
- // every frame (a predictor-corrector), with the lead t decaying to 0 at
1682
- // arrival so the aim converges exactly onto the live orb.
1683
- var lead = Math.min((flight.dur - elapsed) / 1000, WHALE_LEAD_CAP);
1684
- var half = 0.5 * lead * lead;
1685
- var ax = b.fx || 0, ay = b.fy || 0;
1686
- var tx = b.x + b.vx * lead + ax * half;
1687
- var ty = b.y + b.vy * lead + ay * half;
1688
- var wx = flight.sx + (tx - flight.sx) * pe;
1689
- var wy = flight.sy + (ty - flight.sy) * pe - 34 * Math.sin(p * Math.PI); // gentle world-space arc
1690
- var x = panX + wx * zoom, y = panY + wy * zoom;
1691
- whaleEl.style.width = (flight.worldW * zoom).toFixed(1) + 'px';
1692
- whaleEl.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px) translate(-50%,-50%) scaleX(' + flight.dir + ')';
1693
- }
1694
-
1695
- // self-driving demo feed: gently animate real-looking project activity so the
1696
- // sky stays alive until a live registry feed is wired to window.cbWhale.
1697
- function maybeFly() {
1698
- if (flight || !isSpace() || diving || document.hidden) return;
1699
- var others = items.filter(function (it) { return it.proj.type !== 'self'; });
1700
- if (!others.length || !itemForId('cloud-bongos')) return;
1701
- var target = others[Math.floor(Math.random() * others.length)];
1702
- if (Math.random() < 0.4) startFlight(target.proj.id, 'cloud-bongos', 'module'); // a module push — fly back
1703
- else startFlight('cloud-bongos', target.proj.id, 'update'); // a project update
1704
- }
1705
- function scheduleWhale() {
1706
- setTimeout(function () { maybeFly(); scheduleWhale(); }, 9000 + Math.random() * 9000);
1707
- }
1708
- // dormant seam for a real feed (the aiAnswer precedent): cbWhale.update(id) /
1709
- // cbWhale.modulePush(id).
1710
- window.cbWhale = {
1711
- update: function (id) { startFlight('cloud-bongos', id, 'update'); },
1712
- modulePush: function (id) { startFlight(id, 'cloud-bongos', 'module'); }
1713
- };
1714
-
1715
- /* ─── global keys ─────────────────────────────────────────────────────────── */
1716
- window.addEventListener('keydown', function (e) {
1717
- // DIVE: any key skips the in-flight dive cinematic and drops into the world
1718
- if (descent && descent.classList.contains('playing')) { e.preventDefault(); landInWorld(); return; }
1719
- // ENTRY: every key drives the type-bongos terminal
1720
- if (document.body.classList.contains('view-entry')) { entryKey(e); return; }
1721
-
1722
- if (e.key === 'Escape') {
1723
- if (signInOpen) { e.preventDefault(); closeSignIn(); return; }
1724
- if (createOpen) { e.preventDefault(); closeCreate(); return; }
1725
- if (searchOpen) { e.preventDefault(); closeSearch(); return; }
1726
- if (document.body.classList.contains('view-download') || document.body.classList.contains('view-detail')) {
1727
- e.preventDefault(); exitToSpace(); return;
1728
- }
1729
- if (document.body.classList.contains('view-table')) {
1730
- e.preventDefault();
1731
- if (tvSearchInput && document.activeElement === tvSearchInput && tvSearchInput.value) {
1732
- tvSearchInput.value = ''; renderTable();
1733
- } else backToEntry();
1734
- return;
1735
- }
1736
- if (document.body.classList.contains('view-space')) { e.preventDefault(); backToEntry(); return; }
1737
- return;
1738
- }
1739
-
1740
- });
1741
-
1742
- /* ─── SHIFT-tap toggles the in-view search (task 1901) ─────────────────────
1743
- A clean Shift tap (pressed + released with no other key in between) opens
1744
- or closes search. Tracking "used as a modifier" means Shift held for a
1745
- capital while typing in the search box never toggles it. Esc also closes. */
1746
- var shiftDown = false, shiftAsMod = false;
1747
- window.addEventListener('keydown', function (e) {
1748
- if (e.key === 'Shift') { shiftDown = true; shiftAsMod = false; }
1749
- else if (shiftDown) shiftAsMod = true; // Shift + another key → a modifier, not a tap
1750
- }, true);
1751
- window.addEventListener('keyup', function (e) {
1752
- if (e.key !== 'Shift') return;
1753
- var tap = shiftDown && !shiftAsMod;
1754
- shiftDown = false; shiftAsMod = false;
1755
- if (!tap || diving || createOpen || !isSpace()) return;
1756
- searchOpen ? closeSearch() : openSearch();
1757
- });
1758
-
1759
- /* ─── resize ─────────────────────────────────────────────────────────────── */
1760
- addEventListener('resize', function () {
1761
- W = innerWidth; H = innerHeight;
1762
- sizeGrid();
1763
- sizeMinimap();
1764
- if (!userTouched && (document.body.classList.contains('view-space') || document.body.classList.contains('view-entry'))) fitToView();
1765
- else { clampPan(); applyCanvas(); }
1766
- });
1767
-
1768
- /* ─── live project catalog (task 1002260) ────────────────────────────────
1769
- The sky's real content. Fetch the platform's public, curated featured-
1770
- projects feed and pop each LIVE project in via the same dynamic-add path
1771
- the create flow uses (makeCircle → items/byId → swirl → rebalance). Same-
1772
- origin on the apex (Caddy proxies /api/* to the app); on a vanity door the
1773
- feed lives on the hub and the endpoint is CORS-enabled. Fails soft — an
1774
- empty/erroring feed just leaves the Cloud Bongos self-orb (a sparse sky). */
1775
- function catalogAccent(seed) {
1776
- var h = 2166136261; seed = String(seed || '');
1777
- for (var i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = (h * 16777619) >>> 0; }
1778
- return 'hsl(' + (h % 360) + ', 66%, 60%)';
1779
- }
1780
- function catalogSlug(origin) {
1781
- try { return new URL(origin).hostname.replace(/[^a-z0-9]+/gi, '-'); }
1782
- catch (e) { return String(origin || '').replace(/[^a-z0-9]+/gi, '-'); }
1783
- }
1784
- function projectFromCatalog(row) {
1785
- var origin = row.origin || '';
1786
- return {
1787
- id: 'cat-' + catalogSlug(origin),
1788
- name: row.name || catalogSlug(origin),
1789
- type: 'link',
1790
- url: origin,
1791
- builders: null, // the public feed carries no count
1792
- accent: catalogAccent(origin || row.name),
1793
- tagline: row.tagline || '',
1794
- description: row.tagline || '',
1795
- tags: ((row.name || '') + ' ' + (row.tagline || '')).toLowerCase().split(/\s+/).filter(Boolean),
1796
- preview: row.art_url || undefined
1797
- };
1798
- }
1799
- function addLiveOrb(proj) {
1800
- if (byId[proj.id]) return; // already placed
1801
- try { if (proj.url && new URL(proj.url).hostname === location.hostname) return; } catch (e) {} // never dupe ourselves
1802
- PROJECTS.push(proj);
1803
- var c = items.length ? barycenter() : { x: 0, y: 0 };
1804
- var ang = Math.random() * Math.PI * 2, rad = SPIRAL_STEP * Math.sqrt(items.length + 1);
1805
- var it = makeCircle(proj, c.x + Math.cos(ang) * rad, c.y + Math.sin(ang) * rad * 0.82);
1806
- items.push(it); byId[proj.id] = proj;
1807
- var b = barycenter(), jit = GRAV.SWIRL * 60 * GRAV.SWIRL_JITTER; // join the sim (mirror doCreate)
1808
- it.vx = -(it.y - b.y) * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1809
- it.vy = (it.x - b.x) * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1810
- }
1811
- function loadProjects() {
1812
- var url = (IS_DOOR ? 'https://cloudbongos.com' : '') + '/api/bongos/projects/featured';
1813
- return fetch(url, { credentials: 'same-origin' })
1814
- .then(function (r) { return r.ok ? r.json() : null; })
1815
- .then(function (j) {
1816
- var rows = (j && j.projects) || [];
1817
- if (!rows.length) return;
1818
- rows.forEach(function (row) { addLiveOrb(projectFromCatalog(row)); });
1819
- killDrift(); keTarget = kineticEnergy();
1820
- renderTable();
1821
- if (!userTouched && !isSpace()) fitToView();
1822
- })
1823
- .catch(function () { /* sparse sky is fine */ });
1824
- }
1825
-
1826
- /* ─── boot ───────────────────────────────────────────────────────────────── */
1827
- document.body.className = 'view-entry';
1828
- makeStars(140);
1829
- sizeGrid();
1830
- sizeMinimap();
1831
- mountBuddha();
1832
- mountLogoTerminal();
1833
- scheduleWhale();
1834
- layout();
1835
- eRender();
1836
- loadProjects(); // pop the real, live catalog projects into the sky (task 1002260)
1837
- // Frame the constellation now (revealed when the visitor types bongos). The
1838
- // circles stay hidden behind the entry terminal until then.
1839
- fitToView();
1840
- canvas.style.opacity = '0';
1841
- // Arrived from a vanity door (?entered=1)? Skip the type-bongos entry and
1842
- // open the orbs immediately.
1843
- if (wantsDirectOrbs()) openSpaceDirect();
1844
- addEventListener('load', function () { if (!userTouched && !isSpace()) fitToView(); });
1845
- setTimeout(function () { if (!userTouched && !isSpace()) fitToView(); }, 60);
1846
- setTimeout(function () { if (!userTouched && !isSpace()) fitToView(); }, 300);
1847
- requestAnimationFrame(tick);
1848
- })();