@bongos/core 1.19.112 → 1.19.114
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 +11 -11
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +22 -0
- package/public-landing/projects.html +489 -12
- package/src/module-api.js +5 -1
- package/tests/projects_hub.mjs +69 -0
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.114",
|
|
6
|
+
"core_contract": "1.19.114",
|
|
7
|
+
"source_commit": "0de67d2652248283befaf1c2ee7ecbf83fa50346",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-14T15:
|
|
9
|
+
"built_at": "2026-08-14T15:52:55.624Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 302,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 1559,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "f530c12a47c766a901cbfd537e9aa4cdaa95663cfda270bbc25c8575fee0bede",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -4152,12 +4152,12 @@
|
|
|
4152
4152
|
{
|
|
4153
4153
|
"path": "package-lock.json",
|
|
4154
4154
|
"mode": "0000644",
|
|
4155
|
-
"sha256": "
|
|
4155
|
+
"sha256": "de51e915b8cf14b11d3cc68cc6106a2baed1bf351da2c94be74ff65b42c2ac09"
|
|
4156
4156
|
},
|
|
4157
4157
|
{
|
|
4158
4158
|
"path": "package.json",
|
|
4159
4159
|
"mode": "0000644",
|
|
4160
|
-
"sha256": "
|
|
4160
|
+
"sha256": "2fdb7573bd5efafc25771e96ce4a3fc790a8d5e83483dac1c3d3bcd3f1b513fe"
|
|
4161
4161
|
},
|
|
4162
4162
|
{
|
|
4163
4163
|
"path": "public-docs/index.html",
|
|
@@ -4397,7 +4397,7 @@
|
|
|
4397
4397
|
{
|
|
4398
4398
|
"path": "public-landing/assets/cosmos.css",
|
|
4399
4399
|
"mode": "0000644",
|
|
4400
|
-
"sha256": "
|
|
4400
|
+
"sha256": "ad327f42773674cc3a6deb3f65e0f9da89fd1c4c47947c513a958a0b8d37ff5f"
|
|
4401
4401
|
},
|
|
4402
4402
|
{
|
|
4403
4403
|
"path": "public-landing/buddha.png",
|
|
@@ -4417,7 +4417,7 @@
|
|
|
4417
4417
|
{
|
|
4418
4418
|
"path": "public-landing/projects.html",
|
|
4419
4419
|
"mode": "0000644",
|
|
4420
|
-
"sha256": "
|
|
4420
|
+
"sha256": "1a58ca91bed5e4658599b1290465ee83b5845b7465376e13d19bd79ab6ac3dc1"
|
|
4421
4421
|
},
|
|
4422
4422
|
{
|
|
4423
4423
|
"path": "public-landing/world/assets/clouds/cloud-island-large.png",
|
|
@@ -5732,7 +5732,7 @@
|
|
|
5732
5732
|
{
|
|
5733
5733
|
"path": "src/module-api.js",
|
|
5734
5734
|
"mode": "0000644",
|
|
5735
|
-
"sha256": "
|
|
5735
|
+
"sha256": "766abd484728dd8ceb692aa3a4e5a1c4e68f9767503af3982ba69bc6deabe0c6"
|
|
5736
5736
|
},
|
|
5737
5737
|
{
|
|
5738
5738
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7257,7 +7257,7 @@
|
|
|
7257
7257
|
{
|
|
7258
7258
|
"path": "tests/projects_hub.mjs",
|
|
7259
7259
|
"mode": "0000644",
|
|
7260
|
-
"sha256": "
|
|
7260
|
+
"sha256": "dc915f8d09f97eb4a359bf4627a4e4dddb77daf2cd438084bae1b4abbe04963d"
|
|
7261
7261
|
},
|
|
7262
7262
|
{
|
|
7263
7263
|
"path": "tests/provision.mjs",
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.114",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.114",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.114",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -577,6 +577,28 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
|
|
|
577
577
|
.tbl .colName{width:auto;}
|
|
578
578
|
@media (max-width:780px){.tbl .colTags{display:none;}}
|
|
579
579
|
|
|
580
|
+
/* ── the living sky (task 1002762): camera, spotlight, hover card ──
|
|
581
|
+
The camera is ONE transform on .skyCam; orbs carry only their own physics
|
|
582
|
+
delta. transform-origin 0 0 is load-bearing — the pan/zoom algebra assumes
|
|
583
|
+
the layer scales about its origin. */
|
|
584
|
+
.field .skyCam{position:absolute;inset:0;transform-origin:0 0;will-change:transform;}
|
|
585
|
+
.fieldWrap{cursor:grab;touch-action:pan-y pinch-zoom;user-select:none;-webkit-user-select:none;}
|
|
586
|
+
.fieldWrap.panning,.fieldWrap.panning .worldOrb{cursor:grabbing;}
|
|
587
|
+
.worldOrb{transition:opacity .25s ease;}
|
|
588
|
+
/* a dimmed orb is backdrop — no pointer, no focus (the JS drops it from the
|
|
589
|
+
tab order); at .25 opacity its label could not carry interaction honestly */
|
|
590
|
+
.worldOrb.dim{opacity:.25;filter:saturate(.35);pointer-events:none;}
|
|
591
|
+
/* the spotlight glow is an OUTLINE + drop-shadow, never box-shadow: the owned
|
|
592
|
+
ring already lives on the disc's box-shadow, and a same-specificity
|
|
593
|
+
box-shadow here would silently delete it by source order */
|
|
594
|
+
.worldOrb.match .disc{outline:3px solid color-mix(in srgb,var(--acc,var(--accent)) 55%,transparent);
|
|
595
|
+
outline-offset:3px;filter:drop-shadow(0 0 16px color-mix(in srgb,var(--acc,var(--accent)) 60%,transparent));}
|
|
596
|
+
.field .hoverCard{position:absolute;z-index:6;pointer-events:none;display:flex;flex-direction:column;gap:2px;
|
|
597
|
+
background:var(--bg-card);border:1.5px solid var(--line);border-radius:12px;box-shadow:var(--shadow-card);
|
|
598
|
+
padding:9px 13px;max-width:240px;}
|
|
599
|
+
.field .hoverCard .hn{font-family:var(--font-display);font-weight:700;font-size:13.5px;color:var(--ink);white-space:nowrap;}
|
|
600
|
+
.field .hoverCard .ht{font-size:12px;color:var(--ink-faint);font-weight:600;}
|
|
601
|
+
|
|
580
602
|
/* ── the project card ──
|
|
581
603
|
An overlay INSIDE the map, not a destination: opening a project must not cost
|
|
582
604
|
you the sky you were reading (direction §9). It sits above .shell (z-index 40)
|
|
@@ -147,6 +147,13 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
147
147
|
<div class="field" id="orbField">
|
|
148
148
|
<div class="ground"></div>
|
|
149
149
|
<div class="stars" id="mapStars" aria-hidden="true"></div>
|
|
150
|
+
<!-- The camera (task 1002762). Pan and zoom are ONE transform on this
|
|
151
|
+
layer; orbs write only their own physics delta, so the two compose
|
|
152
|
+
for free and the ground, stars, empty state and hintbar stay put.
|
|
153
|
+
It opens BEFORE the apex sentinel: stripApexOnly() deletes whole
|
|
154
|
+
sentinel spans, and a tag opened inside one would leave the child
|
|
155
|
+
instance an unbalanced tree. -->
|
|
156
|
+
<div class="skyCam" id="skyCam">
|
|
150
157
|
<!-- The platform's own orb. Static markup, not JS-built, precisely so the
|
|
151
158
|
apex-only sentinels can remove it: a child instance has no "this
|
|
152
159
|
platform" to point at, and stripApexOnly() is a string transform that
|
|
@@ -158,12 +165,17 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
158
165
|
<span class="sub">this platform</span>
|
|
159
166
|
</a>
|
|
160
167
|
<!--apex-only:end-->
|
|
168
|
+
</div>
|
|
161
169
|
<div class="fieldEmpty">
|
|
162
170
|
<p id="emptyMsg">You don’t own or build on anything here yet.</p>
|
|
163
171
|
<span class="sub">Turn the filter off to see what everyone else is building — or start your own.</span>
|
|
164
172
|
<a class="btn small" href="?view=new" data-nav-to="new">Start a project</a>
|
|
165
173
|
</div>
|
|
166
|
-
<div class="hintbar"><span><kbd>click a project</kbd> open it</span></div>
|
|
174
|
+
<div class="hintbar"><span><kbd>drag</kbd> pan</span><span><kbd>scroll</kbd> zoom</span><span><kbd>click a project</kbd> open it</span></div>
|
|
175
|
+
<!-- The hover card: a glance, not a dialog. Outside the camera so it
|
|
176
|
+
never scales, aria-hidden because the orb itself is the accessible
|
|
177
|
+
link, and chip-free — the orb's own chip is the ONE liveness site. -->
|
|
178
|
+
<div class="hoverCard" id="hoverCard" hidden aria-hidden="true"></div>
|
|
167
179
|
</div>
|
|
168
180
|
</div>
|
|
169
181
|
|
|
@@ -474,6 +486,12 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
474
486
|
if (v === 'mine' && me) refreshList();
|
|
475
487
|
/* owned chips refresh on re-entry, never on a timer — C8 keeps the map quiet */
|
|
476
488
|
if (v === 'map' && me) loadOwnedInstances().then(applyMapFilter);
|
|
489
|
+
/* the living sky runs only on stage (C8) — start and stop with the view;
|
|
490
|
+
anchors that went unreadable while the map was hidden re-adopt first */
|
|
491
|
+
if (v === 'map') {
|
|
492
|
+
if (skyNeedsRebuild) rebuildSkyBodies();
|
|
493
|
+
startSky();
|
|
494
|
+
} else { stopSky(); hideHover(); }
|
|
477
495
|
/* focus the name field only on a deliberate move into the view — never on
|
|
478
496
|
boot, where it would steal focus from whatever the visitor arrived at */
|
|
479
497
|
if (v === 'new' && focus) $('newName').focus();
|
|
@@ -651,16 +669,24 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
651
669
|
return hay.toLowerCase().indexOf(q) !== -1;
|
|
652
670
|
}
|
|
653
671
|
|
|
654
|
-
function renderOrbs(list) {
|
|
655
|
-
var
|
|
672
|
+
function renderOrbs(list, dimmed) {
|
|
673
|
+
var layer = $('skyCam');
|
|
656
674
|
/* replace only the generated orbs — the platform's own orb is static
|
|
657
675
|
apex-only markup and must survive every re-render */
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
676
|
+
layer.querySelectorAll('.worldOrb[data-generated]').forEach(function (n) { n.remove(); });
|
|
677
|
+
/* the spotlight (task 1002762): while a query is live the sky DIMS what
|
|
678
|
+
does not match instead of removing it — the cluster keeps its shape and
|
|
679
|
+
the matches glow. The table still filters, and the count counts matches. */
|
|
680
|
+
var spot = !!(dimmed && dimmed.length);
|
|
681
|
+
(spot ? list.concat(dimmed) : list).forEach(function (p, i) {
|
|
661
682
|
var slot = ORB_SLOTS[i % ORB_SLOTS.length];
|
|
683
|
+
var isDim = spot && i >= list.length;
|
|
662
684
|
var a = document.createElement('a');
|
|
663
|
-
a.className = 'worldOrb';
|
|
685
|
+
a.className = 'worldOrb' + (spot ? (isDim ? ' dim' : ' match') : '');
|
|
686
|
+
/* a dimmed orb is BACKDROP: at .25 opacity its label cannot carry
|
|
687
|
+
interaction honestly, so it leaves the tab order and the pointer
|
|
688
|
+
(pointer-events:none in css) — the matches and the table do the work */
|
|
689
|
+
if (isDim) { a.setAttribute('aria-hidden', 'true'); a.tabIndex = -1; }
|
|
664
690
|
a.setAttribute('data-generated', '');
|
|
665
691
|
var rel = relOf(p);
|
|
666
692
|
a.setAttribute('data-rel', rel);
|
|
@@ -695,7 +721,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
695
721
|
project can never look alive in the sky and unreachable in the list
|
|
696
722
|
(C6: two views of one truth) */
|
|
697
723
|
(inst ? chipFor(inst) : '');
|
|
698
|
-
|
|
724
|
+
layer.appendChild(a);
|
|
699
725
|
});
|
|
700
726
|
}
|
|
701
727
|
|
|
@@ -766,6 +792,11 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
766
792
|
var list = browse.filter(function (p) {
|
|
767
793
|
return matchesMapQuery(p, q) && (!onlyMine || relOf(p) !== 'none');
|
|
768
794
|
});
|
|
795
|
+
/* what the query rules OUT still exists — the sky dims it (spotlight);
|
|
796
|
+
the "only mine" switch is a filter, not a search, and stays a removal */
|
|
797
|
+
var dimmedOrbs = q ? browse.filter(function (p) {
|
|
798
|
+
return !matchesMapQuery(p, q) && (!onlyMine || relOf(p) !== 'none');
|
|
799
|
+
}) : [];
|
|
769
800
|
list.sort(function (x, y) {
|
|
770
801
|
if (mapSort.key === 'name') return String(x.name || '').localeCompare(String(y.name || '')) * mapSort.dir;
|
|
771
802
|
/* unknown (null) sorts to the BOTTOM in both directions — it is not a low
|
|
@@ -779,7 +810,7 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
779
810
|
}
|
|
780
811
|
return 0;
|
|
781
812
|
});
|
|
782
|
-
renderOrbs(list);
|
|
813
|
+
renderOrbs(list, dimmedOrbs);
|
|
783
814
|
renderRows(list);
|
|
784
815
|
|
|
785
816
|
/* The platform's own entry, in BOTH views from one predicate — it used to
|
|
@@ -788,11 +819,19 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
788
819
|
It is somebody's: see selfRel(). It still answers the search box, and it
|
|
789
820
|
still vanishes for a signed-out visitor, who owns nothing here. */
|
|
790
821
|
var rel = selfRel();
|
|
791
|
-
var
|
|
822
|
+
var selfPass = (rel !== 'none' || !onlyMine);
|
|
823
|
+
var selfShown = selfPass && matchesMapQuery(SELF_PROJECT, q);
|
|
792
824
|
var badge = rel === 'owner' ? 'yours' : rel === 'builder' ? 'you build here' : '';
|
|
793
825
|
var orb = $('selfOrb'), row = $('selfRow');
|
|
794
826
|
if (orb) {
|
|
795
|
-
|
|
827
|
+
/* in the sky a query DIMS the platform like everyone else; the "only
|
|
828
|
+
mine" filter still removes it for a signed-out visitor. Dimmed = pure
|
|
829
|
+
backdrop, same as a generated dim: no focus, no pointer. */
|
|
830
|
+
var selfDim = selfPass && !selfShown;
|
|
831
|
+
orb.hidden = !selfPass;
|
|
832
|
+
orb.classList.toggle('dim', selfDim);
|
|
833
|
+
if (selfDim) { orb.setAttribute('aria-hidden', 'true'); orb.tabIndex = -1; }
|
|
834
|
+
else { orb.removeAttribute('aria-hidden'); orb.removeAttribute('tabindex'); }
|
|
796
835
|
orb.setAttribute('data-rel', rel);
|
|
797
836
|
var b = orb.querySelector('.badge');
|
|
798
837
|
if (badge && !b) { b = document.createElement('span'); b.className = 'badge'; orb.insertBefore(b, orb.firstChild); }
|
|
@@ -804,11 +843,18 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
804
843
|
}
|
|
805
844
|
|
|
806
845
|
var shown = list.length + (selfShown ? 1 : 0);
|
|
807
|
-
|
|
846
|
+
/* a sky full of DIMMED orbs is not empty — the message would paint over
|
|
847
|
+
them and eat their pointer events; it shows only when nothing renders */
|
|
848
|
+
$('orbField').classList.toggle('isEmpty', shown === 0 && !dimmedOrbs.length && (onlyMine || !!q));
|
|
808
849
|
$('emptyMsg').textContent = onlyMine
|
|
809
850
|
? 'You don’t own or build on anything here yet.'
|
|
810
851
|
: 'Nothing here matches that.';
|
|
811
852
|
$('mapCount').textContent = shown + (shown === 1 ? ' project' : ' projects');
|
|
853
|
+
|
|
854
|
+
/* every re-render replaces the orb nodes — the sim re-adopts them,
|
|
855
|
+
carrying position + velocity for orbs that persisted (see the sky) */
|
|
856
|
+
rebuildSkyBodies();
|
|
857
|
+
startSky();
|
|
812
858
|
}
|
|
813
859
|
|
|
814
860
|
/* ══ THE PROJECT CARD ══════════════════════════════════════════════════════
|
|
@@ -904,6 +950,9 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
904
950
|
$('orbField').addEventListener('click', function (ev) {
|
|
905
951
|
var a = ev.target.closest && ev.target.closest('.worldOrb');
|
|
906
952
|
if (!a || !isPlainClick(ev)) return;
|
|
953
|
+
/* a drag that ended on an orb is a pan, not a click (task 1002762) —
|
|
954
|
+
and the link underneath must not fire either */
|
|
955
|
+
if (skyPanned) { ev.preventDefault(); return; }
|
|
907
956
|
ev.preventDefault();
|
|
908
957
|
if (a.id === 'selfOrb') return openCard(SELF_PROJECT, selfRel(), a);
|
|
909
958
|
/* an owned orb's job is MANAGE (direction §5) — before the card path */
|
|
@@ -1049,11 +1098,439 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
|
|
|
1049
1098
|
});
|
|
1050
1099
|
});
|
|
1051
1100
|
|
|
1101
|
+
/* ══ THE LIVING SKY (task 1002762) ═════════════════════════════════════════
|
|
1102
|
+
The retired gate's cluster, rewritten for the hub. Physics is the gate's
|
|
1103
|
+
verified core (core-v1.19.93 space.js: softened Newtonian pairs, fixed-dt
|
|
1104
|
+
kick-drift-kick leapfrog, soft collisions, a KE band thermostat, per-step
|
|
1105
|
+
drift removal — stable over a 60-min headless run), re-tuned for this
|
|
1106
|
+
roster: the containment well's radius derives from the actual spread
|
|
1107
|
+
instead of the gate's 520px constant, and mass comes from disc size.
|
|
1108
|
+
|
|
1109
|
+
Layers: the CAMERA is one transform on #skyCam (pan + zoom-toward-cursor,
|
|
1110
|
+
the gate's exact algebra); each orb writes only its own physics delta over
|
|
1111
|
+
its slot anchor, so the two compose for free. The loop is C8-guarded —
|
|
1112
|
+
it stops DEAD off the map view, on the table sub-view, in a hidden tab —
|
|
1113
|
+
and never starts under prefers-reduced-motion (C9): reduced and no-JS
|
|
1114
|
+
both get the mock's static field. Zero requests anywhere in the loop. ══ */
|
|
1115
|
+
|
|
1116
|
+
var SKY_GRAV = {
|
|
1117
|
+
R_REF: 50, /* mass = (radius / R_REF)^3 — size IS gravity */
|
|
1118
|
+
G: 5200, /* px^3 / mass·s^2 */
|
|
1119
|
+
SOFTEN: 48, /* Plummer softening ~ a typical orb radius here */
|
|
1120
|
+
SWIRL: 0.09, /* initial solid-body swirl (rad/s) */
|
|
1121
|
+
SWIRL_JITTER: 0.15,
|
|
1122
|
+
REST: 0.92, /* near-elastic contacts (rare) */
|
|
1123
|
+
CONTAIN_K: 6.0, /* inward accel per px beyond the well */
|
|
1124
|
+
MAXV: 260, /* last-resort speed cap (px/s) */
|
|
1125
|
+
THERMO_LO: 0.55, THERMO_HI: 1.9, THERMO_MAX: 0.01,
|
|
1126
|
+
PHYS_HZ: 120
|
|
1127
|
+
};
|
|
1128
|
+
var skyBodies = [], keTarget = 0, containR = 460, containAspect = 1;
|
|
1129
|
+
var skyView = { x: 0, y: 0, z: 1 };
|
|
1130
|
+
var SKY_ZMIN = 0.55, SKY_ZMAX = 2.4;
|
|
1131
|
+
var SKY_DEADZONE = 5; /* px of movement before a press becomes a pan */
|
|
1132
|
+
var skyPanned = false; /* consumed by the delegated click handler */
|
|
1133
|
+
var skyPtr = { x: 0, y: 0 };
|
|
1134
|
+
|
|
1135
|
+
function skyBarycenter() {
|
|
1136
|
+
var M = 0, x = 0, y = 0;
|
|
1137
|
+
for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; M += b.m; x += b.m * b.x; y += b.m * b.y; }
|
|
1138
|
+
return M > 0 ? { x: x / M, y: y / M } : { x: 0, y: 0 };
|
|
1139
|
+
}
|
|
1140
|
+
function skyKE() {
|
|
1141
|
+
var e = 0;
|
|
1142
|
+
for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; e += 0.5 * b.m * (b.vx * b.vx + b.vy * b.vy); }
|
|
1143
|
+
return e;
|
|
1144
|
+
}
|
|
1145
|
+
function killSkyDrift() {
|
|
1146
|
+
var M = 0, px = 0, py = 0;
|
|
1147
|
+
for (var i = 0; i < skyBodies.length; i++) { var b = skyBodies[i]; M += b.m; px += b.m * b.vx; py += b.m * b.vy; }
|
|
1148
|
+
if (M <= 0) return;
|
|
1149
|
+
var dvx = px / M, dvy = py / M;
|
|
1150
|
+
for (var j = 0; j < skyBodies.length; j++) { skyBodies[j].vx -= dvx; skyBodies[j].vy -= dvy; }
|
|
1151
|
+
}
|
|
1152
|
+
function computeSkyForces(c) {
|
|
1153
|
+
var n = skyBodies.length, soft2 = SKY_GRAV.SOFTEN * SKY_GRAV.SOFTEN, i, j;
|
|
1154
|
+
for (i = 0; i < n; i++) { skyBodies[i].fx = 0; skyBodies[i].fy = 0; }
|
|
1155
|
+
for (i = 0; i < n; i++) {
|
|
1156
|
+
for (j = i + 1; j < n; j++) {
|
|
1157
|
+
var a = skyBodies[i], b = skyBodies[j];
|
|
1158
|
+
var dx = b.x - a.x, dy = b.y - a.y;
|
|
1159
|
+
var d2 = dx * dx + dy * dy + soft2;
|
|
1160
|
+
var f = SKY_GRAV.G / (d2 * Math.sqrt(d2));
|
|
1161
|
+
a.fx += f * b.m * dx; a.fy += f * b.m * dy;
|
|
1162
|
+
b.fx -= f * a.m * dx; b.fy -= f * a.m * dy;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
/* soft containment well — a gentle inward pull only beyond containR.
|
|
1166
|
+
ELLIPTICAL, matched to the field: the gate's circular well lived in an
|
|
1167
|
+
unbounded camera world, but this field clips, and it is much wider than
|
|
1168
|
+
it is tall — a circular well sized to the x-spread would let vertical
|
|
1169
|
+
orbits sail past the clipped edge. y-distance is scaled by the aspect,
|
|
1170
|
+
so the well's shape is the field's shape. */
|
|
1171
|
+
var a2 = containAspect * containAspect;
|
|
1172
|
+
for (i = 0; i < n; i++) {
|
|
1173
|
+
var it = skyBodies[i], ex = it.x - c.x, ey = it.y - c.y;
|
|
1174
|
+
var d = Math.hypot(ex, ey * containAspect);
|
|
1175
|
+
if (d > containR && d > 1e-6) {
|
|
1176
|
+
var k = -SKY_GRAV.CONTAIN_K * (d - containR) / d;
|
|
1177
|
+
it.fx += k * ex; it.fy += k * ey * a2;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
function resolveSkyCollisions() {
|
|
1182
|
+
for (var i = 0; i < skyBodies.length; i++) {
|
|
1183
|
+
for (var j = i + 1; j < skyBodies.length; j++) {
|
|
1184
|
+
var a = skyBodies[i], b = skyBodies[j];
|
|
1185
|
+
var dx = b.x - a.x, dy = (b.y + b.discOff) - (a.y + a.discOff);
|
|
1186
|
+
var dist = Math.hypot(dx, dy) || 1e-6, min = a.r + b.r;
|
|
1187
|
+
if (dist >= min) continue;
|
|
1188
|
+
var nx = dx / dist, ny = dy / dist;
|
|
1189
|
+
var ima = 1 / a.m, imb = 1 / b.m;
|
|
1190
|
+
var corr = (min - dist) / (ima + imb);
|
|
1191
|
+
a.x -= nx * corr * ima; a.y -= ny * corr * ima;
|
|
1192
|
+
b.x += nx * corr * imb; b.y += ny * corr * imb;
|
|
1193
|
+
var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny;
|
|
1194
|
+
if (vn < 0) {
|
|
1195
|
+
var imp = -(1 + SKY_GRAV.REST) * vn / (ima + imb);
|
|
1196
|
+
a.vx -= nx * imp * ima; a.vy -= ny * imp * ima;
|
|
1197
|
+
b.vx += nx * imp * imb; b.vy += ny * imp * imb;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
function skyThermostat() {
|
|
1203
|
+
var lo = keTarget * SKY_GRAV.THERMO_LO, hi = keTarget * SKY_GRAV.THERMO_HI, ke = skyKE();
|
|
1204
|
+
if (ke < 1e-6) return;
|
|
1205
|
+
var target = ke < lo ? lo : (ke > hi ? hi : 0);
|
|
1206
|
+
if (!target) return; /* inside the band: pure physics */
|
|
1207
|
+
var f = Math.sqrt(target / ke);
|
|
1208
|
+
f = Math.max(1 - SKY_GRAV.THERMO_MAX, Math.min(1 + SKY_GRAV.THERMO_MAX, f));
|
|
1209
|
+
for (var i = 0; i < skyBodies.length; i++) { skyBodies[i].vx *= f; skyBodies[i].vy *= f; }
|
|
1210
|
+
}
|
|
1211
|
+
function clampSkyVelocities() {
|
|
1212
|
+
for (var i = 0; i < skyBodies.length; i++) {
|
|
1213
|
+
var b = skyBodies[i], v = Math.hypot(b.vx, b.vy);
|
|
1214
|
+
if (v > SKY_GRAV.MAXV) { var s = SKY_GRAV.MAXV / v; b.vx *= s; b.vy *= s; }
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
function physStep(dt) {
|
|
1218
|
+
var i, it, c = skyBarycenter();
|
|
1219
|
+
for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.px = it.x; it.py = it.y; }
|
|
1220
|
+
computeSkyForces(c); /* leapfrog kick-drift-kick — the order is load-bearing */
|
|
1221
|
+
for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
|
|
1222
|
+
for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.x += it.vx * dt; it.y += it.vy * dt; }
|
|
1223
|
+
computeSkyForces(c);
|
|
1224
|
+
for (i = 0; i < skyBodies.length; i++) { it = skyBodies[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
|
|
1225
|
+
resolveSkyCollisions();
|
|
1226
|
+
skyThermostat();
|
|
1227
|
+
killSkyDrift();
|
|
1228
|
+
clampSkyVelocities();
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/* bodies re-adopt the DOM after every render: an orb that persisted keeps
|
|
1232
|
+
its position AND momentum across filter keystrokes (visual position is
|
|
1233
|
+
x/y itself — the anchor is only the subtraction base for the transform) */
|
|
1234
|
+
var skyNeedsRebuild = false;
|
|
1235
|
+
function rebuildSkyBodies() {
|
|
1236
|
+
var layer = $('skyCam'), fieldEl = $('orbField');
|
|
1237
|
+
if (!layer) { skyBodies = []; return; }
|
|
1238
|
+
/* display:none zeroes offsetLeft/Top AND clientWidth: anchoring to zeros
|
|
1239
|
+
paints every orb at slot + physics position on return, degrades the
|
|
1240
|
+
elliptical well to circular, and breaks the camera clamp (review
|
|
1241
|
+
CRITICAL — reachable from the Table view's own sort/search controls).
|
|
1242
|
+
Defer instead: the fresh orb nodes sit at their CSS slots — the static
|
|
1243
|
+
mock — until the sky is back on stage and the anchors are real. */
|
|
1244
|
+
if (!fieldEl || !fieldEl.clientWidth) { skyNeedsRebuild = true; return; }
|
|
1245
|
+
skyNeedsRebuild = false;
|
|
1246
|
+
var prev = {};
|
|
1247
|
+
skyBodies.forEach(function (b) { prev[b.key] = b; });
|
|
1248
|
+
var next = [];
|
|
1249
|
+
layer.querySelectorAll('.worldOrb').forEach(function (el) {
|
|
1250
|
+
if (el.hidden) return;
|
|
1251
|
+
var d = parseFloat(getComputedStyle(el).getPropertyValue('--d')) || 60;
|
|
1252
|
+
var r = d / 2;
|
|
1253
|
+
/* identity must survive re-sorts: an origin-less orb keys on its NAME,
|
|
1254
|
+
never its list index (an index key hands its momentum to whichever
|
|
1255
|
+
orb lands on that index after the next sort) */
|
|
1256
|
+
var nmEl = el.querySelector('.nm');
|
|
1257
|
+
var key = el.id === 'selfOrb' ? 'self'
|
|
1258
|
+
: (el.getAttribute('data-origin') || 'nm:' + (nmEl ? nmEl.textContent : String(next.length)));
|
|
1259
|
+
var ax = el.offsetLeft, ay = el.offsetTop;
|
|
1260
|
+
/* the body's anchor is the label-stack CENTER, but the disc — the thing
|
|
1261
|
+
that must not interpenetrate — sits above it; collisions measure from
|
|
1262
|
+
the disc's own center */
|
|
1263
|
+
var disc = el.querySelector('.disc');
|
|
1264
|
+
var discOff = disc ? (disc.offsetTop + r) - el.offsetHeight / 2 : 0;
|
|
1265
|
+
/* reduced motion IS the mock: no carry-over, every orb sits exactly on
|
|
1266
|
+
its slot (C9) — carrying frozen positions across a re-sort would
|
|
1267
|
+
leave zero-motion orbs parked off their own anchors forever */
|
|
1268
|
+
var old = REDUCED ? null : prev[key];
|
|
1269
|
+
next.push({
|
|
1270
|
+
key: key, el: el, r: r, ax: ax, ay: ay, discOff: discOff,
|
|
1271
|
+
m: Math.pow(r / SKY_GRAV.R_REF, 3),
|
|
1272
|
+
x: old ? old.x : ax, y: old ? old.y : ay,
|
|
1273
|
+
px: old ? old.x : ax, py: old ? old.y : ay,
|
|
1274
|
+
vx: old ? old.vx : 0, vy: old ? old.vy : 0,
|
|
1275
|
+
fx: 0, fy: 0, seeded: !!old
|
|
1276
|
+
});
|
|
1277
|
+
});
|
|
1278
|
+
skyBodies = next;
|
|
1279
|
+
/* new arrivals join the field swirl at their own position */
|
|
1280
|
+
var c = skyBarycenter();
|
|
1281
|
+
var jit = SKY_GRAV.SWIRL * 60 * SKY_GRAV.SWIRL_JITTER;
|
|
1282
|
+
skyBodies.forEach(function (b) {
|
|
1283
|
+
if (b.seeded) return;
|
|
1284
|
+
var dx = b.x - c.x, dy = b.y - c.y;
|
|
1285
|
+
b.vx = -dy * SKY_GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
|
|
1286
|
+
b.vy = dx * SKY_GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
|
|
1287
|
+
});
|
|
1288
|
+
killSkyDrift();
|
|
1289
|
+
keTarget = skyKE();
|
|
1290
|
+
/* the well hugs the roster: sized from the real spread in ASPECT-SCALED
|
|
1291
|
+
space (the gate's 520px assumed its own scatter; ours is the slot
|
|
1292
|
+
layout in THIS viewport, which is wide and short) */
|
|
1293
|
+
var fieldEl = $('orbField');
|
|
1294
|
+
containAspect = fieldEl
|
|
1295
|
+
? Math.min(3, Math.max(1, fieldEl.clientWidth / Math.max(1, fieldEl.clientHeight)))
|
|
1296
|
+
: 1;
|
|
1297
|
+
var maxD = 0;
|
|
1298
|
+
skyBodies.forEach(function (b) {
|
|
1299
|
+
maxD = Math.max(maxD, Math.hypot(b.x - c.x, (b.y - c.y) * containAspect));
|
|
1300
|
+
});
|
|
1301
|
+
containR = Math.max(380, maxD * 1.15);
|
|
1302
|
+
/* the camera may still be aimed at a cluster that no longer exists */
|
|
1303
|
+
clampSkyCam();
|
|
1304
|
+
applySkyCam();
|
|
1305
|
+
renderSky(1);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function renderSky(alpha) {
|
|
1309
|
+
var dpr = window.devicePixelRatio || 1;
|
|
1310
|
+
for (var k = 0; k < skyBodies.length; k++) {
|
|
1311
|
+
var b = skyBodies[k];
|
|
1312
|
+
var ix = b.px + (b.x - b.px) * alpha, iy = b.py + (b.y - b.py) * alpha;
|
|
1313
|
+
/* snap the offset to the device-pixel grid: motion stays smooth but
|
|
1314
|
+
glyphs rasterize identically each frame, so labels never shimmer */
|
|
1315
|
+
var tx = Math.round((ix - b.ax) * dpr) / dpr, ty = Math.round((iy - b.ay) * dpr) / dpr;
|
|
1316
|
+
b.el.style.transform = 'translate(-50%,-50%) translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px)';
|
|
1317
|
+
}
|
|
1318
|
+
followHover();
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/* the loop: fixed 120Hz physics under an accumulator, interpolated render.
|
|
1322
|
+
The 250ms frame clamp + 8-substep guard + drop-on-overrun keep a slow
|
|
1323
|
+
machine in slow motion rather than a spiral. */
|
|
1324
|
+
var SKY_DT = 1000 / SKY_GRAV.PHYS_HZ, skyAcc = 0, skyLast = 0, skyRaf = 0;
|
|
1325
|
+
function skyActive() {
|
|
1326
|
+
return !REDUCED && !document.hidden && currentView === 'map' &&
|
|
1327
|
+
!$('sub-sky-body').hidden && skyBodies.length > 1;
|
|
1328
|
+
}
|
|
1329
|
+
function skyTick(now) {
|
|
1330
|
+
skyRaf = 0;
|
|
1331
|
+
if (!skyActive()) return;
|
|
1332
|
+
var ft = now - skyLast; skyLast = now; if (ft > 250) ft = 250;
|
|
1333
|
+
skyAcc += ft;
|
|
1334
|
+
var guard = 0;
|
|
1335
|
+
while (skyAcc >= SKY_DT && guard < 8) { physStep(SKY_DT / 1000); skyAcc -= SKY_DT; guard++; }
|
|
1336
|
+
if (guard >= 8) skyAcc = 0;
|
|
1337
|
+
renderSky(Math.max(0, Math.min(1, skyAcc / SKY_DT)));
|
|
1338
|
+
skyRaf = requestAnimationFrame(skyTick);
|
|
1339
|
+
}
|
|
1340
|
+
function startSky() {
|
|
1341
|
+
if (skyRaf || !skyActive()) return;
|
|
1342
|
+
skyLast = performance.now(); skyAcc = 0;
|
|
1343
|
+
skyRaf = requestAnimationFrame(skyTick);
|
|
1344
|
+
}
|
|
1345
|
+
function stopSky() { if (skyRaf) { cancelAnimationFrame(skyRaf); skyRaf = 0; } }
|
|
1346
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden) startSky(); });
|
|
1347
|
+
window.addEventListener('resize', function () {
|
|
1348
|
+
clearTimeout(skyResizeT);
|
|
1349
|
+
skyResizeT = setTimeout(function () {
|
|
1350
|
+
/* percent anchors moved with the field — re-seed rather than let stale
|
|
1351
|
+
pixel anchors shear the cluster */
|
|
1352
|
+
skyBodies = [];
|
|
1353
|
+
rebuildSkyBodies();
|
|
1354
|
+
startSky();
|
|
1355
|
+
}, 200);
|
|
1356
|
+
});
|
|
1357
|
+
var skyResizeT = 0;
|
|
1358
|
+
|
|
1359
|
+
/* ── the camera: the gate's exact pan/zoom algebra on one layer ── */
|
|
1360
|
+
function applySkyCam() {
|
|
1361
|
+
var el = $('skyCam');
|
|
1362
|
+
if (el) el.style.transform = 'translate(' + skyView.x.toFixed(2) + 'px,' + skyView.y.toFixed(2) + 'px) scale(' + skyView.z.toFixed(4) + ')';
|
|
1363
|
+
}
|
|
1364
|
+
function clampSkyCam() {
|
|
1365
|
+
var wrap = $('sub-sky-body');
|
|
1366
|
+
if (!wrap || !skyBodies.length) return;
|
|
1367
|
+
var W = wrap.clientWidth, H = wrap.clientHeight, m = 160;
|
|
1368
|
+
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1369
|
+
skyBodies.forEach(function (b) {
|
|
1370
|
+
if (b.x < minX) minX = b.x; if (b.x > maxX) maxX = b.x;
|
|
1371
|
+
if (b.y < minY) minY = b.y; if (b.y > maxY) maxY = b.y;
|
|
1372
|
+
});
|
|
1373
|
+
var sxMin = skyView.x + minX * skyView.z, sxMax = skyView.x + maxX * skyView.z;
|
|
1374
|
+
var syMin = skyView.y + minY * skyView.z, syMax = skyView.y + maxY * skyView.z;
|
|
1375
|
+
if (sxMax < m) skyView.x += m - sxMax; else if (sxMin > W - m) skyView.x -= sxMin - (W - m);
|
|
1376
|
+
if (syMax < m) skyView.y += m - syMax; else if (syMin > H - m) skyView.y -= syMin - (H - m);
|
|
1377
|
+
}
|
|
1378
|
+
(function skyPanZoom() {
|
|
1379
|
+
var wrap = $('sub-sky-body');
|
|
1380
|
+
if (!wrap) return;
|
|
1381
|
+
var down = false, moved = false, middle = false, sx = 0, sy = 0, startX = 0, startY = 0;
|
|
1382
|
+
var panId = null; /* the ONE pointer that owns the drag — a second finger
|
|
1383
|
+
(pinch, a stray touch) must never hijack the camera */
|
|
1384
|
+
wrap.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
|
|
1385
|
+
wrap.addEventListener('pointerdown', function (e) {
|
|
1386
|
+
if (down) return;
|
|
1387
|
+
if (e.button !== 0 && e.button !== 1) return;
|
|
1388
|
+
/* middle-press on an orb belongs to the browser (open in a new tab);
|
|
1389
|
+
middle on empty sky is pan-anywhere, like the gate */
|
|
1390
|
+
middle = e.button === 1 && !(e.target.closest && e.target.closest('.worldOrb'));
|
|
1391
|
+
if (e.button === 1 && !middle) return;
|
|
1392
|
+
down = true; moved = false; skyPanned = false; panId = e.pointerId;
|
|
1393
|
+
sx = e.clientX; sy = e.clientY; startX = skyView.x; startY = skyView.y;
|
|
1394
|
+
if (middle) { moved = true; skyPanned = true; wrap.classList.add('panning'); hideHover(); e.preventDefault(); }
|
|
1395
|
+
});
|
|
1396
|
+
window.addEventListener('pointermove', function (e) {
|
|
1397
|
+
skyPtr.x = e.clientX; skyPtr.y = e.clientY;
|
|
1398
|
+
if (!down || (panId !== null && e.pointerId !== panId)) return;
|
|
1399
|
+
if (!moved && Math.hypot(e.clientX - sx, e.clientY - sy) < SKY_DEADZONE) return;
|
|
1400
|
+
if (!moved) { moved = true; skyPanned = true; wrap.classList.add('panning'); hideHover(); }
|
|
1401
|
+
skyView.x = startX + (e.clientX - sx);
|
|
1402
|
+
skyView.y = startY + (e.clientY - sy);
|
|
1403
|
+
clampSkyCam(); applySkyCam();
|
|
1404
|
+
});
|
|
1405
|
+
function endPan(e) {
|
|
1406
|
+
if (!down || (panId !== null && e && e.pointerId !== undefined && e.pointerId !== panId)) return;
|
|
1407
|
+
down = false; middle = false; panId = null; wrap.classList.remove('panning');
|
|
1408
|
+
/* the click this pan suppresses fires synchronously after pointerup —
|
|
1409
|
+
clear the flag a tick later, so a keyboard Enter is never eaten */
|
|
1410
|
+
setTimeout(function () { skyPanned = false; }, 0);
|
|
1411
|
+
}
|
|
1412
|
+
window.addEventListener('pointerup', endPan);
|
|
1413
|
+
window.addEventListener('pointercancel', endPan);
|
|
1414
|
+
wrap.addEventListener('wheel', function (e) {
|
|
1415
|
+
var d = e.deltaY;
|
|
1416
|
+
/* line-mode wheels (Firefox) report ~3 lines, not ~100 px */
|
|
1417
|
+
if (e.deltaMode === 1) d *= 16; else if (e.deltaMode === 2) d *= 300;
|
|
1418
|
+
var factor = Math.exp(-d * 0.0016);
|
|
1419
|
+
var z = Math.max(SKY_ZMIN, Math.min(SKY_ZMAX, skyView.z * factor));
|
|
1420
|
+
/* at the clamp the wheel would do nothing — hand it back to the page
|
|
1421
|
+
instead of trapping scroll over the sky */
|
|
1422
|
+
if (z === skyView.z) return;
|
|
1423
|
+
e.preventDefault();
|
|
1424
|
+
var rect = wrap.getBoundingClientRect();
|
|
1425
|
+
var cx = e.clientX - rect.left, cy = e.clientY - rect.top;
|
|
1426
|
+
/* un-project the cursor at the old zoom, re-pin it at the new — the
|
|
1427
|
+
world point under the wheel never moves */
|
|
1428
|
+
var vx = (cx - skyView.x) / skyView.z, vy = (cy - skyView.y) / skyView.z;
|
|
1429
|
+
skyView.z = z;
|
|
1430
|
+
skyView.x = cx - vx * z;
|
|
1431
|
+
skyView.y = cy - vy * z;
|
|
1432
|
+
clampSkyCam(); applySkyCam(); hideHover();
|
|
1433
|
+
}, { passive: false });
|
|
1434
|
+
})();
|
|
1435
|
+
|
|
1436
|
+
/* ── the hover card: a glance under the cursor, never a second chip ── */
|
|
1437
|
+
var hoverOrb = null, hoverFrames = 0;
|
|
1438
|
+
function showHover(a) {
|
|
1439
|
+
if (a === hoverOrb) return;
|
|
1440
|
+
hoverOrb = a;
|
|
1441
|
+
var card = $('hoverCard');
|
|
1442
|
+
if (!card) return;
|
|
1443
|
+
card.textContent = '';
|
|
1444
|
+
var nm = document.createElement('span');
|
|
1445
|
+
nm.className = 'hn';
|
|
1446
|
+
var n = a.querySelector('.nm');
|
|
1447
|
+
nm.textContent = n ? n.textContent : '';
|
|
1448
|
+
card.appendChild(nm);
|
|
1449
|
+
var tag = a.title || (a.querySelector('.sub') ? a.querySelector('.sub').textContent : '');
|
|
1450
|
+
if (tag && tag !== nm.textContent) {
|
|
1451
|
+
var ht = document.createElement('span');
|
|
1452
|
+
ht.className = 'ht';
|
|
1453
|
+
ht.textContent = tag;
|
|
1454
|
+
card.appendChild(ht);
|
|
1455
|
+
}
|
|
1456
|
+
card.hidden = false;
|
|
1457
|
+
placeHover();
|
|
1458
|
+
}
|
|
1459
|
+
function hideHover() {
|
|
1460
|
+
hoverOrb = null;
|
|
1461
|
+
var c = $('hoverCard');
|
|
1462
|
+
if (c) c.hidden = true;
|
|
1463
|
+
}
|
|
1464
|
+
function placeHover() {
|
|
1465
|
+
if (!hoverOrb) return;
|
|
1466
|
+
var card = $('hoverCard'), field = $('orbField');
|
|
1467
|
+
var fr = field.getBoundingClientRect(), or = hoverOrb.getBoundingClientRect();
|
|
1468
|
+
var x = or.left + or.width / 2 - fr.left, y = or.top - fr.top;
|
|
1469
|
+
card.style.left = Math.max(8, Math.min(fr.width - card.offsetWidth - 8, x - card.offsetWidth / 2)) + 'px';
|
|
1470
|
+
var top = y - card.offsetHeight - 10;
|
|
1471
|
+
card.style.top = (top < 8 ? y + or.height + 10 : top) + 'px';
|
|
1472
|
+
}
|
|
1473
|
+
function followHover() {
|
|
1474
|
+
if (!hoverOrb) return;
|
|
1475
|
+
placeHover();
|
|
1476
|
+
/* the sim can slide an orb out from under a stationary cursor and the
|
|
1477
|
+
browser never fires the leave — re-check what is under the pointer
|
|
1478
|
+
every few frames (a gate lesson, not optional polish) */
|
|
1479
|
+
if (++hoverFrames % 4 === 0) {
|
|
1480
|
+
var under = document.elementFromPoint(skyPtr.x, skyPtr.y);
|
|
1481
|
+
var overOrb = under && under.closest ? under.closest('.worldOrb') : null;
|
|
1482
|
+
if (overOrb !== hoverOrb) hideHover();
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
$('orbField').addEventListener('pointerover', function (ev) {
|
|
1486
|
+
/* keep the pointer sample fresh from here too: a hover can arrive before
|
|
1487
|
+
any move (a touch tap, a page-load hover) and the frame check would
|
|
1488
|
+
otherwise measure a stale point and hide the card it just showed */
|
|
1489
|
+
skyPtr.x = ev.clientX; skyPtr.y = ev.clientY;
|
|
1490
|
+
var a = ev.target.closest && ev.target.closest('.worldOrb');
|
|
1491
|
+
if (a && !$('sub-sky-body').classList.contains('panning')) showHover(a);
|
|
1492
|
+
});
|
|
1493
|
+
$('orbField').addEventListener('pointerout', function (ev) {
|
|
1494
|
+
if (!hoverOrb) return;
|
|
1495
|
+
var to = ev.relatedTarget;
|
|
1496
|
+
if (!to || !(to.closest && to.closest('.worldOrb') === hoverOrb)) hideHover();
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
/* a tapped Shift lights the search — the gate's spotlight key, kept: a tap
|
|
1500
|
+
with no other key while held focuses the query box in map view */
|
|
1501
|
+
(function shiftSpot() {
|
|
1502
|
+
var solo = false;
|
|
1503
|
+
document.addEventListener('keydown', function (e) {
|
|
1504
|
+
if (e.key === 'Shift') { solo = !e.repeat; return; }
|
|
1505
|
+
solo = false;
|
|
1506
|
+
});
|
|
1507
|
+
/* Shift used as a MOUSE modifier is not a tap: a shift-click or
|
|
1508
|
+
shift-drag between the keydown and keyup must not light the search */
|
|
1509
|
+
document.addEventListener('pointerdown', function () { solo = false; });
|
|
1510
|
+
document.addEventListener('wheel', function () { solo = false; }, { passive: true });
|
|
1511
|
+
document.addEventListener('keyup', function (e) {
|
|
1512
|
+
if (e.key !== 'Shift' || !solo) return;
|
|
1513
|
+
solo = false;
|
|
1514
|
+
/* never steal focus out of the open project card (it is aria-modal) */
|
|
1515
|
+
if (currentView !== 'map' || !$('cardScrim').hidden) return;
|
|
1516
|
+
var t = document.activeElement;
|
|
1517
|
+
if (t && /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)) return;
|
|
1518
|
+
var q = $('mapQ');
|
|
1519
|
+
if (q) q.focus();
|
|
1520
|
+
});
|
|
1521
|
+
})();
|
|
1522
|
+
|
|
1052
1523
|
function setSub(which) {
|
|
1053
1524
|
$('sub-sky').setAttribute('aria-pressed', String(which === 'sky'));
|
|
1054
1525
|
$('sub-table').setAttribute('aria-pressed', String(which === 'table'));
|
|
1055
1526
|
$('sub-sky-body').hidden = which !== 'sky';
|
|
1056
1527
|
$('sub-table-body').hidden = which !== 'table';
|
|
1528
|
+
/* the sim runs only while the sky is on stage (C8); anchors that were
|
|
1529
|
+
unreadable while hidden are re-adopted before the first frame */
|
|
1530
|
+
if (which === 'sky') {
|
|
1531
|
+
if (skyNeedsRebuild) rebuildSkyBodies();
|
|
1532
|
+
startSky();
|
|
1533
|
+
} else { stopSky(); hideHover(); }
|
|
1057
1534
|
}
|
|
1058
1535
|
$('sub-sky').addEventListener('click', function () { setSub('sky'); });
|
|
1059
1536
|
$('sub-table').addEventListener('click', function () { setSub('table'); });
|
package/src/module-api.js
CHANGED
|
@@ -651,8 +651,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
651
651
|
// landed since 1.19.110 with no explicit bump. run 31810638191. (task 1002620)
|
|
652
652
|
// 1.19.112 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
653
653
|
// landed since 1.19.111 with no explicit bump. run 31814245230. (task 1002620)
|
|
654
|
+
// 1.19.113 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
655
|
+
// landed since 1.19.112 with no explicit bump. run 31816239246. (task 1002620)
|
|
656
|
+
// 1.19.114 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
657
|
+
// landed since 1.19.113 with no explicit bump. run 31816599678. (task 1002620)
|
|
654
658
|
// ---------------------------------------------------------------------------
|
|
655
|
-
const CORE_VERSION = '1.19.
|
|
659
|
+
const CORE_VERSION = '1.19.114'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
656
660
|
|
|
657
661
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
658
662
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -702,3 +702,72 @@ test('the chip-freshness tick spends no requests — it re-renders what it has (
|
|
|
702
702
|
assert.match(fn, /document\.hidden/, 'and does nothing in a hidden tab');
|
|
703
703
|
assert.doesNotMatch(fn, /fetch\(/, 'zero requests — the re-fetch belongs to entry and tab return only');
|
|
704
704
|
});
|
|
705
|
+
|
|
706
|
+
/* ── The living sky (task 1002762) ──────────────────────────────────────────
|
|
707
|
+
The retired gate's n-body cluster + pan/zoom/hover/spotlight, rewritten for
|
|
708
|
+
the hub: one camera layer, physics in ~110 guarded lines, and the mock's
|
|
709
|
+
static field as the reduced-motion and no-JS state. These pin the C8/C9
|
|
710
|
+
contract and the structural topology the camera depends on. ── */
|
|
711
|
+
|
|
712
|
+
test('the sky camera is one layer inside the field, outside the sentinels', () => {
|
|
713
|
+
const stack = openAncestorsAt(HUB, 'id="skyCam"');
|
|
714
|
+
assert.ok(stack.includes('view-map') && stack.includes('orbField'),
|
|
715
|
+
'the camera lives in the orb field. open: ' + stack.join(' > '));
|
|
716
|
+
assert.ok(!stack.includes('sub-table-body'), 'and never inside the table');
|
|
717
|
+
const selfStack = openAncestorsAt(HUB, 'id="selfOrb"');
|
|
718
|
+
assert.ok(selfStack.includes('skyCam'), 'the platform orb pans and zooms with everyone else');
|
|
719
|
+
const camAt = HUB.indexOf('id="skyCam"');
|
|
720
|
+
const selfAt = HUB.indexOf('id="selfOrb"');
|
|
721
|
+
const sentinelAt = HUB.lastIndexOf('<!--apex-only:start-->', selfAt);
|
|
722
|
+
assert.ok(camAt !== -1 && camAt < sentinelAt && sentinelAt < selfAt,
|
|
723
|
+
'the camera opens BEFORE the orb sentinel — stripApexOnly() must never eat its open tag');
|
|
724
|
+
const hoverStack = openAncestorsAt(HUB, 'id="hoverCard"');
|
|
725
|
+
assert.ok(hoverStack.includes('orbField') && !hoverStack.includes('skyCam'),
|
|
726
|
+
'the hover card sits outside the camera so it never scales or pans');
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
test('the sim stops dead when the sky is not on stage (C8, C9)', () => {
|
|
730
|
+
const active = (SCRIPT.match(/function skyActive\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
731
|
+
assert.ok(active, 'skyActive() must exist');
|
|
732
|
+
for (const guard of ['REDUCED', 'document.hidden', "currentView === 'map'", 'sub-sky-body']) {
|
|
733
|
+
assert.ok(active.includes(guard), `skyActive must check ${guard}`);
|
|
734
|
+
}
|
|
735
|
+
const tick = (SCRIPT.match(/function skyTick\(now\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
736
|
+
assert.ok(tick, 'skyTick() must exist');
|
|
737
|
+
assert.match(tick, /if \(!skyActive\(\)\) return/, 'a tick on a hidden sky ends the loop, not just the work');
|
|
738
|
+
assert.doesNotMatch(tick, /fetch\(/, 'the sky spends no requests (C6+C8)');
|
|
739
|
+
const step = (SCRIPT.match(/function physStep\(dt\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
740
|
+
assert.ok(step, 'physStep() must exist');
|
|
741
|
+
assert.doesNotMatch(step, /fetch\(/);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test('a drag is a pan and a click is a click — the two never blur', () => {
|
|
745
|
+
assert.match(SCRIPT, /var SKY_DEADZONE = 5/, 'the 5px Euclidean deadzone from the gate');
|
|
746
|
+
// mutation-found gap: a bare /skyPanned/ match was satisfied by the flag's
|
|
747
|
+
// declaration alone — pin the CONSUME site and the deferred clear instead
|
|
748
|
+
assert.match(SCRIPT, /if \(skyPanned\) \{ ev\.preventDefault\(\); return; \}/,
|
|
749
|
+
'the delegated click handler consumes the pan flag AND still cancels the link');
|
|
750
|
+
assert.match(SCRIPT, /setTimeout\(function \(\) \{ skyPanned = false; \}, 0\)/,
|
|
751
|
+
'the flag clears a tick after the pan ends, so a keyboard Enter is never eaten');
|
|
752
|
+
assert.match(SCRIPT, /pointercancel/, 'an interrupted touch must not strand the grab state');
|
|
753
|
+
assert.match(SCRIPT, /panId/, 'one pointer owns the drag — a second finger cannot hijack it');
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
test('anchors are never read from a hidden field', () => {
|
|
757
|
+
// review CRITICAL: offsetLeft/Top and clientWidth all read 0 under
|
|
758
|
+
// [hidden]{display:none} — anchoring to zeros paints every orb at
|
|
759
|
+
// slot + physics position when the sky returns. The rebuild must defer.
|
|
760
|
+
const fn = (SCRIPT.match(/function rebuildSkyBodies\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
|
|
761
|
+
assert.ok(fn, 'rebuildSkyBodies() must exist');
|
|
762
|
+
assert.match(fn, /clientWidth\) \{ skyNeedsRebuild = true; return; \}/,
|
|
763
|
+
'an unlaid-out field defers the rebuild instead of anchoring to zeros');
|
|
764
|
+
assert.match(SCRIPT, /if \(skyNeedsRebuild\) rebuildSkyBodies\(\);/,
|
|
765
|
+
'the deferred rebuild is consumed when the sky comes back on stage');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
test('zoom heads for the cursor and survives line-mode wheels', () => {
|
|
769
|
+
assert.match(SCRIPT, /deltaMode/, 'Firefox line-mode deltas must be normalized');
|
|
770
|
+
assert.match(SCRIPT, /Math\.exp\(/, 'exponential zoom — symmetric in and out');
|
|
771
|
+
assert.ok(COSMOS.includes('transform-origin:0 0'),
|
|
772
|
+
'the camera algebra assumes the layer scales about its origin');
|
|
773
|
+
});
|