@bongos/core 1.19.93 → 1.19.95
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.
- package/.bongos-core.json +25 -90
- package/docs/adr/0152-landing-gate-served-by-the-core-app.md +14 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +240 -0
- package/public-landing/index.html +10 -4
- package/public-landing/projects.html +663 -44
- package/scripts/gds/run-unit-tests.js +0 -1
- package/src/bongos/module-scope-map.js +1 -1
- package/src/module-api.js +5 -1
- package/src/platform-server.js +24 -42
- package/tests/known_failing_ratchet.mjs +7 -8
- package/tests/landing_page.mjs +15 -2
- package/tests/projects_hub.mjs +205 -0
- package/public-landing/gate/README.md +0 -137
- package/public-landing/gate/dispmaps/bar.png +0 -0
- package/public-landing/gate/dispmaps/flat.png +0 -0
- package/public-landing/gate/dispmaps/key.png +0 -0
- package/public-landing/gate/index.html +0 -634
- package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
- package/public-landing/gate/previews/off-the-boats.jpg +0 -0
- package/public-landing/gate/space.css +0 -809
- package/public-landing/gate/space.js +0 -1848
- package/public-landing/gate/sprites/enter.png +0 -0
- package/public-landing/gate/sprites/search.png +0 -0
- package/public-landing/gate/sprites/terminal-blank.png +0 -0
- package/public-landing/gate/sprites/tile.png +0 -0
- package/tests/gate_launch_metadata.mjs +0 -67
|
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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
|
-
})();
|