rubylens 0.1.0 → 0.2.0
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.
- checksums.yaml +4 -4
- data/README.md +67 -39
- data/assets/runtime/report.js +1607 -251
- data/assets/shells/report.html +9 -7
- data/assets/shells/showcase.html +1 -0
- data/assets/styles/report.css +51 -25
- data/assets/styles/showcase.css +3 -0
- data/lib/rubylens/art_model_builder.rb +62 -10
- data/lib/rubylens/artifact_marker.rb +17 -0
- data/lib/rubylens/atomic_output.rb +27 -0
- data/lib/rubylens/cli.rb +53 -36
- data/lib/rubylens/clip/chrome_page.rb +183 -0
- data/lib/rubylens/clip/deadline_io.rb +28 -0
- data/lib/rubylens/clip/renderer.rb +113 -0
- data/lib/rubylens/clip/toolchain.rb +82 -0
- data/lib/rubylens/clip/web_socket_channel.rb +116 -0
- data/lib/rubylens/clip_generator.rb +102 -0
- data/lib/rubylens/default_output.rb +24 -0
- data/lib/rubylens/generator.rb +9 -10
- data/lib/rubylens/git_repository.rb +13 -7
- data/lib/rubylens/index/manifest.rb +8 -7
- data/lib/rubylens/index/rspec_extractor.rb +12 -10
- data/lib/rubylens/index/rubydex_adapter.rb +290 -140
- data/lib/rubylens/index/source_path.rb +12 -11
- data/lib/rubylens/model/dependency_aggregation.rb +9 -7
- data/lib/rubylens/morphology_classifier.rb +52 -12
- data/lib/rubylens/report_writer.rb +5 -20
- data/lib/rubylens/showcase_generator.rb +5 -9
- data/lib/rubylens/showcase_model.rb +12 -14
- data/lib/rubylens/showcase_writer.rb +0 -6
- data/lib/rubylens/version.rb +1 -1
- data/lib/rubylens.rb +7 -0
- metadata +10 -1
data/assets/runtime/report.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
const showcaseDetails = showcaseMode && model.details === true;
|
|
5
5
|
const interactiveMode = !showcaseMode;
|
|
6
6
|
const canvas = document.getElementById("cosmos");
|
|
7
|
+
const travelCanvas = showcaseMode ? document.getElementById("travel-cosmos") : canvas;
|
|
7
8
|
const showcaseStage = document.getElementById("showcase-stage");
|
|
8
9
|
const showcaseStatus = document.getElementById("showcase-status");
|
|
9
10
|
const showcaseAnnotation = document.getElementById("cinema-annotation");
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
const searchInput = document.getElementById("explorer-search");
|
|
17
18
|
const searchStatus = document.getElementById("search-status");
|
|
18
19
|
const searchResults = document.getElementById("search-results");
|
|
20
|
+
const uiToggle = document.getElementById("ui-toggle");
|
|
19
21
|
const helpOverlay = document.getElementById("shortcuts-help");
|
|
20
22
|
const helpClose = document.getElementById("help-close");
|
|
21
23
|
let helpReturnFocus = null;
|
|
@@ -31,27 +33,15 @@
|
|
|
31
33
|
tests: { ancestorDepth: .18, definitionSites: .25, reopenings: .18, descendants: .42, references: .85, members: .55 },
|
|
32
34
|
dependencies: { ancestorDepth: .12, definitionSites: .35, reopenings: .2, descendants: .32, references: .48, members: .4 },
|
|
33
35
|
};
|
|
34
|
-
let width = 0, height = 0, dpr = 1, sceneRight = 0, sceneBottom = 0, sceneCenterX = 0, sceneCenterY = 0, yaw = -.36, pitch = .34, zoom = 1, panX = 0, panY = 0, dragging = false, gesture = null, pinchState = null, animationFrame = 0, hoverFrame = 0, pendingHover = null, selectedPoint = null, selectionLocked = false, focusedCategory = null, expandedSystemIndex = null, expandedPackageIndex = null, activeFactButton = null, navigationMode = "orbit", cameraFlight = null, showcaseStartedAt = null, showcaseRenderer = null, showcaseAnnotationSlot = -1, activeShowcaseAnnotation = null;
|
|
36
|
+
let width = 0, height = 0, dpr = 1, sceneRight = 0, sceneBottom = 0, sceneCenterX = 0, sceneCenterY = 0, yaw = -.36, pitch = .34, zoom = 1, panX = 0, panY = 0, dragging = false, gesture = null, pinchState = null, animationFrame = 0, hoverFrame = 0, pendingHover = null, selectedPoint = null, selectionLocked = false, focusedCategory = null, expandedSystemIndex = null, expandedPackageIndex = null, activeFactButton = null, navigationMode = "orbit", cameraFlight = null, showcaseStartedAt = null, showcaseRenderer = null, showcaseAnnotationSlot = -1, activeShowcaseAnnotation = null, clipMode = false, dependencySpinElapsed = 0;
|
|
35
37
|
const MIN_ZOOM = .35, MAX_ZOOM = 40, ZOOM_STEP = 1.7, DEPENDENCY_EXPANSION = 2.35;
|
|
36
38
|
const CORE_SCALE_BASELINE = 3_000;
|
|
37
|
-
const
|
|
39
|
+
const DEPENDENCY_CLOUD_THRESHOLD = 18;
|
|
40
|
+
const DEPENDENCY_HALO_SPACING_SCALE = 1.15;
|
|
38
41
|
const DEPENDENCY_STAR_ALPHA_SCALE = .85;
|
|
39
42
|
const MORPHOLOGY_FAMILY = Object.freeze({ elliptical: 0, lenticular: 1, spiral: 2, barredSpiral: 3, irregular: 4 });
|
|
40
43
|
const MORPHOLOGY_FAMILY_LABELS = Object.freeze(["Elliptical galaxy", "Lenticular galaxy", "Spiral galaxy", "Barred spiral galaxy", "Irregular galaxy"]);
|
|
41
|
-
const
|
|
42
|
-
legacy: true,
|
|
43
|
-
family: MORPHOLOGY_FAMILY.spiral,
|
|
44
|
-
ellipticity: 0,
|
|
45
|
-
bulgeShare: .24,
|
|
46
|
-
armCount: 3,
|
|
47
|
-
winding: .105,
|
|
48
|
-
armFraction: .38,
|
|
49
|
-
barLength: 0,
|
|
50
|
-
clumpCount: 0,
|
|
51
|
-
clumpSpread: 0,
|
|
52
|
-
phaseSeed: 0,
|
|
53
|
-
phase: 0,
|
|
54
|
-
});
|
|
44
|
+
const FALLBACK_MORPHOLOGY_ROW = Object.freeze([MORPHOLOGY_FAMILY.spiral, 0, 240, 3, 105, 380, 0, 0, 0, 0]);
|
|
55
45
|
const RSPEC_PROXY_PREFIX = "RSpec example group #";
|
|
56
46
|
const DEFAULT_CAMERA = Object.freeze({ yaw: -.36, pitch: .34, zoom: 2, panX: 0, panY: 0 });
|
|
57
47
|
const DEFAULT_ROTATION_DIRECTION = "clockwise";
|
|
@@ -62,7 +52,9 @@
|
|
|
62
52
|
const SEARCH_BATCH_SIZE = 8;
|
|
63
53
|
const WARNING_ROW_LIMIT = 24;
|
|
64
54
|
let lastDriftTimestamp = null;
|
|
55
|
+
let lastDependencySpinTimestamp = null;
|
|
65
56
|
let doubleClickTarget = null;
|
|
57
|
+
let restoreTapDoubleClickDeadline = 0;
|
|
66
58
|
let searchIndex = null;
|
|
67
59
|
let searchTimer = 0;
|
|
68
60
|
let searchMatches = [];
|
|
@@ -81,7 +73,9 @@
|
|
|
81
73
|
"centerXPercent": 49,
|
|
82
74
|
"centerYPercent": 67,
|
|
83
75
|
"starBrightnessPercent": 75,
|
|
84
|
-
"pointGlowPercent":
|
|
76
|
+
"pointGlowPercent": 0,
|
|
77
|
+
"hazeMilkRadius": 12,
|
|
78
|
+
"hazeMilkGainPercent": 12,
|
|
85
79
|
"backgroundGlowPercent": 200,
|
|
86
80
|
"textScalePercent": 80,
|
|
87
81
|
"layoutReferenceWidth": 720,
|
|
@@ -90,6 +84,22 @@
|
|
|
90
84
|
"mastheadTop": 40,
|
|
91
85
|
"mastheadWidth": 632
|
|
92
86
|
});
|
|
87
|
+
// Self-gravitating systems have a characteristic angular frequency
|
|
88
|
+
// proportional to sqrt(mass / radius^3). The host's tidal gradient adds a
|
|
89
|
+
// weaker distance^-3/2 frequency term. Declaration count is the local mass
|
|
90
|
+
// proxy; the result is compressed to whole turns per Showcase loop so Clip
|
|
91
|
+
// keeps its exact seam. Direction and the package-local orbital plane are
|
|
92
|
+
// independent seeded enrichment. Uniform cos(inclination) samples plane
|
|
93
|
+
// normals isotropically instead of inheriting Core's disc plane.
|
|
94
|
+
const DEPENDENCY_SPIN_RECIPE = Object.freeze({
|
|
95
|
+
selfGravityScale: .25,
|
|
96
|
+
tidalScale: .35,
|
|
97
|
+
minimumTurnsPerLoop: 1,
|
|
98
|
+
maximumTurnsPerLoop: 2,
|
|
99
|
+
directionChannel: 104,
|
|
100
|
+
inclinationChannel: 105,
|
|
101
|
+
azimuthChannel: 106,
|
|
102
|
+
});
|
|
93
103
|
const SHOWCASE_WIDESCREEN_LAYOUT_PRESET = Object.freeze({
|
|
94
104
|
"minimumFittedWidth": 1600,
|
|
95
105
|
"minimumAspectRatio": 1.6,
|
|
@@ -126,6 +136,46 @@
|
|
|
126
136
|
"safeInsetBottom": 90,
|
|
127
137
|
"labelWidth": 440
|
|
128
138
|
});
|
|
139
|
+
const TRAVEL_PRESET = Object.freeze({
|
|
140
|
+
"flightDurationMs": 2200,
|
|
141
|
+
"initialDelayMin": 0.2,
|
|
142
|
+
"initialDelayRange": 0.5,
|
|
143
|
+
"intervalMin": 0.55,
|
|
144
|
+
"intervalRange": 0.75,
|
|
145
|
+
"handoffGapMinMs": 24,
|
|
146
|
+
"handoffGapRangeMs": 126,
|
|
147
|
+
"loopQuietMs": 80,
|
|
148
|
+
"tailFraction": 0.7,
|
|
149
|
+
"progressEase": 0.055,
|
|
150
|
+
"fadeInFraction": 0.12,
|
|
151
|
+
"fadeOutStart": 0.82,
|
|
152
|
+
"minimumVisibility": 0.04,
|
|
153
|
+
"minimumRouteLengthPx": 48,
|
|
154
|
+
"admissionInsetPx": 16,
|
|
155
|
+
"admissionCandidateLimit": 12,
|
|
156
|
+
"arcHeightPercent": 24,
|
|
157
|
+
"arcHeightMin": 16,
|
|
158
|
+
"arcHeightMax": 120,
|
|
159
|
+
"tailSegments": 12,
|
|
160
|
+
"tailLengthPx": 145.6,
|
|
161
|
+
"lineWidth": 1.91,
|
|
162
|
+
"tailAlpha": 0.38,
|
|
163
|
+
"tailHeadOverlap": 0.82,
|
|
164
|
+
"headLengthPx": 6.5,
|
|
165
|
+
"headWidthPx": 1.82,
|
|
166
|
+
"headAlpha": 0.64,
|
|
167
|
+
"headGlowAlpha": 0.12,
|
|
168
|
+
"headGlowBlur": 2.95,
|
|
169
|
+
"initialDelayChannel": 13,
|
|
170
|
+
"intervalChannel": 29,
|
|
171
|
+
"handoffGapChannel": 43,
|
|
172
|
+
"candidateChannel": 59,
|
|
173
|
+
"arcDirectionChannel": 71
|
|
174
|
+
});
|
|
175
|
+
function travelFlightLimitForPointCount(pointCount) {
|
|
176
|
+
if (pointCount < 500) return 1;
|
|
177
|
+
return 2;
|
|
178
|
+
}
|
|
129
179
|
const TOP_DOWN_PITCH = Math.PI / 2;
|
|
130
180
|
const CONTEXT_TARGET_X = .32;
|
|
131
181
|
const CONTEXT_CORE_X = .68;
|
|
@@ -144,6 +194,8 @@
|
|
|
144
194
|
const glslVec3 = rgb => `vec3(${rgb.map(channel => channel.toFixed(1)).join(", ")})`;
|
|
145
195
|
const colourStyles = Object.fromEntries(Object.entries(colours).map(([category, rgb]) => [category, `rgb(${rgb.join(",")})`]));
|
|
146
196
|
const projectionScratch = [0, 0, 0];
|
|
197
|
+
const scenePointPositionScratch = [0, 0, 0];
|
|
198
|
+
const dependencySpinScratch = [0, 0, 0];
|
|
147
199
|
let zoomReadout = null;
|
|
148
200
|
let zoomReadoutText = "";
|
|
149
201
|
const showcaseAnnotationData = showcaseDetails && Array.isArray(model.annotations)
|
|
@@ -164,31 +216,44 @@
|
|
|
164
216
|
};
|
|
165
217
|
const unit = (seed, channel) => hash(seed, channel) / 4294967296;
|
|
166
218
|
const normal = (seed, channel) => Math.sqrt(-2 * Math.log(Math.max(unit(seed, channel), 1e-7))) * Math.cos(6.283185 * unit(seed, channel + 1));
|
|
219
|
+
// Spitzer isothermal-sheet vertical draw: a sech^2(z / 2z0) disc profile
|
|
220
|
+
// via its logistic inverse CDF, scaled to Gaussian-sigma-equivalent units.
|
|
221
|
+
const sheet = (seed, channel) => {
|
|
222
|
+
const share = Math.min(.996, Math.max(.004, unit(seed, channel)));
|
|
223
|
+
return .551 * Math.log(share / (1 - share));
|
|
224
|
+
};
|
|
167
225
|
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
|
226
|
+
function decodeConstantReferenceLinks(rows) {
|
|
227
|
+
if (!Array.isArray(rows)) return [];
|
|
228
|
+
const seen = new Set();
|
|
229
|
+
const endpointCount = model.namespaces.length + model.dependencyStars.length;
|
|
230
|
+
return rows.slice(0, 1_024).reduce((links, row) => {
|
|
231
|
+
if (!Array.isArray(row) || row.length !== 2 || !row.every(Number.isInteger)) return links;
|
|
232
|
+
const [referringIndex, referencedIndex] = row;
|
|
233
|
+
if (referringIndex < 0 || referringIndex >= model.namespaces.length) return links;
|
|
234
|
+
if (referencedIndex < 0 || referencedIndex >= endpointCount) return links;
|
|
235
|
+
if (referringIndex === referencedIndex) return links;
|
|
236
|
+
const key = `${referringIndex}:${referencedIndex}`;
|
|
237
|
+
if (seen.has(key)) return links;
|
|
238
|
+
seen.add(key);
|
|
239
|
+
links.push(Object.freeze({
|
|
240
|
+
departureIndex: referencedIndex,
|
|
241
|
+
arrivalIndex: referringIndex,
|
|
242
|
+
}));
|
|
243
|
+
return links;
|
|
244
|
+
}, []);
|
|
245
|
+
}
|
|
168
246
|
function fallbackMorphology(phaseSeed = 0) {
|
|
169
247
|
const normalizedSeed = Number.isInteger(phaseSeed) && phaseSeed >= 0 && phaseSeed <= 0xffff_ffff ? phaseSeed >>> 0 : 0;
|
|
170
|
-
|
|
171
|
-
return Object.freeze({
|
|
172
|
-
...LEGACY_MORPHOLOGY,
|
|
173
|
-
phaseSeed: normalizedSeed,
|
|
174
|
-
phase: unit(normalizedSeed, 80) * Math.PI * 2,
|
|
175
|
-
});
|
|
248
|
+
return decodeMorphology([...FALLBACK_MORPHOLOGY_ROW.slice(0, 9), normalizedSeed]);
|
|
176
249
|
}
|
|
177
|
-
function decodeMorphology(
|
|
178
|
-
|
|
179
|
-
? raw
|
|
180
|
-
: raw && typeof raw === "object" && Array.isArray(raw.knobs)
|
|
181
|
-
? [raw.family, ...raw.knobs]
|
|
182
|
-
: null;
|
|
183
|
-
if (!row || row.length !== 10 || !row.every(Number.isInteger)) return fallbackMorphology(fallbackPhaseSeed);
|
|
250
|
+
function decodeMorphology(row, fallbackPhaseSeed = 0) {
|
|
251
|
+
if (!Array.isArray(row) || row.length !== 10 || !row.every(Number.isInteger)) return fallbackMorphology(fallbackPhaseSeed);
|
|
184
252
|
const family = row[0];
|
|
185
253
|
if (family < MORPHOLOGY_FAMILY.elliptical || family > MORPHOLOGY_FAMILY.irregular) return fallbackMorphology(fallbackPhaseSeed);
|
|
186
254
|
if (row[9] < 0 || row[9] > 0xffff_ffff) return fallbackMorphology(fallbackPhaseSeed);
|
|
187
|
-
const legacy = family === MORPHOLOGY_FAMILY.spiral &&
|
|
188
|
-
row.slice(1).every((value, index) => value === [0, 240, 3, 105, 380, 0, 0, 0, 0][index]);
|
|
189
255
|
const phaseSeed = row[9] >>> 0;
|
|
190
256
|
return Object.freeze({
|
|
191
|
-
legacy,
|
|
192
257
|
family,
|
|
193
258
|
ellipticity: family === MORPHOLOGY_FAMILY.elliptical ? clamp(row[1], 0, 700) / 1000 : 0,
|
|
194
259
|
bulgeShare: [MORPHOLOGY_FAMILY.lenticular, MORPHOLOGY_FAMILY.spiral, MORPHOLOGY_FAMILY.barredSpiral].includes(family)
|
|
@@ -199,7 +264,7 @@
|
|
|
199
264
|
: family === MORPHOLOGY_FAMILY.barredSpiral ? clamp(row[3], 2, 4) : 0,
|
|
200
265
|
winding: [MORPHOLOGY_FAMILY.spiral, MORPHOLOGY_FAMILY.barredSpiral].includes(family) ? clamp(row[4], 40, 220) / 1000 : 0,
|
|
201
266
|
armFraction: [MORPHOLOGY_FAMILY.spiral, MORPHOLOGY_FAMILY.barredSpiral].includes(family) ? clamp(row[5], 0, 800) / 1000 : 0,
|
|
202
|
-
barLength: family === MORPHOLOGY_FAMILY.barredSpiral ? clamp(row[6], 100,
|
|
267
|
+
barLength: family === MORPHOLOGY_FAMILY.barredSpiral ? clamp(row[6], 100, 800) / 1000 : 0,
|
|
203
268
|
clumpCount: family === MORPHOLOGY_FAMILY.irregular ? clamp(row[7], 2, 5) : 0,
|
|
204
269
|
clumpSpread: family === MORPHOLOGY_FAMILY.irregular ? clamp(row[8], 250, 1000) / 1000 : 0,
|
|
205
270
|
phaseSeed,
|
|
@@ -234,19 +299,19 @@
|
|
|
234
299
|
const cameraScale = Math.pow(tests, .8);
|
|
235
300
|
let coreExtent = 42;
|
|
236
301
|
let testExtent = 62;
|
|
237
|
-
if (
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
302
|
+
if (activeMorphology.family === MORPHOLOGY_FAMILY.elliptical) {
|
|
303
|
+
coreExtent = 36;
|
|
304
|
+
testExtent = 52;
|
|
305
|
+
} else if (activeMorphology.family === MORPHOLOGY_FAMILY.lenticular) {
|
|
306
|
+
const discProgress = clamp((.42 - activeMorphology.bulgeShare) / .08, 0, 1);
|
|
307
|
+
coreExtent = 42 + 2 * discProgress;
|
|
308
|
+
testExtent = 54 + 4 * discProgress;
|
|
309
|
+
} else if (activeMorphology.family === MORPHOLOGY_FAMILY.barredSpiral) {
|
|
310
|
+
testExtent = Math.max(17, activeMorphology.barLength * 48) + 45;
|
|
311
|
+
} else if (activeMorphology.family === MORPHOLOGY_FAMILY.irregular) {
|
|
312
|
+
const centerSpread = 12 + 18 * activeMorphology.clumpSpread;
|
|
313
|
+
coreExtent = centerSpread + 24;
|
|
314
|
+
testExtent = centerSpread + 32;
|
|
250
315
|
}
|
|
251
316
|
return Object.freeze({
|
|
252
317
|
disk,
|
|
@@ -261,12 +326,61 @@
|
|
|
261
326
|
});
|
|
262
327
|
}
|
|
263
328
|
|
|
264
|
-
const coreCount = model.namespaces.reduce((count, row) => count + (row[
|
|
329
|
+
const coreCount = model.namespaces.reduce((count, row) => count + (row[2] === 1 ? 0 : 1), 0);
|
|
265
330
|
const layoutScale = layoutMetricsForCoreCount(coreCount, morphology);
|
|
266
331
|
const cameraDistance = layoutScale.cameraDistance;
|
|
267
332
|
const cameraFocalLength = layoutScale.cameraFocalLength;
|
|
333
|
+
const barRadius = morphology.barLength * 48;
|
|
334
|
+
// Arm recipe shared by the project galaxy and dependency clouds. Seeded
|
|
335
|
+
// jitters draw from a galaxy's phase seed on the channels below, so each
|
|
336
|
+
// galaxy varies while every one of its stars agrees.
|
|
337
|
+
const ARM_RECIPE = Object.freeze({
|
|
338
|
+
// The winding knob maps linearly to tan(pitch), floored near 7 degrees
|
|
339
|
+
// and reaching about 24 degrees for the loosest arms.
|
|
340
|
+
tanPitchFloor: .12,
|
|
341
|
+
tanPitchIntercept: .43,
|
|
342
|
+
tanPitchSlope: 1.5,
|
|
343
|
+
// Per-bar-end pitch (x0.82-1.18) and trailing-side-only takeoff
|
|
344
|
+
// (0-0.12 rad) jitters keep the arm pair from mirroring.
|
|
345
|
+
pitchJitterFloor: .82,
|
|
346
|
+
pitchJitterSpan: .36,
|
|
347
|
+
pitchChannel: 60,
|
|
348
|
+
takeoffJitter: .12,
|
|
349
|
+
takeoffChannel: 66,
|
|
350
|
+
// Third and fourth arms ride a main arm, then fork off at 1.3-1.8x the
|
|
351
|
+
// bar radius and open with a 1.4-1.9x looser pitch.
|
|
352
|
+
forkRadialFloor: 1.3,
|
|
353
|
+
forkRadialSpan: .5,
|
|
354
|
+
forkRadialChannel: 72,
|
|
355
|
+
forkPitchFloor: 1.4,
|
|
356
|
+
forkPitchSpan: .5,
|
|
357
|
+
forkPitchChannel: 84,
|
|
358
|
+
// Roughly a third of barred galaxies are ringed SB(r) systems; rings
|
|
359
|
+
// take 15% of arm members, hug the bar tips at 1.05-1.15x their
|
|
360
|
+
// radius, and sit 15% squashed across the bar.
|
|
361
|
+
ringGalaxyShare: .3,
|
|
362
|
+
ringGalaxyChannel: 88,
|
|
363
|
+
ringMemberShare: .15,
|
|
364
|
+
ringRadialFloor: 1.05,
|
|
365
|
+
ringRadialSpan: .1,
|
|
366
|
+
ringSquash: .15,
|
|
367
|
+
// 22% of arm members feather into a 2.6x wider sheath, and arm width
|
|
368
|
+
// grows with radius from a .55 base, so arms read as overdensities
|
|
369
|
+
// rather than rails.
|
|
370
|
+
featherShare: .22,
|
|
371
|
+
featherWidth: 2.6,
|
|
372
|
+
widthFloor: .55,
|
|
373
|
+
// Unbarred arms sweep from the log-spiral origin radius but admit
|
|
374
|
+
// members only beyond the minimum, where the spiral has visibly curved
|
|
375
|
+
// away from its takeoff angle; closer in, members share that angle and
|
|
376
|
+
// would draw a cross of straight spokes through the core.
|
|
377
|
+
unbarredOriginRadial: 6,
|
|
378
|
+
unbarredMinimumRadial: 8,
|
|
379
|
+
});
|
|
380
|
+
const barredRing = morphology.family === MORPHOLOGY_FAMILY.barredSpiral &&
|
|
381
|
+
unit(morphology.phaseSeed, ARM_RECIPE.ringGalaxyChannel) < ARM_RECIPE.ringGalaxyShare;
|
|
268
382
|
|
|
269
|
-
const irregularClumpCenters = morphology.family === MORPHOLOGY_FAMILY.irregular
|
|
383
|
+
const irregularClumpCenters = morphology.family === MORPHOLOGY_FAMILY.irregular
|
|
270
384
|
? Array.from({ length: morphology.clumpCount }, (_, index) => {
|
|
271
385
|
const centerSpread = 12 + 18 * morphology.clumpSpread;
|
|
272
386
|
const angle = morphology.phase + index * Math.PI * 2 / morphology.clumpCount + (unit(morphology.phaseSeed, 90 + index * 3) - .5) * .8;
|
|
@@ -295,22 +409,76 @@
|
|
|
295
409
|
];
|
|
296
410
|
}
|
|
297
411
|
|
|
298
|
-
|
|
412
|
+
const armTanPitch = winding =>
|
|
413
|
+
Math.max(ARM_RECIPE.tanPitchFloor, ARM_RECIPE.tanPitchIntercept - ARM_RECIPE.tanPitchSlope * winding);
|
|
414
|
+
|
|
415
|
+
// Constant-pitch logarithmic arm angle shared by the project galaxy and
|
|
416
|
+
// dependency clouds: exactly one arm trails each bar end (takeoff jitter
|
|
417
|
+
// stays on the trailing side), each end draws its own seeded pitch so the
|
|
418
|
+
// pair is not a mirror image, and third and fourth arms are downstream
|
|
419
|
+
// bifurcations that ride a main arm before forking outward with a looser
|
|
420
|
+
// pitch, as in real multi-armed barred galaxies.
|
|
421
|
+
function barredArmTheta(phaseSeed, phase, winding, arm, armRadial, tipRadial) {
|
|
422
|
+
const barEnd = arm % 2;
|
|
423
|
+
const branch = Math.floor(arm / 2);
|
|
424
|
+
const tanPitch = armTanPitch(winding) *
|
|
425
|
+
(ARM_RECIPE.pitchJitterFloor + ARM_RECIPE.pitchJitterSpan * unit(phaseSeed, ARM_RECIPE.pitchChannel + barEnd));
|
|
426
|
+
const origin = phase + barEnd * Math.PI + unit(phaseSeed, ARM_RECIPE.takeoffChannel + barEnd) * ARM_RECIPE.takeoffJitter;
|
|
427
|
+
let sweep = Math.log(armRadial / tipRadial) / tanPitch;
|
|
428
|
+
if (branch > 0) {
|
|
429
|
+
const forkRadial = tipRadial *
|
|
430
|
+
(ARM_RECIPE.forkRadialFloor + ARM_RECIPE.forkRadialSpan * unit(phaseSeed, ARM_RECIPE.forkRadialChannel + arm));
|
|
431
|
+
if (armRadial > forkRadial) {
|
|
432
|
+
sweep = Math.log(forkRadial / tipRadial) / tanPitch +
|
|
433
|
+
Math.log(armRadial / forkRadial) / (tanPitch *
|
|
434
|
+
(ARM_RECIPE.forkPitchFloor + ARM_RECIPE.forkPitchSpan * unit(phaseSeed, ARM_RECIPE.forkPitchChannel + arm)));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return origin + sweep;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// SB(r) variant: an inner ring hugging the bar ends, slightly elongated
|
|
441
|
+
// along the bar. Shared by the project galaxy and dependency clouds.
|
|
442
|
+
function barredRingPlacement(phase, tipRadial, radialUnit, thetaUnit) {
|
|
443
|
+
const ringTheta = thetaUnit * Math.PI * 2;
|
|
444
|
+
const across = Math.sin(ringTheta);
|
|
445
|
+
return [
|
|
446
|
+
phase + ringTheta,
|
|
447
|
+
tipRadial * (ARM_RECIPE.ringRadialFloor + ARM_RECIPE.ringRadialSpan * radialUnit) * (1 - ARM_RECIPE.ringSquash * across * across),
|
|
448
|
+
];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// The arm's center angle at a radius — the one arm-angle law shared by
|
|
452
|
+
// star placement and the dust lanes that hug those arms.
|
|
453
|
+
function armCenterTheta(arm, radial) {
|
|
454
|
+
if (morphology.family === MORPHOLOGY_FAMILY.barredSpiral) {
|
|
455
|
+
return barredArmTheta(morphology.phaseSeed, morphology.phase, morphology.winding, arm, radial, barRadius);
|
|
456
|
+
}
|
|
457
|
+
const origin = ARM_RECIPE.unbarredOriginRadial;
|
|
458
|
+
return morphology.phase + arm * Math.PI * 2 / morphology.armCount +
|
|
459
|
+
Math.log(Math.max(radial, origin) / origin) / armTanPitch(morphology.winding);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Answers both the arm angle and the point's (possibly adjusted) radius:
|
|
463
|
+
// barred-spiral arms unwind from the bar tips, so arm members that fall
|
|
464
|
+
// inside the bar are respread outward along the arm, densest at the tip.
|
|
465
|
+
function spiralArmPlacement(seed, radial, channel) {
|
|
299
466
|
const arm = Math.floor(unit(seed, channel) * morphology.armCount);
|
|
300
467
|
const scatter = .22 - (morphology.armCount - 2) * .025;
|
|
301
|
-
let origin;
|
|
302
|
-
let armRadial = radial;
|
|
303
468
|
if (morphology.family === MORPHOLOGY_FAMILY.barredSpiral) {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
469
|
+
const armRadial = radial < barRadius
|
|
470
|
+
? barRadius + radial / barRadius * (40 - barRadius)
|
|
471
|
+
: radial;
|
|
472
|
+
if (barredRing && unit(seed, channel + 5) < ARM_RECIPE.ringMemberShare) {
|
|
473
|
+
return barredRingPlacement(morphology.phase, barRadius, unit(seed, channel + 6), unit(seed, channel + 7));
|
|
474
|
+
}
|
|
475
|
+
const theta = armCenterTheta(arm, armRadial);
|
|
476
|
+
const feather = unit(seed, channel + 2) < ARM_RECIPE.featherShare ? ARM_RECIPE.featherWidth : 1;
|
|
477
|
+
const width = scatter * (ARM_RECIPE.widthFloor + armRadial / 64) * Math.min(1, 36 / armRadial) * feather;
|
|
478
|
+
return [theta + normal(seed, channel + 3) * width, armRadial];
|
|
312
479
|
}
|
|
313
|
-
|
|
480
|
+
const theta = armCenterTheta(arm, radial) + normal(seed, channel + 1) * scatter;
|
|
481
|
+
return [theta, radial];
|
|
314
482
|
}
|
|
315
483
|
|
|
316
484
|
function irregularPosition(seed, outer) {
|
|
@@ -330,9 +498,21 @@
|
|
|
330
498
|
}
|
|
331
499
|
|
|
332
500
|
function barredCorePosition(seed, vertical, scale) {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
501
|
+
let along;
|
|
502
|
+
let acrossSigma;
|
|
503
|
+
if (morphology.barLength > .45 && unit(seed, 37) < .1) {
|
|
504
|
+
// Long early-type bars carry ansae: bright knots near the bar ends.
|
|
505
|
+
along = (unit(seed, 38) < .5 ? 1 : -1) * barRadius * .9 + normal(seed, 39) * barRadius * .08;
|
|
506
|
+
acrossSigma = barRadius * .08;
|
|
507
|
+
} else {
|
|
508
|
+
const draw = unit(seed, 34) * 2 - 1;
|
|
509
|
+
// Flat density along early-type bars, exponential-like falloff for
|
|
510
|
+
// short late-type bars; lens envelope tapering into the tips.
|
|
511
|
+
along = (morphology.barLength < .36 ? Math.sign(draw) * Math.pow(Math.abs(draw), 1.5) : draw) * barRadius;
|
|
512
|
+
const taper = Math.sqrt(Math.max(.15, 1 - (along / barRadius) * (along / barRadius)));
|
|
513
|
+
acrossSigma = barRadius * .16 * taper;
|
|
514
|
+
}
|
|
515
|
+
const across = normal(seed, 35) * acrossSigma;
|
|
336
516
|
const cos = Math.cos(morphology.phase), sin = Math.sin(morphology.phase);
|
|
337
517
|
return [
|
|
338
518
|
(cos * along - sin * across) * scale,
|
|
@@ -341,61 +521,49 @@
|
|
|
341
521
|
];
|
|
342
522
|
}
|
|
343
523
|
|
|
344
|
-
function coreDiscUsesArm(seed, bulge) {
|
|
524
|
+
function coreDiscUsesArm(seed, bulge, radial) {
|
|
345
525
|
return !bulge &&
|
|
346
526
|
spiralMorphology &&
|
|
527
|
+
(morphology.family === MORPHOLOGY_FAMILY.barredSpiral || radial > ARM_RECIPE.unbarredMinimumRadial) &&
|
|
347
528
|
unit(seed, 30) < morphology.armFraction;
|
|
348
529
|
}
|
|
349
530
|
|
|
350
531
|
function corePosition(seed) {
|
|
351
|
-
if (morphology.legacy) {
|
|
352
|
-
const bulge = unit(seed, 2) < .24;
|
|
353
|
-
const radial = bulge ? 17 * Math.pow(unit(seed, 3), 1.75) : Math.min(42, -10 * Math.log(Math.max(1e-5, 1 - unit(seed, 3))));
|
|
354
|
-
const theta = unit(seed, 4) * Math.PI * 2 + radial * .04;
|
|
355
|
-
const vertical = normal(seed, 5) * (bulge ? 5.8 : 1.4 + radial * .025);
|
|
356
|
-
const scale = bulge ? layoutScale.bulge : layoutScale.disk;
|
|
357
|
-
return [Math.cos(theta) * radial * scale, vertical * scale, Math.sin(theta) * radial * scale];
|
|
358
|
-
}
|
|
359
532
|
if (morphology.family === MORPHOLOGY_FAMILY.elliptical) return spheroidPosition(seed, false);
|
|
360
533
|
if (morphology.family === MORPHOLOGY_FAMILY.irregular) return irregularPosition(seed, false);
|
|
361
534
|
|
|
362
535
|
const bulge = unit(seed, 2) < morphology.bulgeShare;
|
|
363
536
|
const discLimit = morphology.family === MORPHOLOGY_FAMILY.lenticular ? layoutScale.coreOuterRadius / layoutScale.disk : 42;
|
|
364
537
|
const radial = bulge ? 17 * Math.pow(unit(seed, 3), 1.75) : Math.min(discLimit, -10 * Math.log(Math.max(1e-5, 1 - unit(seed, 3))));
|
|
365
|
-
const vertical = normal(seed, 5) * (bulge ? 5.8 : 1.4 + radial * .025);
|
|
366
538
|
const scale = bulge ? layoutScale.bulge : layoutScale.disk;
|
|
367
|
-
if (!bulge && morphology.family === MORPHOLOGY_FAMILY.barredSpiral &&
|
|
368
|
-
return barredCorePosition(seed,
|
|
539
|
+
if (!bulge && morphology.family === MORPHOLOGY_FAMILY.barredSpiral && unit(seed, 33) < .32) {
|
|
540
|
+
return barredCorePosition(seed, sheet(seed, 5) * 1.6, scale);
|
|
369
541
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
542
|
+
if (coreDiscUsesArm(seed, bulge, radial)) {
|
|
543
|
+
const [theta, armRadial] = spiralArmPlacement(seed, radial, 43);
|
|
544
|
+
return [Math.cos(theta) * armRadial * scale, sheet(seed, 5) * (1.4 + armRadial * .025) * scale, Math.sin(theta) * armRadial * scale];
|
|
545
|
+
}
|
|
546
|
+
const vertical = bulge ? normal(seed, 5) * 5.8 : sheet(seed, 5) * (1.4 + radial * .025);
|
|
547
|
+
const theta = morphology.phase + unit(seed, 4) * Math.PI * 2 + radial * .04;
|
|
374
548
|
return [Math.cos(theta) * radial * scale, vertical * scale, Math.sin(theta) * radial * scale];
|
|
375
549
|
}
|
|
376
550
|
|
|
377
551
|
function testPosition(seed) {
|
|
378
|
-
if (morphology.legacy) {
|
|
379
|
-
const radial = 17 + Math.min(45, -14 * Math.log(Math.max(1e-5, 1 - unit(seed, 7))));
|
|
380
|
-
const arm = Math.floor(unit(seed, 8) * 3);
|
|
381
|
-
const inArm = unit(seed, 9) < .38;
|
|
382
|
-
const theta = inArm
|
|
383
|
-
? arm * (Math.PI * 2 / 3) + radial * .105 + normal(seed, 10) * .22
|
|
384
|
-
: unit(seed, 10) * Math.PI * 2;
|
|
385
|
-
const vertical = normal(seed, 11) * (1.4 + radial * .035);
|
|
386
|
-
return [Math.cos(theta) * radial * layoutScale.tests, vertical * layoutScale.tests, Math.sin(theta) * radial * layoutScale.tests];
|
|
387
|
-
}
|
|
388
552
|
if (morphology.family === MORPHOLOGY_FAMILY.elliptical) return spheroidPosition(seed, true);
|
|
389
553
|
if (morphology.family === MORPHOLOGY_FAMILY.irregular) return irregularPosition(seed, true);
|
|
390
554
|
|
|
555
|
+
const discFloor = morphology.family === MORPHOLOGY_FAMILY.barredSpiral
|
|
556
|
+
? Math.max(17, barRadius) - 5 * unit(seed, 28)
|
|
557
|
+
: 17;
|
|
391
558
|
const radial = morphology.family === MORPHOLOGY_FAMILY.lenticular
|
|
392
559
|
? 16 + Math.min(layoutScale.testOuterRadius / layoutScale.tests - 16, -13 * Math.log(Math.max(1e-5, 1 - unit(seed, 7))))
|
|
393
|
-
:
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
const
|
|
560
|
+
: discFloor + Math.min(45, -14 * Math.log(Math.max(1e-5, 1 - unit(seed, 7))));
|
|
561
|
+
if (spiralMorphology && unit(seed, 9) < morphology.armFraction) {
|
|
562
|
+
const [theta, armRadial] = spiralArmPlacement(seed, radial, 13);
|
|
563
|
+
return [Math.cos(theta) * armRadial * layoutScale.tests, sheet(seed, 11) * (1.4 + armRadial * .035) * layoutScale.tests, Math.sin(theta) * armRadial * layoutScale.tests];
|
|
564
|
+
}
|
|
565
|
+
const theta = morphology.phase + unit(seed, 10) * Math.PI * 2;
|
|
566
|
+
const vertical = sheet(seed, 11) * (1.4 + radial * .035);
|
|
399
567
|
return [Math.cos(theta) * radial * layoutScale.tests, vertical * layoutScale.tests, Math.sin(theta) * radial * layoutScale.tests];
|
|
400
568
|
}
|
|
401
569
|
|
|
@@ -425,7 +593,7 @@
|
|
|
425
593
|
const declarationCount = Math.max(0, Number(packageRow[3]) || 0);
|
|
426
594
|
return Object.freeze({
|
|
427
595
|
...decodeMorphology(raw, phaseSeed),
|
|
428
|
-
compact: declarationCount <
|
|
596
|
+
compact: declarationCount < DEPENDENCY_CLOUD_THRESHOLD,
|
|
429
597
|
});
|
|
430
598
|
}
|
|
431
599
|
|
|
@@ -465,14 +633,22 @@
|
|
|
465
633
|
);
|
|
466
634
|
}
|
|
467
635
|
|
|
636
|
+
const barred = cloud.family === MORPHOLOGY_FAMILY.barredSpiral;
|
|
468
637
|
const bulge = unit(seed, 18) < cloud.bulgeShare;
|
|
638
|
+
const radialUnit = unit(seed, 19);
|
|
639
|
+
const inArm = unit(seed, 22) < cloud.armFraction;
|
|
640
|
+
const spiralArmTail = cloud.family === MORPHOLOGY_FAMILY.spiral && !bulge && inArm
|
|
641
|
+
? .12 * Math.max(0, -Math.log((1 - radialUnit) / .15))
|
|
642
|
+
: 0;
|
|
469
643
|
const radial = bulge
|
|
470
|
-
? radius * .36 * Math.pow(
|
|
471
|
-
: radius * (.2 + .72 * Math.sqrt(
|
|
472
|
-
const vertical = clamp(normal(seed,
|
|
473
|
-
if (
|
|
474
|
-
const
|
|
475
|
-
const
|
|
644
|
+
? radius * .36 * Math.pow(radialUnit, 1.55)
|
|
645
|
+
: radius * (.2 + .72 * Math.sqrt(radialUnit) + spiralArmTail);
|
|
646
|
+
const vertical = clamp(bulge ? normal(seed, 26) : sheet(seed, 26), -2.2, 2.2) * radius * (bulge ? .13 : .055);
|
|
647
|
+
if (barred && !bulge && unit(seed, 21) < .34) {
|
|
648
|
+
const halfLength = radius * cloud.barLength;
|
|
649
|
+
const along = (unit(seed, 22) * 2 - 1) * halfLength;
|
|
650
|
+
const taper = Math.sqrt(Math.max(.15, 1 - (along / halfLength) * (along / halfLength)));
|
|
651
|
+
const across = clamp(normal(seed, 23), -2.2, 2.2) * halfLength * .16 * taper;
|
|
476
652
|
return boundedDependencyOffset(
|
|
477
653
|
Math.cos(cloud.phase) * along - Math.sin(cloud.phase) * across,
|
|
478
654
|
vertical,
|
|
@@ -482,20 +658,35 @@
|
|
|
482
658
|
}
|
|
483
659
|
|
|
484
660
|
const armCount = Math.max(2, cloud.armCount);
|
|
485
|
-
const arm = Math.floor(unit(seed,
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
661
|
+
const arm = Math.floor(unit(seed, 25) * armCount);
|
|
662
|
+
if (barred && !bulge && inArm) {
|
|
663
|
+
const tipRadial = radius * cloud.barLength;
|
|
664
|
+
const rootShare = (radial / radius - .2) / Math.max(.05, cloud.barLength - .2);
|
|
665
|
+
const armRadial = radial < tipRadial
|
|
666
|
+
? tipRadial + rootShare * rootShare * (radius * .92 - tipRadial) * .5
|
|
667
|
+
: radial;
|
|
668
|
+
if (unit(cloud.phaseSeed, ARM_RECIPE.ringGalaxyChannel) < ARM_RECIPE.ringGalaxyShare && unit(seed, 29) < ARM_RECIPE.ringMemberShare) {
|
|
669
|
+
const [ringTheta, ringRadial] = barredRingPlacement(cloud.phase, tipRadial, unit(seed, 30), unit(seed, 31));
|
|
670
|
+
return boundedDependencyOffset(Math.cos(ringTheta) * ringRadial, vertical, Math.sin(ringTheta) * ringRadial, radius);
|
|
671
|
+
}
|
|
672
|
+
const feather = unit(seed, 28) < ARM_RECIPE.featherShare ? ARM_RECIPE.featherWidth : 1;
|
|
673
|
+
const width = .16 * (ARM_RECIPE.widthFloor + armRadial / (radius * .92)) * Math.min(1, radius * .52 / armRadial) * feather;
|
|
674
|
+
const theta = barredArmTheta(cloud.phaseSeed, cloud.phase, cloud.winding, arm, armRadial, tipRadial) + normal(seed, 23) * width;
|
|
675
|
+
return boundedDependencyOffset(Math.cos(theta) * armRadial, vertical, Math.sin(theta) * armRadial, radius);
|
|
676
|
+
}
|
|
677
|
+
const theta = inArm && !barred && !bulge
|
|
678
|
+
? cloud.phase + arm * Math.PI * 2 / armCount +
|
|
679
|
+
Math.log(Math.max(radial, radius * .2) / (radius * .2)) / armTanPitch(cloud.winding) + normal(seed, 23) * .17
|
|
491
680
|
: cloud.phase + unit(seed, 23) * Math.PI * 2;
|
|
492
|
-
|
|
681
|
+
const x = Math.cos(theta) * radial;
|
|
682
|
+
const z = Math.sin(theta) * radial;
|
|
683
|
+
return spiralArmTail > 0 ? [x, vertical, z] : boundedDependencyOffset(x, vertical, z, radius);
|
|
493
684
|
}
|
|
494
685
|
const dependencyAnchor = (seed, declarationCount) => {
|
|
495
686
|
const radius = layoutScale.dependencyInnerRadius + 72 * Math.sqrt(layoutScale.tests) * Math.pow(unit(seed, 14), .72);
|
|
496
687
|
const theta = unit(seed, 15) * Math.PI * 2;
|
|
497
688
|
const vertical = normal(seed, 16) * 24 * Math.sqrt(layoutScale.tests);
|
|
498
|
-
return [Math.cos(theta) * radius, vertical, Math.sin(theta) * radius, 1.6 + Math.
|
|
689
|
+
return [Math.cos(theta) * radius, vertical, Math.sin(theta) * radius, 1.6 + Math.sqrt(declarationCount) * .055];
|
|
499
690
|
};
|
|
500
691
|
const systemAnchors = dependencySystems.map((row, index) => {
|
|
501
692
|
const anchor = dependencyAnchor(row[0], systemAggregates[index]?.declarationCount || 0);
|
|
@@ -505,7 +696,7 @@
|
|
|
505
696
|
});
|
|
506
697
|
const packageAnchors = model.packages.map((row, index) => {
|
|
507
698
|
const systemIndex = Number(row[8]);
|
|
508
|
-
const cloudRadius = 1.6 + Math.
|
|
699
|
+
const cloudRadius = 1.6 + Math.sqrt(row[3]) * .055;
|
|
509
700
|
if (!Number.isInteger(systemIndex) || systemIndex < 0 || !systemAnchors[systemIndex]) {
|
|
510
701
|
return [...dependencyAnchor(row[0], row[3]), index, -1];
|
|
511
702
|
}
|
|
@@ -526,14 +717,124 @@
|
|
|
526
717
|
systemIndex,
|
|
527
718
|
];
|
|
528
719
|
});
|
|
720
|
+
for (const anchors of [systemAnchors, packageAnchors]) {
|
|
721
|
+
for (const anchor of anchors) {
|
|
722
|
+
anchor[0] *= DEPENDENCY_HALO_SPACING_SCALE;
|
|
723
|
+
anchor[1] *= DEPENDENCY_HALO_SPACING_SCALE;
|
|
724
|
+
anchor[2] *= DEPENDENCY_HALO_SPACING_SCALE;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const IDENTITY_DEPENDENCY_ORIENTATION = Object.freeze([0, 1, 0, 1, 0, 0, 0, 0, 1]);
|
|
729
|
+
function dependencyOrientation(seed) {
|
|
730
|
+
const normalY = unit(seed, DEPENDENCY_SPIN_RECIPE.inclinationChannel);
|
|
731
|
+
const horizontal = Math.sqrt(Math.max(0, 1 - normalY * normalY));
|
|
732
|
+
const azimuth = unit(seed, DEPENDENCY_SPIN_RECIPE.azimuthChannel) * Math.PI * 2;
|
|
733
|
+
const cosine = Math.cos(azimuth);
|
|
734
|
+
const sine = Math.sin(azimuth);
|
|
735
|
+
return Object.freeze([
|
|
736
|
+
horizontal * cosine, normalY, horizontal * sine,
|
|
737
|
+
normalY * cosine, -horizontal, normalY * sine,
|
|
738
|
+
-sine, 0, cosine,
|
|
739
|
+
]);
|
|
740
|
+
}
|
|
741
|
+
const packageOrientations = model.packages.map(row => dependencyOrientation(row[0]));
|
|
742
|
+
|
|
743
|
+
function orientDependencyOffset(offset, orientation, out = [0, 0, 0]) {
|
|
744
|
+
out[0] = offset[0] * orientation[3] + offset[1] * orientation[0] + offset[2] * orientation[6];
|
|
745
|
+
out[1] = offset[0] * orientation[4] + offset[1] * orientation[1] + offset[2] * orientation[7];
|
|
746
|
+
out[2] = offset[0] * orientation[5] + offset[1] * orientation[2] + offset[2] * orientation[8];
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function dependencySpinTurns(packageRow, anchor) {
|
|
751
|
+
const declarationCount = Math.max(1, Number(packageRow?.[3]) || 0);
|
|
752
|
+
const cloudRadius = Math.max(1e-6, Number(anchor?.[3]) || 0);
|
|
753
|
+
const coreDistance = Math.max(1e-6, Math.hypot(anchor?.[0] || 0, anchor?.[1] || 0, anchor?.[2] || 0)) /
|
|
754
|
+
DEPENDENCY_HALO_SPACING_SCALE;
|
|
755
|
+
const selfGravityFrequency = Math.sqrt(declarationCount / cloudRadius ** 3);
|
|
756
|
+
const tidalFrequency = (layoutScale.dependencyInnerRadius / coreDistance) ** 1.5;
|
|
757
|
+
const characteristicFrequency = selfGravityFrequency * DEPENDENCY_SPIN_RECIPE.selfGravityScale +
|
|
758
|
+
tidalFrequency * DEPENDENCY_SPIN_RECIPE.tidalScale;
|
|
759
|
+
return clamp(
|
|
760
|
+
Math.round(characteristicFrequency),
|
|
761
|
+
DEPENDENCY_SPIN_RECIPE.minimumTurnsPerLoop,
|
|
762
|
+
DEPENDENCY_SPIN_RECIPE.maximumTurnsPerLoop,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const packageSpinRates = model.packages.map((row, index) => {
|
|
767
|
+
const direction = unit(row[0], DEPENDENCY_SPIN_RECIPE.directionChannel) < .5 ? -1 : 1;
|
|
768
|
+
const turns = dependencySpinTurns(row, packageAnchors[index]);
|
|
769
|
+
return direction * turns * Math.PI * 2 / (SHOWCASE_PRESET.durationMs / 1000);
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
function dependencySpunPosition(positionX, positionY, positionZ, packageIndex, elapsed, out = [0, 0, 0]) {
|
|
773
|
+
const anchor = packageAnchors[packageIndex];
|
|
774
|
+
const orientation = packageOrientations[packageIndex];
|
|
775
|
+
const rate = packageSpinRates[packageIndex];
|
|
776
|
+
if (!anchor || !orientation || !Number.isFinite(rate)) {
|
|
777
|
+
out[0] = positionX;
|
|
778
|
+
out[1] = positionY;
|
|
779
|
+
out[2] = positionZ;
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
782
|
+
const loopElapsed = ((Number(elapsed) % SHOWCASE_PRESET.durationMs) + SHOWCASE_PRESET.durationMs) % SHOWCASE_PRESET.durationMs;
|
|
783
|
+
const angle = rate * loopElapsed / 1000;
|
|
784
|
+
const cosine = Math.cos(angle);
|
|
785
|
+
const sine = Math.sin(angle);
|
|
786
|
+
const offsetX = positionX - anchor[0];
|
|
787
|
+
const offsetY = positionY - anchor[1];
|
|
788
|
+
const offsetZ = positionZ - anchor[2];
|
|
789
|
+
const axisX = orientation[0];
|
|
790
|
+
const axisY = orientation[1];
|
|
791
|
+
const axisZ = orientation[2];
|
|
792
|
+
const axial = axisX * offsetX + axisY * offsetY + axisZ * offsetZ;
|
|
793
|
+
const carry = 1 - cosine;
|
|
794
|
+
out[0] = anchor[0] + offsetX * cosine + (axisY * offsetZ - axisZ * offsetY) * sine + axisX * axial * carry;
|
|
795
|
+
out[1] = anchor[1] + offsetY * cosine + (axisZ * offsetX - axisX * offsetZ) * sine + axisY * axial * carry;
|
|
796
|
+
out[2] = anchor[2] + offsetZ * cosine + (axisX * offsetY - axisY * offsetX) * sine + axisZ * axial * carry;
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function createDependencySpinTexture(gl) {
|
|
801
|
+
const maximumSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
|
|
802
|
+
const texelCount = Math.max(1, model.packages.length * 2);
|
|
803
|
+
const width = Math.min(maximumSize, texelCount);
|
|
804
|
+
const height = Math.ceil(texelCount / width);
|
|
805
|
+
if (height > maximumSize) throw new Error("Dependency package count exceeds WebGL2 texture capacity");
|
|
806
|
+
const pixels = new Float32Array(width * height * 4);
|
|
807
|
+
packageAnchors.forEach((anchor, index) => {
|
|
808
|
+
const offset = index * 8;
|
|
809
|
+
const orientation = packageOrientations[index];
|
|
810
|
+
pixels[offset] = anchor[0];
|
|
811
|
+
pixels[offset + 1] = anchor[1];
|
|
812
|
+
pixels[offset + 2] = anchor[2];
|
|
813
|
+
pixels[offset + 3] = packageSpinRates[index];
|
|
814
|
+
pixels[offset + 4] = orientation[0];
|
|
815
|
+
pixels[offset + 5] = orientation[1];
|
|
816
|
+
pixels[offset + 6] = orientation[2];
|
|
817
|
+
});
|
|
818
|
+
const texture = gl.createTexture();
|
|
819
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
820
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
|
821
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
|
822
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
823
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
824
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, pixels);
|
|
825
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
826
|
+
return Object.freeze({ texture, width });
|
|
827
|
+
}
|
|
529
828
|
|
|
530
829
|
function dependencyPosition(seed, packageIndex) {
|
|
531
830
|
const anchor = packageAnchors[packageIndex] || [0, 0, 0, 2];
|
|
831
|
+
const orientation = packageOrientations[packageIndex] || IDENTITY_DEPENDENCY_ORIENTATION;
|
|
532
832
|
const offset = dependencyCloudOffset(seed, packageMorphologies[packageIndex], anchor[3]);
|
|
833
|
+
const oriented = orientDependencyOffset(offset, orientation);
|
|
533
834
|
return [
|
|
534
|
-
anchor[0] +
|
|
535
|
-
anchor[1] +
|
|
536
|
-
anchor[2] +
|
|
835
|
+
anchor[0] + oriented[0],
|
|
836
|
+
anchor[1] + oriented[1],
|
|
837
|
+
anchor[2] + oriented[2],
|
|
537
838
|
];
|
|
538
839
|
}
|
|
539
840
|
|
|
@@ -583,8 +884,8 @@
|
|
|
583
884
|
};
|
|
584
885
|
model.namespaces.forEach((row, index) => {
|
|
585
886
|
const name = interactiveMode ? model.namespaceNames[index] : "";
|
|
586
|
-
const category = row[
|
|
587
|
-
const values = row.slice(
|
|
887
|
+
const category = row[2] === 1 ? "tests" : "core";
|
|
888
|
+
const values = row.slice(3, 9);
|
|
588
889
|
const position = category === "tests" ? testPosition(row[0]) : corePosition(row[0]);
|
|
589
890
|
const sizeFactor = writeScenePoint(category, position, weightedSignal(normalizedSignals(values), category), category === "core" ? .82 : .68, false, -1, -1);
|
|
590
891
|
const interactive = interactiveMode && !name.startsWith(RSPEC_PROXY_PREFIX);
|
|
@@ -592,7 +893,7 @@
|
|
|
592
893
|
const anchored = showcaseDetails && (showcaseAnnotationAnchors.has(annotationKey) || showcasePinnedNamespaceAnchors.has(index));
|
|
593
894
|
if (!interactive && !anchored) return;
|
|
594
895
|
const point = interactiveMode
|
|
595
|
-
? { category, position, name, kind: row[
|
|
896
|
+
? { category, position, name, kind: row[1] === 0 ? "Class" : "Module", rubyCounts: row.slice(9, 13), instanceVariableCount: row[13] || 0, values }
|
|
596
897
|
: { category, position };
|
|
597
898
|
if (anchored) showcasePointsByAnchor.set(annotationKey, point);
|
|
598
899
|
addPoint(point, sizeFactor, interactive);
|
|
@@ -638,25 +939,242 @@
|
|
|
638
939
|
return { sceneData, scenePointCount, interactivePoints, dependencyHubs, packageHubs, systemHubs };
|
|
639
940
|
}
|
|
640
941
|
const { sceneData, scenePointCount, interactivePoints, dependencyHubs, packageHubs, systemHubs } = buildPoints();
|
|
641
|
-
const
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
942
|
+
const travelFlightLimit = travelFlightLimitForPointCount(scenePointCount);
|
|
943
|
+
const constantReferenceLinks = decodeConstantReferenceLinks(model.constantReferenceLinks);
|
|
944
|
+
const travelScheduleSeed = hash((
|
|
945
|
+
morphology.phaseSeed ^ scenePointCount ^
|
|
946
|
+
Math.imul(constantReferenceLinks.length + 1, 0x9e3779b9)
|
|
947
|
+
) >>> 0);
|
|
948
|
+
delete model.constantReferenceLinks;
|
|
949
|
+
function travelEndpointCategory(renderIndex) {
|
|
950
|
+
const categoryCode = sceneData[renderIndex * SCENE_POINT_STRIDE + 5];
|
|
951
|
+
if (categoryCode === categoryCodes.tests) return "tests";
|
|
952
|
+
if (categoryCode === categoryCodes.dependencies) return "dependencies";
|
|
953
|
+
return "core";
|
|
954
|
+
}
|
|
955
|
+
const dependencyTravelLinkIndices = [];
|
|
956
|
+
const workspaceTravelLinkIndices = [];
|
|
957
|
+
constantReferenceLinks.forEach((link, index) => {
|
|
958
|
+
const pool = travelEndpointCategory(link.departureIndex) === "dependencies"
|
|
959
|
+
? dependencyTravelLinkIndices
|
|
960
|
+
: workspaceTravelLinkIndices;
|
|
961
|
+
pool.push(index);
|
|
962
|
+
});
|
|
963
|
+
const packageCount = model.packages.length;
|
|
964
|
+
|
|
965
|
+
// Unresolved-glow population. The milky texture of a real galaxy is not a
|
|
966
|
+
// halo around bright stars; it is millions of separate faint stars too
|
|
967
|
+
// small to resolve. Haze micro-stars are therefore drawn from the same
|
|
968
|
+
// deterministic position law as the data marks themselves — corePosition,
|
|
969
|
+
// testPosition, and dependencyPosition with fresh seeds — so the haze is
|
|
970
|
+
// literally more of the same stellar population, only fainter: arms stay
|
|
971
|
+
// crisp, the exponential radial falloff shows, and zooming in resolves the
|
|
972
|
+
// texture into faint individual stars instead of per-mark blur blobs.
|
|
973
|
+
// Haze rows live after the data rows in one interleaved buffer, encode
|
|
974
|
+
// their category as data category + HAZE_CATEGORY_OFFSET, and never enter
|
|
975
|
+
// interactive points, hit scans, search, or reported scene point counts.
|
|
976
|
+
const HAZE_CATEGORY_OFFSET = 3;
|
|
977
|
+
// Tuned constants and unit/normal channels for the haze population, named
|
|
978
|
+
// in one frozen recipe block per the renderer geometry practices. normal
|
|
979
|
+
// draws consume their channel and channel + 1.
|
|
980
|
+
const HAZE_RECIPE = Object.freeze({
|
|
981
|
+
pointBudget: 90_000,
|
|
982
|
+
starsPerMark: Object.freeze({ core: 24, tests: 18, dependencyStar: 2 }),
|
|
983
|
+
sizeChannel: 96,
|
|
984
|
+
faintChannel: 97,
|
|
985
|
+
ditherSeedChannel: 120,
|
|
986
|
+
ditherChannel: 121,
|
|
987
|
+
poolSeedChannel: 133, // + category code: core 133, tests 134, dependencies 135
|
|
988
|
+
clumpCenterChannel: 137, // + category code
|
|
989
|
+
clumpMemberChannel: 139, // + category code; members draw normal channels 1/3/5
|
|
990
|
+
clumpSize: 14,
|
|
991
|
+
clumpShare: Object.freeze({ core: .2, tests: .3 }),
|
|
992
|
+
clumpSpread: Object.freeze({ core: 1.1, tests: 1.4 }),
|
|
993
|
+
faintFloor: .24,
|
|
994
|
+
faintSpan: .95,
|
|
995
|
+
sparkleThreshold: .96,
|
|
996
|
+
size: Object.freeze({ floor: .16, span: .14 }),
|
|
997
|
+
sparkleSize: Object.freeze({ floor: .3, span: .18 }),
|
|
998
|
+
maxSize: 1.2,
|
|
999
|
+
});
|
|
1000
|
+
function buildHazePoints() {
|
|
1001
|
+
const markCounts = [0, 0, 0];
|
|
1002
|
+
const alphaSums = [0, 0, 0];
|
|
1003
|
+
for (let index = 0; index < scenePointCount; index += 1) {
|
|
1004
|
+
const offset = index * SCENE_POINT_STRIDE;
|
|
1005
|
+
if (sceneData[offset + 6] > 4) continue;
|
|
1006
|
+
const category = sceneData[offset + 5];
|
|
1007
|
+
markCounts[category] += 1;
|
|
1008
|
+
alphaSums[category] += sceneData[offset + 4];
|
|
1009
|
+
}
|
|
1010
|
+
const perMark = [HAZE_RECIPE.starsPerMark.core, HAZE_RECIPE.starsPerMark.tests, HAZE_RECIPE.starsPerMark.dependencyStar];
|
|
1011
|
+
const requested = markCounts[0] * perMark[0] + markCounts[1] * perMark[1] + markCounts[2] * perMark[2];
|
|
1012
|
+
const budgetScale = Math.min(1, HAZE_RECIPE.pointBudget / Math.max(1, requested));
|
|
1013
|
+
const poolCounts = markCounts.map((count, category) => Math.round(count * perMark[category] * budgetScale));
|
|
1014
|
+
// The budget is a hard cap: rounding and dither tails trim, never spill.
|
|
1015
|
+
poolCounts[0] = Math.min(poolCounts[0], HAZE_RECIPE.pointBudget);
|
|
1016
|
+
poolCounts[1] = Math.min(poolCounts[1], HAZE_RECIPE.pointBudget - poolCounts[0]);
|
|
1017
|
+
const meanAlpha = alphaSums.map((sum, category) => (markCounts[category] ? sum / markCounts[category] : 0));
|
|
1018
|
+
|
|
1019
|
+
// Dependency haze resamples per declaration row so each gem cloud keeps
|
|
1020
|
+
// its exact share, morphology, and expansion indexes.
|
|
1021
|
+
const dependencyRows = [];
|
|
1022
|
+
if (poolCounts[2] > 0) {
|
|
1023
|
+
const perStar = perMark[2] * budgetScale;
|
|
1024
|
+
for (let index = 0; index < scenePointCount; index += 1) {
|
|
1025
|
+
const offset = index * SCENE_POINT_STRIDE;
|
|
1026
|
+
if (sceneData[offset + 5] !== categoryCodes.dependencies || sceneData[offset + 6] > 4) continue;
|
|
1027
|
+
const stars = Math.floor(perStar) +
|
|
1028
|
+
(unit(hash(index + 1, HAZE_RECIPE.ditherSeedChannel), HAZE_RECIPE.ditherChannel) < perStar % 1 ? 1 : 0);
|
|
1029
|
+
for (let star = 0; star < stars; star += 1) dependencyRows.push(index * 8 + star);
|
|
1030
|
+
}
|
|
1031
|
+
const dependencyBudget = Math.max(0, HAZE_RECIPE.pointBudget - poolCounts[0] - poolCounts[1]);
|
|
1032
|
+
if (dependencyRows.length > dependencyBudget) dependencyRows.length = dependencyBudget;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const hazeCount = poolCounts[0] + poolCounts[1] + dependencyRows.length;
|
|
1036
|
+
const data = new Float32Array(hazeCount * SCENE_POINT_STRIDE);
|
|
1037
|
+
let cursor = 0;
|
|
1038
|
+
const writeHazeStar = (position, category, packageIndex, systemIndex, seed) => {
|
|
1039
|
+
const out = cursor * SCENE_POINT_STRIDE;
|
|
1040
|
+
const faint = unit(seed, HAZE_RECIPE.faintChannel);
|
|
1041
|
+
// faint⁴ keeps most of the field very dim with a small sparkle tail of
|
|
1042
|
+
// brighter, slightly larger resolved stars — the depth hierarchy of a
|
|
1043
|
+
// real star field rather than uniform grain.
|
|
1044
|
+
const sparkle = faint > HAZE_RECIPE.sparkleThreshold;
|
|
1045
|
+
const size = sparkle ? HAZE_RECIPE.sparkleSize : HAZE_RECIPE.size;
|
|
1046
|
+
data[out] = position[0];
|
|
1047
|
+
data[out + 1] = position[1];
|
|
1048
|
+
data[out + 2] = position[2];
|
|
1049
|
+
data[out + 3] = size.floor + size.span * unit(seed, HAZE_RECIPE.sizeChannel);
|
|
1050
|
+
data[out + 4] = meanAlpha[category] * (HAZE_RECIPE.faintFloor + HAZE_RECIPE.faintSpan * faint * faint * faint * faint);
|
|
1051
|
+
data[out + 5] = category + HAZE_CATEGORY_OFFSET;
|
|
1052
|
+
data[out + 6] = HAZE_RECIPE.maxSize;
|
|
1053
|
+
data[out + 7] = packageIndex;
|
|
1054
|
+
data[out + 8] = systemIndex;
|
|
1055
|
+
cursor += 1;
|
|
1056
|
+
};
|
|
1057
|
+
// A fraction of each namespace pool lands in tight clumps resampled from
|
|
1058
|
+
// the same law — the flocculent star-cloud patchiness of a real disc.
|
|
1059
|
+
const writeNamespacePool = (count, category, positionFor, clumpShare, clumpSpread) => {
|
|
1060
|
+
const clumps = Math.floor(count * clumpShare / HAZE_RECIPE.clumpSize);
|
|
1061
|
+
const smooth = count - clumps * HAZE_RECIPE.clumpSize;
|
|
1062
|
+
for (let star = 0; star < smooth; star += 1) {
|
|
1063
|
+
const seed = hash(star + 1, HAZE_RECIPE.poolSeedChannel + category);
|
|
1064
|
+
writeHazeStar(positionFor(seed), category, -1, -1, seed);
|
|
1065
|
+
}
|
|
1066
|
+
for (let clump = 0; clump < clumps; clump += 1) {
|
|
1067
|
+
const center = positionFor(hash(clump + 1, HAZE_RECIPE.clumpCenterChannel + category));
|
|
1068
|
+
for (let member = 0; member < HAZE_RECIPE.clumpSize; member += 1) {
|
|
1069
|
+
const seed = hash(clump * 97 + member + 1, HAZE_RECIPE.clumpMemberChannel + category);
|
|
1070
|
+
writeHazeStar([
|
|
1071
|
+
center[0] + normal(seed, 1) * clumpSpread,
|
|
1072
|
+
center[1] + normal(seed, 3) * clumpSpread * .4,
|
|
1073
|
+
center[2] + normal(seed, 5) * clumpSpread,
|
|
1074
|
+
], category, -1, -1, seed);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
writeNamespacePool(poolCounts[0], categoryCodes.core, corePosition, HAZE_RECIPE.clumpShare.core, HAZE_RECIPE.clumpSpread.core * layoutScale.disk);
|
|
1079
|
+
writeNamespacePool(poolCounts[1], categoryCodes.tests, testPosition, HAZE_RECIPE.clumpShare.tests, HAZE_RECIPE.clumpSpread.tests * layoutScale.tests);
|
|
1080
|
+
for (const row of dependencyRows) {
|
|
1081
|
+
const offset = Math.floor(row / 8) * SCENE_POINT_STRIDE;
|
|
1082
|
+
const seed = hash(row + 1, HAZE_RECIPE.poolSeedChannel + categoryCodes.dependencies);
|
|
1083
|
+
writeHazeStar(
|
|
1084
|
+
dependencyPosition(seed, sceneData[offset + 7]),
|
|
1085
|
+
categoryCodes.dependencies,
|
|
1086
|
+
sceneData[offset + 7],
|
|
1087
|
+
sceneData[offset + 8],
|
|
1088
|
+
seed,
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
return data;
|
|
1092
|
+
}
|
|
1093
|
+
// Dust absorbs. One broad, broken lane hugs each spiral arm's inner edge
|
|
1094
|
+
// and attenuates the marks and haze behind it by up to half — rule 6 of
|
|
1095
|
+
// docs/STELLAR_DESIGN_RESEARCH.md: dust is derived from the morphology,
|
|
1096
|
+
// broken into segments, and absorbing; never uniform fog and never new
|
|
1097
|
+
// glowing particles. Positions are static in galaxy space, so absorption
|
|
1098
|
+
// is baked into point alphas at build time and costs nothing per frame.
|
|
1099
|
+
// innerRadius matches the bulge's outer edge — corePosition's bulge radial
|
|
1100
|
+
// law caps at 17, and the tests disc starts there — so lanes never cut
|
|
1101
|
+
// through bulge stars and dust begins where arms actually live.
|
|
1102
|
+
const DUST_PRESET = Object.freeze({
|
|
1103
|
+
maxAbsorption: .5,
|
|
1104
|
+
laneWidth: 2.4,
|
|
1105
|
+
laneOffset: -.16,
|
|
1106
|
+
innerRadius: 17,
|
|
1107
|
+
fadeInSpan: 5,
|
|
1108
|
+
outerFadeStart: 44,
|
|
1109
|
+
outerFadeEnd: 58,
|
|
1110
|
+
segmentLength: 9,
|
|
1111
|
+
brokenChannel: 150,
|
|
1112
|
+
});
|
|
1113
|
+
const smoothstep = (low, high, value) => {
|
|
1114
|
+
const t = clamp((value - low) / (high - low), 0, 1);
|
|
1115
|
+
return t * t * (3 - 2 * t);
|
|
646
1116
|
};
|
|
647
|
-
|
|
1117
|
+
function dustAttenuation(position, radialScale) {
|
|
1118
|
+
if (!spiralMorphology) return 0;
|
|
1119
|
+
const x = position[0] / radialScale;
|
|
1120
|
+
const z = position[2] / radialScale;
|
|
1121
|
+
const radial = Math.hypot(x, z);
|
|
1122
|
+
const barred = morphology.family === MORPHOLOGY_FAMILY.barredSpiral;
|
|
1123
|
+
// Lanes hug arms, and barred arms only exist beyond the bar tips.
|
|
1124
|
+
const laneStart = barred ? Math.max(DUST_PRESET.innerRadius, barRadius) : DUST_PRESET.innerRadius;
|
|
1125
|
+
const window = smoothstep(laneStart, laneStart + DUST_PRESET.fadeInSpan, radial) *
|
|
1126
|
+
(1 - smoothstep(DUST_PRESET.outerFadeStart, DUST_PRESET.outerFadeEnd, radial));
|
|
1127
|
+
if (window <= 0) return 0;
|
|
1128
|
+
const pointTheta = Math.atan2(z, x);
|
|
1129
|
+
let density = 0;
|
|
1130
|
+
for (let arm = 0; arm < morphology.armCount; arm += 1) {
|
|
1131
|
+
// armCenterTheta is the same law that places arm stars, so the lane
|
|
1132
|
+
// follows the rendered arm — including its seeded jitters and forks.
|
|
1133
|
+
const laneTheta = armCenterTheta(arm, radial) + DUST_PRESET.laneOffset;
|
|
1134
|
+
const delta = Math.atan2(Math.sin(pointTheta - laneTheta), Math.cos(pointTheta - laneTheta));
|
|
1135
|
+
const across = Math.abs(delta) * radial;
|
|
1136
|
+
if (across > DUST_PRESET.laneWidth * 3) continue;
|
|
1137
|
+
const segment = Math.floor((radial + arm * 3.7) / DUST_PRESET.segmentLength);
|
|
1138
|
+
const broken = (.35 + .65 * unit(hash(morphology.phaseSeed + arm * 17 + 1, DUST_PRESET.brokenChannel + segment), 0)) *
|
|
1139
|
+
(.6 + .4 * Math.sin(radial * 1.7 + arm * 2.1));
|
|
1140
|
+
density = Math.max(density, Math.exp(-((across / DUST_PRESET.laneWidth) ** 2)) * clamp(broken, 0, 1));
|
|
1141
|
+
}
|
|
1142
|
+
return DUST_PRESET.maxAbsorption * window * density;
|
|
1143
|
+
}
|
|
1144
|
+
function applyDustLanes(rows, count, categoryColumnOffset) {
|
|
1145
|
+
for (let index = 0; index < count; index += 1) {
|
|
1146
|
+
const offset = index * SCENE_POINT_STRIDE;
|
|
1147
|
+
const category = rows[offset + 5] - categoryColumnOffset;
|
|
1148
|
+
if (category !== categoryCodes.core && category !== categoryCodes.tests) continue;
|
|
1149
|
+
const radialScale = category === categoryCodes.core ? layoutScale.disk : layoutScale.tests;
|
|
1150
|
+
const absorbed = dustAttenuation([rows[offset], rows[offset + 1], rows[offset + 2]], radialScale);
|
|
1151
|
+
if (absorbed > 0) rows[offset + 4] *= 1 - absorbed;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
// Haze and dust exist only to be rendered, so they are built on first use
|
|
1155
|
+
// by an accepted renderer — a browser that takes the WebGL2-unavailable
|
|
1156
|
+
// path never spends the CPU or memory. Data rows come first, haze rows
|
|
1157
|
+
// after: glow and white-hot passes draw only the first scenePointCount
|
|
1158
|
+
// rows, and data render indexes stay pairable with gl_VertexID for
|
|
1159
|
+
// selection.
|
|
1160
|
+
let hazeBufferState = null;
|
|
1161
|
+
function hazeBuffers() {
|
|
1162
|
+
if (!hazeBufferState) {
|
|
1163
|
+
const hazeData = buildHazePoints();
|
|
1164
|
+
applyDustLanes(sceneData, scenePointCount, 0);
|
|
1165
|
+
applyDustLanes(hazeData, hazeData.length / SCENE_POINT_STRIDE, HAZE_CATEGORY_OFFSET);
|
|
1166
|
+
const renderPointData = new Float32Array(sceneData.length + hazeData.length);
|
|
1167
|
+
renderPointData.set(sceneData, 0);
|
|
1168
|
+
renderPointData.set(hazeData, sceneData.length);
|
|
1169
|
+
// Counts are always derived from the buffers at their call sites; the
|
|
1170
|
+
// arrays are the single source of truth for the rendered workload.
|
|
1171
|
+
hazeBufferState = Object.freeze({ hazeData, renderPointData });
|
|
1172
|
+
}
|
|
1173
|
+
return hazeBufferState;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
648
1176
|
const embeddedDependencyDeclarations = model.dependencyStars.length;
|
|
649
1177
|
let plottedDependencyDeclarations = embeddedDependencyDeclarations;
|
|
650
|
-
function dependencySamplingState(exact, embedded, packageCount) {
|
|
651
|
-
if (embedded >= exact) return null;
|
|
652
|
-
const gems = `${packageCount.toLocaleString()} ${packageCount === 1 ? "gem" : "gems"}`;
|
|
653
|
-
return {
|
|
654
|
-
summary: "Dependency stars sampled",
|
|
655
|
-
title: "Report data",
|
|
656
|
-
countLabel: `${embedded.toLocaleString()} embedded`,
|
|
657
|
-
note: `This report embeds ${embedded.toLocaleString()} of ${exact.toLocaleString()} dependency stars. Exact totals across ${gems} remain complete.`,
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
1178
|
|
|
661
1179
|
function updateGalaxySummary() {
|
|
662
1180
|
const summary = document.getElementById("galaxy-summary");
|
|
@@ -687,6 +1205,7 @@
|
|
|
687
1205
|
function markExplorerUnavailable(reason, error = null) {
|
|
688
1206
|
const activeElement = document.activeElement;
|
|
689
1207
|
const hadInteractiveFocus = !helpOverlay.hidden || activeElement === canvas || Boolean(activeElement?.closest?.(".toolbar, #controls, #explorer-search-region"));
|
|
1208
|
+
document.body.classList.remove("is-ui-hidden");
|
|
690
1209
|
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
691
1210
|
if (hoverFrame) cancelAnimationFrame(hoverFrame);
|
|
692
1211
|
animationFrame = 0;
|
|
@@ -709,10 +1228,9 @@
|
|
|
709
1228
|
plottedDependencyDeclarations = 0;
|
|
710
1229
|
document.documentElement.dataset.explorerRenderer = "unavailable";
|
|
711
1230
|
document.documentElement.dataset.explorerUnavailableReason = reason;
|
|
712
|
-
document.documentElement.dataset.dependencySampling = String(embeddedDependencyDeclarations < exactDependencyDeclarations);
|
|
713
|
-
document.documentElement.dataset.embeddedDependencySampling = String(embeddedDependencyDeclarations < exactDependencyDeclarations);
|
|
714
1231
|
document.documentElement.dataset.plottedDependencyDeclarations = String(plottedDependencyDeclarations);
|
|
715
1232
|
document.documentElement.dataset.plottedScenePoints = "0";
|
|
1233
|
+
document.documentElement.dataset.hazePoints = "0";
|
|
716
1234
|
if (error) document.documentElement.dataset.explorerRendererError = error.message;
|
|
717
1235
|
canvas.setAttribute("aria-label", "Interactive artwork unavailable because WebGL2 is required.");
|
|
718
1236
|
disableExplorerControls();
|
|
@@ -731,6 +1249,7 @@
|
|
|
731
1249
|
hideShowcaseAnnotation();
|
|
732
1250
|
if (showcaseAnnotation) showcaseAnnotation.hidden = true;
|
|
733
1251
|
canvas.hidden = true;
|
|
1252
|
+
travelCanvas.hidden = true;
|
|
734
1253
|
canvas.setAttribute("aria-label", "Showcase artwork unavailable because WebGL2 is required.");
|
|
735
1254
|
if (showcaseStatus) {
|
|
736
1255
|
showcaseStatus.textContent = "WebGL2 is required to display this Showcase.";
|
|
@@ -741,6 +1260,7 @@
|
|
|
741
1260
|
plottedDependencyDeclarations = 0;
|
|
742
1261
|
document.documentElement.dataset.plottedDependencyDeclarations = String(plottedDependencyDeclarations);
|
|
743
1262
|
document.documentElement.dataset.plottedScenePoints = "0";
|
|
1263
|
+
document.documentElement.dataset.hazePoints = "0";
|
|
744
1264
|
document.documentElement.dataset.showcaseMotion = "unavailable";
|
|
745
1265
|
document.documentElement.dataset.showcaseReady = "true";
|
|
746
1266
|
if (error) document.documentElement.dataset.showcaseRendererError = error.message;
|
|
@@ -766,6 +1286,17 @@
|
|
|
766
1286
|
void main() {
|
|
767
1287
|
if (v_alpha <= 0.0) discard;
|
|
768
1288
|
float radial = length(gl_PointCoord - vec2(0.5)) * 2.0;
|
|
1289
|
+
// Negative radius marks a milk sprite: a soft puff whose additive
|
|
1290
|
+
// overlap integrates unresolved light into a continuous glow
|
|
1291
|
+
// surface. (1-r²)² tracks a gaussian closely and stays cheap on the
|
|
1292
|
+
// software rasterizers the milk pass must survive.
|
|
1293
|
+
if (v_radius < 0.0) {
|
|
1294
|
+
if (radial > 1.0) discard;
|
|
1295
|
+
float falloff = 1.0 - radial * radial;
|
|
1296
|
+
float milk = v_alpha * falloff * falloff;
|
|
1297
|
+
outColor = vec4(v_colour * milk, milk);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
769
1300
|
float feather = min(1.0, 1.0 / max(v_radius, 1.0));
|
|
770
1301
|
float coverage = 1.0 - smoothstep(1.0 - feather, 1.0, radial);
|
|
771
1302
|
if (coverage <= 0.0) discard;
|
|
@@ -774,6 +1305,26 @@
|
|
|
774
1305
|
}
|
|
775
1306
|
`;
|
|
776
1307
|
|
|
1308
|
+
const DEPENDENCY_SPIN_VERTEX_SOURCE = `
|
|
1309
|
+
uniform sampler2D u_dependencySpins;
|
|
1310
|
+
uniform int u_dependencySpinTextureWidth;
|
|
1311
|
+
uniform float u_dependencySpinElapsed;
|
|
1312
|
+
|
|
1313
|
+
vec3 dependencySpinPosition(vec3 position, float category, float maxSize, float packageIndex) {
|
|
1314
|
+
if (category < 1.5 || maxSize >= 4.0 || packageIndex < 0.0) return position;
|
|
1315
|
+
int index = int(packageIndex + 0.5) * 2;
|
|
1316
|
+
ivec2 spinCoordinate = ivec2(index % u_dependencySpinTextureWidth, index / u_dependencySpinTextureWidth);
|
|
1317
|
+
ivec2 axisCoordinate = ivec2((index + 1) % u_dependencySpinTextureWidth, (index + 1) / u_dependencySpinTextureWidth);
|
|
1318
|
+
vec4 spin = texelFetch(u_dependencySpins, spinCoordinate, 0);
|
|
1319
|
+
vec3 axis = texelFetch(u_dependencySpins, axisCoordinate, 0).xyz;
|
|
1320
|
+
float angle = spin.w * u_dependencySpinElapsed;
|
|
1321
|
+
float cosine = cos(angle);
|
|
1322
|
+
float sine = sin(angle);
|
|
1323
|
+
vec3 offset = position - spin.xyz;
|
|
1324
|
+
return spin.xyz + offset * cosine + cross(axis, offset) * sine + axis * dot(axis, offset) * (1.0 - cosine);
|
|
1325
|
+
}
|
|
1326
|
+
`;
|
|
1327
|
+
|
|
777
1328
|
function compileGlShader(gl, type, source) {
|
|
778
1329
|
const shader = gl.createShader(type);
|
|
779
1330
|
gl.shaderSource(shader, source);
|
|
@@ -816,13 +1367,21 @@
|
|
|
816
1367
|
document.documentElement.dataset.showcaseUnavailableReason = "webgl2-unavailable";
|
|
817
1368
|
return null;
|
|
818
1369
|
}
|
|
819
|
-
//
|
|
820
|
-
|
|
1370
|
+
// The largest active sprite decides the capability floor: the hub glow
|
|
1371
|
+
// when the glow pass is on, otherwise the larger of the hub body and the
|
|
1372
|
+
// milk sprite (drawn at quarter resolution, so its device size is half
|
|
1373
|
+
// a hazeMilkRadius).
|
|
1374
|
+
const maxSpriteCssSize = Math.max(
|
|
1375
|
+
SHOWCASE_PRESET.pointGlowPercent > 0 ? 5.2 * 3.4 * 2 : 5.2 * 2,
|
|
1376
|
+
SHOWCASE_PRESET.hazeMilkRadius / 2,
|
|
1377
|
+
) + 2;
|
|
821
1378
|
const pointSizeRange = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE);
|
|
822
1379
|
if (pointSizeRange[1] < maxSpriteCssSize) {
|
|
823
1380
|
document.documentElement.dataset.showcaseUnavailableReason = "webgl2-point-size-range";
|
|
824
1381
|
return null;
|
|
825
1382
|
}
|
|
1383
|
+
const { renderPointData } = hazeBuffers();
|
|
1384
|
+
const renderPointCount = renderPointData.length / SCENE_POINT_STRIDE;
|
|
826
1385
|
|
|
827
1386
|
const createProgram = (vertexSource, fragmentSource) => createGlProgram(gl, vertexSource, fragmentSource);
|
|
828
1387
|
|
|
@@ -851,6 +1410,7 @@
|
|
|
851
1410
|
layout(location = 2) in float a_alpha;
|
|
852
1411
|
layout(location = 3) in float a_category;
|
|
853
1412
|
layout(location = 4) in float a_maxSize;
|
|
1413
|
+
layout(location = 5) in float a_packageIndex;
|
|
854
1414
|
uniform vec2 u_resolution;
|
|
855
1415
|
uniform vec2 u_center;
|
|
856
1416
|
uniform vec4 u_trig;
|
|
@@ -861,6 +1421,7 @@
|
|
|
861
1421
|
uniform float u_glow;
|
|
862
1422
|
uniform float u_deepDetail;
|
|
863
1423
|
uniform int u_pass;
|
|
1424
|
+
${DEPENDENCY_SPIN_VERTEX_SOURCE}
|
|
864
1425
|
out vec3 v_colour;
|
|
865
1426
|
out float v_alpha;
|
|
866
1427
|
out float v_radius;
|
|
@@ -874,14 +1435,17 @@
|
|
|
874
1435
|
}
|
|
875
1436
|
|
|
876
1437
|
void main() {
|
|
1438
|
+
bool hazePoint = a_category >= 2.5;
|
|
1439
|
+
float categoryCode = hazePoint ? a_category - 3.0 : a_category;
|
|
1440
|
+
vec3 position = dependencySpinPosition(a_position, categoryCode, a_maxSize, a_packageIndex);
|
|
877
1441
|
float cy = u_trig.x;
|
|
878
1442
|
float sy = u_trig.y;
|
|
879
1443
|
float cp = u_trig.z;
|
|
880
1444
|
float sp = u_trig.w;
|
|
881
|
-
float x1 =
|
|
882
|
-
float z1 =
|
|
883
|
-
float y2 =
|
|
884
|
-
float z2 =
|
|
1445
|
+
float x1 = position.x * cy - position.z * sy;
|
|
1446
|
+
float z1 = position.x * sy + position.z * cy;
|
|
1447
|
+
float y2 = position.y * cp - z1 * sp;
|
|
1448
|
+
float z2 = position.y * sp + z1 * cp;
|
|
885
1449
|
float depth = u_cameraDistance - z2;
|
|
886
1450
|
if (depth <= 35.0) { hidePoint(); return; }
|
|
887
1451
|
|
|
@@ -893,33 +1457,43 @@
|
|
|
893
1457
|
}
|
|
894
1458
|
|
|
895
1459
|
float size = clamp(a_sizeFactor * perspective, 0.35, a_maxSize);
|
|
896
|
-
float starAlphaScale =
|
|
1460
|
+
float starAlphaScale = categoryCode > 1.5 ? float(${SHOWCASE_DEPENDENCY_PRESET.starAlphaScale}) : 1.0;
|
|
897
1461
|
float visibleAlpha = clamp(a_alpha * starAlphaScale * u_brightness / 100.0, 0.0, 1.0);
|
|
898
1462
|
float radius = size;
|
|
899
1463
|
float alpha = visibleAlpha;
|
|
900
|
-
vec3 colour =
|
|
1464
|
+
vec3 colour = categoryCode < 0.5
|
|
901
1465
|
? ${glslVec3(colours.core)} / 255.0
|
|
902
|
-
: (
|
|
903
|
-
|
|
904
|
-
if (u_pass ==
|
|
905
|
-
|
|
1466
|
+
: (categoryCode < 1.5 ? ${glslVec3(colours.tests)} / 255.0 : ${glslVec3(colours.dependencies)} / 255.0);
|
|
1467
|
+
|
|
1468
|
+
if (u_pass == 3) {
|
|
1469
|
+
// Milk pass: on the fixed far camera the unresolved population
|
|
1470
|
+
// presents as its integrated light — big, ultra-faint gaussian
|
|
1471
|
+
// sprites whose overlap forms a continuous glow tracing the
|
|
1472
|
+
// real density law, not individual resolved points. It renders
|
|
1473
|
+
// into a half-resolution target, so device sizes are halved.
|
|
1474
|
+
if (!hazePoint) { hidePoint(); return; }
|
|
1475
|
+
radius = float(${SHOWCASE_PRESET.hazeMilkRadius});
|
|
1476
|
+
alpha = visibleAlpha * float(${SHOWCASE_PRESET.hazeMilkGainPercent}) / 100.0;
|
|
1477
|
+
} else if (u_pass == 0) {
|
|
1478
|
+
if (hazePoint || size <= 1.35 || u_glow <= 0.0) { hidePoint(); return; }
|
|
906
1479
|
float glowScale = (3.4 - u_deepDetail * 1.3) * (0.75 + 0.25 * u_glow / 100.0);
|
|
907
1480
|
radius = size * glowScale;
|
|
908
1481
|
alpha = min(1.0, visibleAlpha * 0.055 * u_glow / 100.0);
|
|
909
1482
|
} else if (u_pass == 1) {
|
|
1483
|
+
if (hazePoint) { hidePoint(); return; }
|
|
910
1484
|
radius = size < 0.85 ? 0.5 : size;
|
|
911
1485
|
} else {
|
|
912
|
-
if (size <= 1.1) { hidePoint(); return; }
|
|
1486
|
+
if (hazePoint || size <= 1.1) { hidePoint(); return; }
|
|
913
1487
|
radius = max(0.45 + u_deepDetail * 0.25, size * (0.24 + u_deepDetail * 0.06));
|
|
914
1488
|
alpha = min(0.9, visibleAlpha * 1.25);
|
|
915
1489
|
colour = ${glslVec3(whiteHotColour)} / 255.0;
|
|
916
1490
|
}
|
|
917
1491
|
|
|
918
1492
|
gl_Position = vec4(screen.x / u_resolution.x * 2.0 - 1.0, 1.0 - screen.y / u_resolution.y * 2.0, 0.0, 1.0);
|
|
919
|
-
gl_PointSize = max(1.0, radius * 2.0);
|
|
1493
|
+
gl_PointSize = max(1.0, radius * 2.0) * (u_pass == 3 ? 0.25 : 1.0);
|
|
920
1494
|
v_colour = colour;
|
|
921
1495
|
v_alpha = alpha;
|
|
922
|
-
v_radius = radius;
|
|
1496
|
+
v_radius = u_pass == 3 ? -radius : radius;
|
|
923
1497
|
}
|
|
924
1498
|
`, POINT_FRAGMENT_SOURCE);
|
|
925
1499
|
|
|
@@ -927,13 +1501,51 @@
|
|
|
927
1501
|
const pointBuffer = gl.createBuffer();
|
|
928
1502
|
gl.bindVertexArray(pointVao);
|
|
929
1503
|
gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer);
|
|
930
|
-
gl.bufferData(gl.ARRAY_BUFFER,
|
|
1504
|
+
gl.bufferData(gl.ARRAY_BUFFER, renderPointData, gl.STATIC_DRAW);
|
|
931
1505
|
const stride = SCENE_POINT_STRIDE * Float32Array.BYTES_PER_ELEMENT;
|
|
932
|
-
[[0, 3, 0], [1, 1, 3], [2, 1, 4], [3, 1, 5], [4, 1, 6]].forEach(([location, size, offset]) => {
|
|
1506
|
+
[[0, 3, 0], [1, 1, 3], [2, 1, 4], [3, 1, 5], [4, 1, 6], [5, 1, 7]].forEach(([location, size, offset]) => {
|
|
933
1507
|
gl.enableVertexAttribArray(location);
|
|
934
1508
|
gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, offset * Float32Array.BYTES_PER_ELEMENT);
|
|
935
1509
|
});
|
|
936
1510
|
gl.bindVertexArray(null);
|
|
1511
|
+
const dependencySpins = createDependencySpinTexture(gl);
|
|
1512
|
+
|
|
1513
|
+
// The milk pass accumulates into a quarter-resolution offscreen target
|
|
1514
|
+
// and is composited up with linear filtering: identical integrated light
|
|
1515
|
+
// at a sixteenth of the fragment work, which keeps large scenes and the
|
|
1516
|
+
// software rasterizers CI and clip export run on inside frame budgets.
|
|
1517
|
+
const milkTexture = gl.createTexture();
|
|
1518
|
+
const milkFramebuffer = gl.createFramebuffer();
|
|
1519
|
+
let milkWidth = 0;
|
|
1520
|
+
let milkHeight = 0;
|
|
1521
|
+
const ensureMilkTarget = () => {
|
|
1522
|
+
const targetWidth = Math.max(1, Math.round(canvas.width / 4));
|
|
1523
|
+
const targetHeight = Math.max(1, Math.round(canvas.height / 4));
|
|
1524
|
+
if (targetWidth === milkWidth && targetHeight === milkHeight) return;
|
|
1525
|
+
milkWidth = targetWidth;
|
|
1526
|
+
milkHeight = targetHeight;
|
|
1527
|
+
gl.bindTexture(gl.TEXTURE_2D, milkTexture);
|
|
1528
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, milkWidth, milkHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
|
|
1529
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
1530
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
1531
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
1532
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
1533
|
+
gl.bindTexture(gl.TEXTURE_2D, null);
|
|
1534
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, milkFramebuffer);
|
|
1535
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, milkTexture, 0);
|
|
1536
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1537
|
+
};
|
|
1538
|
+
const milkCompositeProgram = createProgram(FULLSCREEN_TRIANGLE_VERTEX_SOURCE, `#version 300 es
|
|
1539
|
+
precision highp float;
|
|
1540
|
+
uniform sampler2D u_milk;
|
|
1541
|
+
uniform vec2 u_resolution;
|
|
1542
|
+
out vec4 outColor;
|
|
1543
|
+
void main() { outColor = texture(u_milk, gl_FragCoord.xy / u_resolution); }
|
|
1544
|
+
`);
|
|
1545
|
+
const milkCompositeUniforms = {
|
|
1546
|
+
milk: gl.getUniformLocation(milkCompositeProgram, "u_milk"),
|
|
1547
|
+
resolution: gl.getUniformLocation(milkCompositeProgram, "u_resolution"),
|
|
1548
|
+
};
|
|
937
1549
|
|
|
938
1550
|
const backgroundUniforms = {
|
|
939
1551
|
resolution: gl.getUniformLocation(backgroundProgram, "u_resolution"),
|
|
@@ -950,6 +1562,9 @@
|
|
|
950
1562
|
brightness: gl.getUniformLocation(pointProgram, "u_brightness"),
|
|
951
1563
|
glow: gl.getUniformLocation(pointProgram, "u_glow"),
|
|
952
1564
|
deepDetail: gl.getUniformLocation(pointProgram, "u_deepDetail"),
|
|
1565
|
+
dependencySpins: gl.getUniformLocation(pointProgram, "u_dependencySpins"),
|
|
1566
|
+
dependencySpinTextureWidth: gl.getUniformLocation(pointProgram, "u_dependencySpinTextureWidth"),
|
|
1567
|
+
dependencySpinElapsed: gl.getUniformLocation(pointProgram, "u_dependencySpinElapsed"),
|
|
953
1568
|
pass: gl.getUniformLocation(pointProgram, "u_pass"),
|
|
954
1569
|
};
|
|
955
1570
|
canvas.addEventListener("webglcontextlost", () => {
|
|
@@ -964,6 +1579,7 @@
|
|
|
964
1579
|
canvas.width = Math.round(viewportWidth);
|
|
965
1580
|
canvas.height = Math.round(viewportHeight);
|
|
966
1581
|
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
1582
|
+
ensureMilkTarget();
|
|
967
1583
|
},
|
|
968
1584
|
render() {
|
|
969
1585
|
const deepDetail = clamp(Math.log2(Math.max(1, zoom)) / 5, 0, 1);
|
|
@@ -980,6 +1596,11 @@
|
|
|
980
1596
|
gl.blendFunc(gl.ONE, gl.ONE);
|
|
981
1597
|
gl.useProgram(pointProgram);
|
|
982
1598
|
gl.bindVertexArray(pointVao);
|
|
1599
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1600
|
+
gl.bindTexture(gl.TEXTURE_2D, dependencySpins.texture);
|
|
1601
|
+
gl.uniform1i(pointUniforms.dependencySpins, 0);
|
|
1602
|
+
gl.uniform1i(pointUniforms.dependencySpinTextureWidth, dependencySpins.width);
|
|
1603
|
+
gl.uniform1f(pointUniforms.dependencySpinElapsed, dependencySpinElapsed / 1000);
|
|
983
1604
|
gl.uniform2f(pointUniforms.resolution, width, height);
|
|
984
1605
|
gl.uniform2f(pointUniforms.center, sceneCenterX, sceneCenterY);
|
|
985
1606
|
const [cy, sy, cp, sp] = viewMatrix();
|
|
@@ -990,6 +1611,26 @@
|
|
|
990
1611
|
gl.uniform1f(pointUniforms.brightness, SHOWCASE_PRESET.starBrightnessPercent);
|
|
991
1612
|
gl.uniform1f(pointUniforms.glow, SHOWCASE_PRESET.pointGlowPercent);
|
|
992
1613
|
gl.uniform1f(pointUniforms.deepDetail, deepDetail);
|
|
1614
|
+
|
|
1615
|
+
ensureMilkTarget();
|
|
1616
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, milkFramebuffer);
|
|
1617
|
+
gl.viewport(0, 0, milkWidth, milkHeight);
|
|
1618
|
+
gl.clearColor(0, 0, 0, 0);
|
|
1619
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
1620
|
+
gl.uniform1i(pointUniforms.pass, 3);
|
|
1621
|
+
gl.drawArrays(gl.POINTS, scenePointCount, renderPointCount - scenePointCount);
|
|
1622
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
|
|
1623
|
+
gl.viewport(0, 0, canvas.width, canvas.height);
|
|
1624
|
+
gl.useProgram(milkCompositeProgram);
|
|
1625
|
+
gl.bindVertexArray(null);
|
|
1626
|
+
gl.activeTexture(gl.TEXTURE1);
|
|
1627
|
+
gl.bindTexture(gl.TEXTURE_2D, milkTexture);
|
|
1628
|
+
gl.uniform1i(milkCompositeUniforms.milk, 1);
|
|
1629
|
+
gl.uniform2f(milkCompositeUniforms.resolution, canvas.width, canvas.height);
|
|
1630
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
1631
|
+
gl.useProgram(pointProgram);
|
|
1632
|
+
gl.bindVertexArray(pointVao);
|
|
1633
|
+
|
|
993
1634
|
for (let pass = 0; pass < 3; pass += 1) {
|
|
994
1635
|
gl.uniform1i(pointUniforms.pass, pass);
|
|
995
1636
|
gl.drawArrays(gl.POINTS, 0, scenePointCount);
|
|
@@ -1009,10 +1650,9 @@
|
|
|
1009
1650
|
document.documentElement.dataset.showcaseUnavailableReason = "webgl2-initialization-error";
|
|
1010
1651
|
}
|
|
1011
1652
|
if (showcaseRenderer) {
|
|
1012
|
-
document.documentElement.dataset.dependencySampling = String(embeddedDependencyDeclarations < exactDependencyDeclarations);
|
|
1013
|
-
document.documentElement.dataset.embeddedDependencySampling = String(embeddedDependencyDeclarations < exactDependencyDeclarations);
|
|
1014
1653
|
document.documentElement.dataset.plottedDependencyDeclarations = String(embeddedDependencyDeclarations);
|
|
1015
1654
|
document.documentElement.dataset.plottedScenePoints = String(scenePointCount);
|
|
1655
|
+
document.documentElement.dataset.hazePoints = String(hazeBuffers().hazeData.length / SCENE_POINT_STRIDE);
|
|
1016
1656
|
} else {
|
|
1017
1657
|
markShowcaseUnavailable(
|
|
1018
1658
|
document.documentElement.dataset.showcaseUnavailableReason || "webgl2-unavailable",
|
|
@@ -1038,14 +1678,17 @@
|
|
|
1038
1678
|
document.documentElement.dataset.explorerUnavailableReason = "webgl2-unavailable";
|
|
1039
1679
|
return null;
|
|
1040
1680
|
}
|
|
1041
|
-
//
|
|
1042
|
-
|
|
1681
|
+
// Dependency marks skip this pass, so an ordinary Core/Test glow is the
|
|
1682
|
+
// largest Explorer sprite: radius 3.2 * 3.4 CSS pixels.
|
|
1683
|
+
const maxSpriteCssSize = 3.2 * 3.4 * 2 + 2;
|
|
1043
1684
|
const pointSizeRange = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE);
|
|
1044
1685
|
if (pointSizeRange[1] < maxSpriteCssSize) {
|
|
1045
1686
|
liveCanvas.remove();
|
|
1046
1687
|
document.documentElement.dataset.explorerUnavailableReason = "webgl2-point-size-range";
|
|
1047
1688
|
return null;
|
|
1048
1689
|
}
|
|
1690
|
+
const { renderPointData } = hazeBuffers();
|
|
1691
|
+
const renderPointCount = renderPointData.length / SCENE_POINT_STRIDE;
|
|
1049
1692
|
let rendererDpr = 1;
|
|
1050
1693
|
|
|
1051
1694
|
const backgroundProgram = createGlProgram(gl, FULLSCREEN_TRIANGLE_VERTEX_SOURCE, `#version 300 es
|
|
@@ -1091,6 +1734,7 @@
|
|
|
1091
1734
|
uniform float u_expansion;
|
|
1092
1735
|
uniform int u_selectedIndex;
|
|
1093
1736
|
uniform int u_pass;
|
|
1737
|
+
${DEPENDENCY_SPIN_VERTEX_SOURCE}
|
|
1094
1738
|
out vec3 v_colour;
|
|
1095
1739
|
out float v_alpha;
|
|
1096
1740
|
out float v_radius;
|
|
@@ -1104,9 +1748,12 @@
|
|
|
1104
1748
|
}
|
|
1105
1749
|
|
|
1106
1750
|
void main() {
|
|
1107
|
-
|
|
1751
|
+
bool hazePoint = a_category >= 2.5;
|
|
1752
|
+
float categoryCode = hazePoint ? a_category - 3.0 : a_category;
|
|
1753
|
+
int category = int(categoryCode + 0.5);
|
|
1108
1754
|
if (u_categoryVisible[category] < 0.5) { hidePoint(); return; }
|
|
1109
|
-
|
|
1755
|
+
if (u_pass == 0 && (hazePoint || category == 2)) { hidePoint(); return; }
|
|
1756
|
+
vec3 position = dependencySpinPosition(a_position, categoryCode, a_maxSize, a_packageIndex);
|
|
1110
1757
|
bool expandedPoint = (u_expandedPackage >= 0.0 && a_packageIndex == u_expandedPackage)
|
|
1111
1758
|
|| (u_expandedSystem >= 0.0 && a_systemIndex == u_expandedSystem);
|
|
1112
1759
|
if (expandedPoint) position = u_expandedAnchor + (position - u_expandedAnchor) * u_expansion;
|
|
@@ -1131,27 +1778,38 @@
|
|
|
1131
1778
|
}
|
|
1132
1779
|
|
|
1133
1780
|
float size = clamp(a_sizeFactor * perspective, 0.35, a_maxSize);
|
|
1134
|
-
bool focusedDependencyPoint = expandedPoint && category == 2;
|
|
1781
|
+
bool focusedDependencyPoint = !hazePoint && expandedPoint && category == 2;
|
|
1135
1782
|
bool focusedDependencyHub = focusedDependencyPoint && a_maxSize > 4.0;
|
|
1136
1783
|
float emphasis = focusedDependencyPoint || selected ? 1.0 : u_categoryEmphasis[category];
|
|
1137
1784
|
float focusedAlpha = max(focusedDependencyHub ? 0.34 : 0.289, a_alpha);
|
|
1138
|
-
float
|
|
1785
|
+
float categoryExposure = category == 2
|
|
1786
|
+
? min(u_exposure, float(${explorerExposureForZoom(DEFAULT_CAMERA.zoom * ZOOM_STEP)}))
|
|
1787
|
+
: u_exposure;
|
|
1788
|
+
float visibleAlpha = (focusedDependencyPoint ? focusedAlpha : a_alpha * emphasis) * categoryExposure;
|
|
1139
1789
|
bool detailed = emphasis >= 0.1;
|
|
1140
1790
|
float radius = size;
|
|
1141
1791
|
float alpha = visibleAlpha;
|
|
1142
|
-
vec3 colour =
|
|
1792
|
+
vec3 colour = categoryCode < 0.5
|
|
1143
1793
|
? ${glslVec3(colours.core)} / 255.0
|
|
1144
|
-
: (
|
|
1794
|
+
: (categoryCode < 1.5 ? ${glslVec3(colours.tests)} / 255.0 : ${glslVec3(colours.dependencies)} / 255.0);
|
|
1145
1795
|
|
|
1146
1796
|
if (u_pass == 0) {
|
|
1147
1797
|
if (size <= 1.35 || !detailed) { hidePoint(); return; }
|
|
1148
|
-
float glowScale =
|
|
1798
|
+
float glowScale = 3.4 - u_deepDetail * 1.3;
|
|
1149
1799
|
radius = size * glowScale;
|
|
1150
|
-
alpha = visibleAlpha * (
|
|
1800
|
+
alpha = visibleAlpha * 0.055 * (1.0 - 0.78 * u_deepDetail);
|
|
1151
1801
|
} else if (u_pass == 1) {
|
|
1152
|
-
|
|
1802
|
+
if (hazePoint) {
|
|
1803
|
+
// Haze escapes the zoom exposure dimming: as marks calm down at
|
|
1804
|
+
// depth, the unresolved field stays lit and reads as resolved
|
|
1805
|
+
// faint stars, the way a telescope resolves the Milky Way.
|
|
1806
|
+
radius = size * (1.0 + 0.4 * u_deepDetail);
|
|
1807
|
+
alpha = a_alpha * emphasis * mix(categoryExposure, 1.35, u_deepDetail);
|
|
1808
|
+
} else {
|
|
1809
|
+
radius = (!detailed || size < 0.85) ? 0.5 : size;
|
|
1810
|
+
}
|
|
1153
1811
|
} else {
|
|
1154
|
-
if (size <= 1.1 || !detailed) { hidePoint(); return; }
|
|
1812
|
+
if (hazePoint || size <= 1.1 || !detailed) { hidePoint(); return; }
|
|
1155
1813
|
radius = max(0.45 + u_deepDetail * 0.25, size * (0.24 + u_deepDetail * 0.06));
|
|
1156
1814
|
alpha = min(0.9, visibleAlpha * 1.25);
|
|
1157
1815
|
colour = ${glslVec3(whiteHotColour)} / 255.0;
|
|
@@ -1169,13 +1827,14 @@
|
|
|
1169
1827
|
const pointBuffer = gl.createBuffer();
|
|
1170
1828
|
gl.bindVertexArray(pointVao);
|
|
1171
1829
|
gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer);
|
|
1172
|
-
gl.bufferData(gl.ARRAY_BUFFER,
|
|
1830
|
+
gl.bufferData(gl.ARRAY_BUFFER, renderPointData, gl.STATIC_DRAW);
|
|
1173
1831
|
const stride = SCENE_POINT_STRIDE * Float32Array.BYTES_PER_ELEMENT;
|
|
1174
1832
|
[[0, 3, 0], [1, 1, 3], [2, 1, 4], [3, 1, 5], [4, 1, 6], [5, 1, 7], [6, 1, 8]].forEach(([location, size, offset]) => {
|
|
1175
1833
|
gl.enableVertexAttribArray(location);
|
|
1176
1834
|
gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, offset * Float32Array.BYTES_PER_ELEMENT);
|
|
1177
1835
|
});
|
|
1178
1836
|
gl.bindVertexArray(null);
|
|
1837
|
+
const dependencySpins = createDependencySpinTexture(gl);
|
|
1179
1838
|
|
|
1180
1839
|
const backgroundUniforms = {
|
|
1181
1840
|
resolution: gl.getUniformLocation(backgroundProgram, "u_resolution"),
|
|
@@ -1185,7 +1844,8 @@
|
|
|
1185
1844
|
"u_resolution", "u_center", "u_sceneBounds", "u_trig", "u_zoom",
|
|
1186
1845
|
"u_cameraDistance", "u_cameraFocalLength", "u_exposure", "u_deepDetail", "u_dpr",
|
|
1187
1846
|
"u_categoryVisible", "u_categoryEmphasis", "u_expandedPackage", "u_expandedSystem",
|
|
1188
|
-
"u_expandedAnchor", "u_expansion", "u_selectedIndex", "
|
|
1847
|
+
"u_expandedAnchor", "u_expansion", "u_selectedIndex", "u_dependencySpins",
|
|
1848
|
+
"u_dependencySpinTextureWidth", "u_dependencySpinElapsed", "u_pass",
|
|
1189
1849
|
].map(name => [name.slice(2), gl.getUniformLocation(pointProgram, name)]));
|
|
1190
1850
|
|
|
1191
1851
|
const categoryEmphasisVector = () => {
|
|
@@ -1228,6 +1888,11 @@
|
|
|
1228
1888
|
gl.blendFunc(gl.ONE, gl.ONE);
|
|
1229
1889
|
gl.useProgram(pointProgram);
|
|
1230
1890
|
gl.bindVertexArray(pointVao);
|
|
1891
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
1892
|
+
gl.bindTexture(gl.TEXTURE_2D, dependencySpins.texture);
|
|
1893
|
+
gl.uniform1i(pointUniforms.dependencySpins, 0);
|
|
1894
|
+
gl.uniform1i(pointUniforms.dependencySpinTextureWidth, dependencySpins.width);
|
|
1895
|
+
gl.uniform1f(pointUniforms.dependencySpinElapsed, dependencySpinElapsed / 1000);
|
|
1231
1896
|
gl.uniform2f(pointUniforms.resolution, width, height);
|
|
1232
1897
|
gl.uniform2f(pointUniforms.center, centerX, centerY);
|
|
1233
1898
|
gl.uniform2f(pointUniforms.sceneBounds, sceneRight, sceneBottom);
|
|
@@ -1253,7 +1918,7 @@
|
|
|
1253
1918
|
gl.uniform1i(pointUniforms.selectedIndex, selectedPoint ? selectedPoint.renderIndex : -1);
|
|
1254
1919
|
for (let pass = 0; pass < 3; pass += 1) {
|
|
1255
1920
|
gl.uniform1i(pointUniforms.pass, pass);
|
|
1256
|
-
gl.drawArrays(gl.POINTS, 0, scenePointCount);
|
|
1921
|
+
gl.drawArrays(gl.POINTS, 0, pass === 1 ? renderPointCount : scenePointCount);
|
|
1257
1922
|
}
|
|
1258
1923
|
gl.bindVertexArray(null);
|
|
1259
1924
|
gl.disable(gl.BLEND);
|
|
@@ -1273,10 +1938,9 @@
|
|
|
1273
1938
|
document.documentElement.dataset.explorerUnavailableReason = "webgl2-initialization-error";
|
|
1274
1939
|
}
|
|
1275
1940
|
if (explorerRenderer) {
|
|
1276
|
-
document.documentElement.dataset.dependencySampling = String(plottedDependencyDeclarations < exactDependencyDeclarations);
|
|
1277
|
-
document.documentElement.dataset.embeddedDependencySampling = String(embeddedDependencyDeclarations < exactDependencyDeclarations);
|
|
1278
1941
|
document.documentElement.dataset.plottedDependencyDeclarations = String(plottedDependencyDeclarations);
|
|
1279
1942
|
document.documentElement.dataset.plottedScenePoints = String(scenePointCount);
|
|
1943
|
+
document.documentElement.dataset.hazePoints = String(hazeBuffers().hazeData.length / SCENE_POINT_STRIDE);
|
|
1280
1944
|
} else {
|
|
1281
1945
|
markExplorerUnavailable(
|
|
1282
1946
|
document.documentElement.dataset.explorerUnavailableReason || "webgl2-unavailable",
|
|
@@ -1285,8 +1949,9 @@
|
|
|
1285
1949
|
}
|
|
1286
1950
|
}
|
|
1287
1951
|
const context = interactiveMode ? canvas.getContext("2d", { alpha: Boolean(explorerRenderer) }) : null;
|
|
1952
|
+
const travelContext = showcaseMode ? travelCanvas.getContext("2d", { alpha: true }) : context;
|
|
1288
1953
|
const directGemCount = model.packages.filter(row => row[1] === 0).length;
|
|
1289
|
-
const transitiveGemCount =
|
|
1954
|
+
const transitiveGemCount = packageCount - directGemCount;
|
|
1290
1955
|
const allRubyMetricIndexes = [0, 1, 2, 3];
|
|
1291
1956
|
const testRubyMetricIndexes = [0, 2];
|
|
1292
1957
|
const dependencyRubyCounts = model.packages.reduce(
|
|
@@ -1296,7 +1961,7 @@
|
|
|
1296
1961
|
const categoryMeta = {
|
|
1297
1962
|
core: { title: "Core code", rubyCounts: model.categoryStats?.core || [0, 0, 0, 0], metricIndexes: allRubyMetricIndexes, focusZoom: 2.8 },
|
|
1298
1963
|
tests: { title: "Tests", rubyCounts: model.categoryStats?.tests || [0, 0, 0, 0], metricIndexes: testRubyMetricIndexes, focusZoom: 1.35 },
|
|
1299
|
-
dependencies: { title: "Gems",
|
|
1964
|
+
dependencies: { title: "Gems", rubyCounts: dependencyRubyCounts, metricIndexes: allRubyMetricIndexes, note: `${directGemCount.toLocaleString()} direct · ${transitiveGemCount.toLocaleString()} transitive`, focusZoom: .72 },
|
|
1300
1965
|
};
|
|
1301
1966
|
model.namespaces = [];
|
|
1302
1967
|
model.packages = [];
|
|
@@ -1308,6 +1973,7 @@
|
|
|
1308
1973
|
zoom = clamp(target.zoom, MIN_ZOOM, MAX_ZOOM);
|
|
1309
1974
|
panX = target.panX;
|
|
1310
1975
|
panY = target.panY;
|
|
1976
|
+
rebaseExplorerTravelAdmission();
|
|
1311
1977
|
}
|
|
1312
1978
|
|
|
1313
1979
|
function contextualSelectionCameraTarget(point, preferredZoom = point.hub ? 4 : point.category === "dependencies" ? 5 : 7) {
|
|
@@ -1421,6 +2087,7 @@
|
|
|
1421
2087
|
panY = start.panY + (target.panY - start.panY) * eased;
|
|
1422
2088
|
const pullback = Math.sin(Math.PI * progress) ** 2 * cameraFlight.pullback;
|
|
1423
2089
|
zoom = Math.exp(Math.log(start.zoom) + (Math.log(target.zoom) - Math.log(start.zoom)) * eased - pullback);
|
|
2090
|
+
rebaseExplorerTravelAdmission();
|
|
1424
2091
|
if (progress >= 1) {
|
|
1425
2092
|
cameraFlight = null;
|
|
1426
2093
|
applyCameraTarget(finalTarget);
|
|
@@ -1447,6 +2114,19 @@
|
|
|
1447
2114
|
return true;
|
|
1448
2115
|
}
|
|
1449
2116
|
|
|
2117
|
+
function advanceDependencySpin(timestamp) {
|
|
2118
|
+
if (!interactiveMode || !drifting || reducedMotionQuery.matches) {
|
|
2119
|
+
lastDependencySpinTimestamp = null;
|
|
2120
|
+
return false;
|
|
2121
|
+
}
|
|
2122
|
+
const elapsed = lastDependencySpinTimestamp === null
|
|
2123
|
+
? 1000 / 60
|
|
2124
|
+
: clamp(timestamp - lastDependencySpinTimestamp, 0, MAX_DRIFT_DELTA_MS);
|
|
2125
|
+
lastDependencySpinTimestamp = timestamp;
|
|
2126
|
+
dependencySpinElapsed = (dependencySpinElapsed + elapsed) % SHOWCASE_PRESET.durationMs;
|
|
2127
|
+
return true;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
1450
2130
|
function addTooltipMetric(label, value) {
|
|
1451
2131
|
const term = document.createElement("dt");
|
|
1452
2132
|
const detail = document.createElement("dd");
|
|
@@ -1492,7 +2172,7 @@
|
|
|
1492
2172
|
}
|
|
1493
2173
|
|
|
1494
2174
|
function positionTooltip(point) {
|
|
1495
|
-
if (cameraFlight || !point?.screen) { tooltip.hidden = true; return; }
|
|
2175
|
+
if (document.body.classList.contains("is-ui-hidden") || cameraFlight || !point?.screen) { tooltip.hidden = true; return; }
|
|
1496
2176
|
tooltip.hidden = false;
|
|
1497
2177
|
const bounds = tooltip.getBoundingClientRect();
|
|
1498
2178
|
if (bounds.width === 0 || bounds.height === 0) {
|
|
@@ -1657,7 +2337,7 @@
|
|
|
1657
2337
|
}
|
|
1658
2338
|
|
|
1659
2339
|
function queueHover(x, y) {
|
|
1660
|
-
if (cameraFlight || dragging || pointers.size > 0) return;
|
|
2340
|
+
if (document.body.classList.contains("is-ui-hidden") || cameraFlight || dragging || pointers.size > 0) return;
|
|
1661
2341
|
pendingHover = [x, y];
|
|
1662
2342
|
if (hoverFrame) return;
|
|
1663
2343
|
hoverFrame = requestAnimationFrame(() => {
|
|
@@ -1678,10 +2358,28 @@
|
|
|
1678
2358
|
|
|
1679
2359
|
function setPanelCollapsed(collapsed) {
|
|
1680
2360
|
if (collapsed && panelBody.contains(document.activeElement)) panelToggle.focus();
|
|
2361
|
+
const action = collapsed ? "Expand" : "Collapse";
|
|
1681
2362
|
panel.classList.toggle("is-collapsed", collapsed);
|
|
1682
2363
|
panelBody.hidden = collapsed;
|
|
1683
2364
|
panelToggle.setAttribute("aria-expanded", String(!collapsed));
|
|
1684
|
-
panelToggle.
|
|
2365
|
+
panelToggle.setAttribute("aria-label", `${action} Explorer`);
|
|
2366
|
+
panelToggle.textContent = action;
|
|
2367
|
+
updateSceneViewport();
|
|
2368
|
+
requestRender();
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
function setUiHidden(hidden) {
|
|
2372
|
+
const action = hidden ? "Show" : "Hide";
|
|
2373
|
+
document.body.classList.toggle("is-ui-hidden", hidden);
|
|
2374
|
+
uiToggle.setAttribute("aria-pressed", String(hidden));
|
|
2375
|
+
uiToggle.setAttribute("aria-label", `${action} interface`);
|
|
2376
|
+
uiToggle.textContent = `${action} UI`;
|
|
2377
|
+
uiToggle.title = `${action} interface (H)`;
|
|
2378
|
+
cancelPendingHover();
|
|
2379
|
+
canvas.classList.remove("is-star");
|
|
2380
|
+
tooltip.hidden = true;
|
|
2381
|
+
if (!selectionLocked) selectPoint(null);
|
|
2382
|
+
if (hidden && document.activeElement !== canvas) canvas.focus({ preventScroll: true });
|
|
1685
2383
|
updateSceneViewport();
|
|
1686
2384
|
requestRender();
|
|
1687
2385
|
}
|
|
@@ -1752,6 +2450,7 @@
|
|
|
1752
2450
|
}
|
|
1753
2451
|
|
|
1754
2452
|
function clearExpandedPackage() {
|
|
2453
|
+
if (expandedSystemIndex !== null || expandedPackageIndex !== null) resetExplorerTravel();
|
|
1755
2454
|
expandedSystemIndex = null;
|
|
1756
2455
|
expandedPackageIndex = null;
|
|
1757
2456
|
}
|
|
@@ -1795,6 +2494,7 @@
|
|
|
1795
2494
|
}
|
|
1796
2495
|
|
|
1797
2496
|
function setCategoryVisible(category, visible) {
|
|
2497
|
+
if (visibleCategories[category] !== visible) resetExplorerTravel();
|
|
1798
2498
|
visibleCategories[category] = visible;
|
|
1799
2499
|
if (visibilityInputs[category]) visibilityInputs[category].checked = visible;
|
|
1800
2500
|
if (!visible && (selectedPoint?.category === category || focusedCategory === category)) clearExplorationFocus();
|
|
@@ -1827,10 +2527,13 @@
|
|
|
1827
2527
|
}
|
|
1828
2528
|
clearCategoryFocus();
|
|
1829
2529
|
if (expandDependency && point.systemHub && !point.packageHub) {
|
|
2530
|
+
if (expandedSystemIndex !== point.systemIndex || expandedPackageIndex !== null) resetExplorerTravel();
|
|
1830
2531
|
expandedSystemIndex = point.systemIndex;
|
|
1831
2532
|
expandedPackageIndex = null;
|
|
1832
2533
|
} else if (expandDependency && point.packageHub) {
|
|
1833
|
-
|
|
2534
|
+
const systemIndex = point.systemIndex >= 0 ? point.systemIndex : null;
|
|
2535
|
+
if (expandedSystemIndex !== systemIndex || expandedPackageIndex !== point.packageIndex) resetExplorerTravel();
|
|
2536
|
+
expandedSystemIndex = systemIndex;
|
|
1834
2537
|
expandedPackageIndex = point.packageIndex;
|
|
1835
2538
|
} else {
|
|
1836
2539
|
clearExpandedPackage();
|
|
@@ -1912,17 +2615,13 @@
|
|
|
1912
2615
|
const counts = Object.fromEntries(["manifest", "index", "integrity"].map(category => [category, Math.max(0, Number(model.warningCounts?.[category]) || 0)]));
|
|
1913
2616
|
const warningTotal = Object.values(counts).reduce((sum, count) => sum + count, 0);
|
|
1914
2617
|
const rendererUnavailable = interactiveMode && document.documentElement.dataset.explorerRenderer === "unavailable";
|
|
1915
|
-
const sampling = interactiveMode
|
|
1916
|
-
? dependencySamplingState(exactDependencyDeclarations, embeddedDependencyDeclarations, totals.packages)
|
|
1917
|
-
: null;
|
|
1918
2618
|
container.textContent = "";
|
|
1919
|
-
details.hidden = !rendererUnavailable &&
|
|
2619
|
+
details.hidden = !rendererUnavailable && warningTotal === 0;
|
|
1920
2620
|
if (details.hidden) return;
|
|
1921
2621
|
|
|
1922
2622
|
const analysisSummary = `${warningTotal.toLocaleString()} analysis warning${warningTotal === 1 ? "" : "s"}`;
|
|
1923
2623
|
const statusSummaries = [];
|
|
1924
2624
|
if (rendererUnavailable) statusSummaries.push("WebGL2 required");
|
|
1925
|
-
if (sampling) statusSummaries.push(sampling.summary);
|
|
1926
2625
|
if (warningTotal > 0) statusSummaries.push(analysisSummary);
|
|
1927
2626
|
summary.textContent = statusSummaries.join(" · ");
|
|
1928
2627
|
|
|
@@ -1933,22 +2632,11 @@
|
|
|
1933
2632
|
"Interactive rendering",
|
|
1934
2633
|
1,
|
|
1935
2634
|
[],
|
|
1936
|
-
`This report requires WebGL2 to display its ${scenePointCount.toLocaleString()}-star interactive scene. Exact dependency totals across ${
|
|
2635
|
+
`This report requires WebGL2 to display its ${scenePointCount.toLocaleString()}-star interactive scene. Exact dependency totals across ${packageCount.toLocaleString()} ${packageCount === 1 ? "gem" : "gems"} remain complete.`,
|
|
1937
2636
|
"Unavailable",
|
|
1938
2637
|
);
|
|
1939
2638
|
}
|
|
1940
2639
|
|
|
1941
|
-
if (sampling) {
|
|
1942
|
-
appendWarningGroup(
|
|
1943
|
-
container,
|
|
1944
|
-
sampling.title,
|
|
1945
|
-
1,
|
|
1946
|
-
[],
|
|
1947
|
-
sampling.note,
|
|
1948
|
-
sampling.countLabel,
|
|
1949
|
-
);
|
|
1950
|
-
}
|
|
1951
|
-
|
|
1952
2640
|
const safeWarnings = (Array.isArray(model.dependencyWarnings) ? model.dependencyWarnings : []).filter(warning =>
|
|
1953
2641
|
warning && typeof warning.name === "string" && warning.name.length > 0 && typeof warning.reason === "string" && warning.reason.length > 0
|
|
1954
2642
|
);
|
|
@@ -2060,7 +2748,7 @@
|
|
|
2060
2748
|
searchInput.value = "";
|
|
2061
2749
|
searchResults.textContent = "";
|
|
2062
2750
|
searchResults.hidden = true;
|
|
2063
|
-
searchStatus.textContent = "
|
|
2751
|
+
searchStatus.textContent = "";
|
|
2064
2752
|
if (focus) searchInput.focus();
|
|
2065
2753
|
}
|
|
2066
2754
|
|
|
@@ -2135,21 +2823,27 @@
|
|
|
2135
2823
|
for (const category of ["core", "tests", "dependencies"]) {
|
|
2136
2824
|
const meta = categoryMeta[category];
|
|
2137
2825
|
const details = document.createElement("details");
|
|
2826
|
+
details.className = `explorer-section ${category}`;
|
|
2138
2827
|
details.open = category === "core";
|
|
2139
2828
|
const summary = document.createElement("summary");
|
|
2140
|
-
const swatch = document.createElement("span");
|
|
2141
|
-
swatch.className = `swatch ${category}`;
|
|
2142
2829
|
const heading = document.createElement("span");
|
|
2143
2830
|
heading.className = "section-heading";
|
|
2144
2831
|
const title = document.createElement("strong");
|
|
2145
2832
|
title.textContent = meta.title;
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2833
|
+
const summaryText = document.createElement("small");
|
|
2834
|
+
summaryText.textContent = category === "dependencies"
|
|
2835
|
+
? `${packageCount.toLocaleString()} dependency gems`
|
|
2836
|
+
: `${Number(meta.rubyCounts[0] || 0).toLocaleString()} classes · ${Number(meta.rubyCounts[2] || 0).toLocaleString()} methods`;
|
|
2837
|
+
heading.append(title, summaryText);
|
|
2838
|
+
const state = document.createElement("span");
|
|
2839
|
+
state.className = "section-state";
|
|
2840
|
+
for (const [className, label] of [["visible", "In view"], ["focused", "Focused"], ["hidden", "Hidden"]]) {
|
|
2841
|
+
const stateLabel = document.createElement("span");
|
|
2842
|
+
stateLabel.className = className;
|
|
2843
|
+
stateLabel.textContent = label;
|
|
2844
|
+
state.append(stateLabel);
|
|
2151
2845
|
}
|
|
2152
|
-
summary.append(
|
|
2846
|
+
summary.append(heading, state);
|
|
2153
2847
|
|
|
2154
2848
|
const body = document.createElement("div");
|
|
2155
2849
|
body.className = "section-body";
|
|
@@ -2164,8 +2858,11 @@
|
|
|
2164
2858
|
checkbox.addEventListener("change", () => setCategoryVisible(category, checkbox.checked));
|
|
2165
2859
|
visibilityInputs[category] = checkbox;
|
|
2166
2860
|
const visibilityText = document.createElement("span");
|
|
2167
|
-
|
|
2168
|
-
|
|
2861
|
+
const visibilityTrack = document.createElement("span");
|
|
2862
|
+
visibilityTrack.className = "visibility-track";
|
|
2863
|
+
visibilityTrack.setAttribute("aria-hidden", "true");
|
|
2864
|
+
visibilityText.textContent = "Show stars";
|
|
2865
|
+
visibility.append(checkbox, visibilityTrack, visibilityText);
|
|
2169
2866
|
const focus = document.createElement("button");
|
|
2170
2867
|
focus.type = "button";
|
|
2171
2868
|
focus.textContent = "Focus";
|
|
@@ -2212,7 +2909,7 @@
|
|
|
2212
2909
|
if (categoryFacts.length) {
|
|
2213
2910
|
const factLabel = document.createElement("p");
|
|
2214
2911
|
factLabel.className = "fact-label";
|
|
2215
|
-
factLabel.textContent = "
|
|
2912
|
+
factLabel.textContent = "Fly to a landmark";
|
|
2216
2913
|
body.append(factLabel, facts);
|
|
2217
2914
|
}
|
|
2218
2915
|
details.append(summary, body);
|
|
@@ -2266,6 +2963,13 @@
|
|
|
2266
2963
|
width = SHOWCASE_PRESET.stageWidth;
|
|
2267
2964
|
height = SHOWCASE_PRESET.stageHeight;
|
|
2268
2965
|
if (showcaseRenderer) showcaseRenderer.resize(width, height);
|
|
2966
|
+
travelCanvas.width = width;
|
|
2967
|
+
travelCanvas.height = height;
|
|
2968
|
+
travelContext.setTransform(1, 0, 0, 1, 0, 0);
|
|
2969
|
+
travelOverlayDirty = false;
|
|
2970
|
+
travelEpisodes.length = 0;
|
|
2971
|
+
travelScheduleMinute = 0;
|
|
2972
|
+
travelScheduleComplete = false;
|
|
2269
2973
|
fitShowcaseStage();
|
|
2270
2974
|
updateSceneViewport();
|
|
2271
2975
|
if (reducedMotionQuery.matches) applyShowcaseCamera(0);
|
|
@@ -2276,6 +2980,7 @@
|
|
|
2276
2980
|
width = window.innerWidth; height = window.innerHeight;
|
|
2277
2981
|
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr);
|
|
2278
2982
|
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
2983
|
+
travelOverlayDirty = false;
|
|
2279
2984
|
if (explorerRenderer) explorerRenderer.resize();
|
|
2280
2985
|
updateSceneViewport();
|
|
2281
2986
|
requestRender();
|
|
@@ -2289,6 +2994,13 @@
|
|
|
2289
2994
|
sceneCenterY = height * activeShowcaseLayout.centerYPercent / 100;
|
|
2290
2995
|
return;
|
|
2291
2996
|
}
|
|
2997
|
+
if (document.body.classList.contains("is-ui-hidden")) {
|
|
2998
|
+
sceneRight = width;
|
|
2999
|
+
sceneBottom = height;
|
|
3000
|
+
sceneCenterX = width / 2;
|
|
3001
|
+
sceneCenterY = height / 2;
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
2292
3004
|
const panelBounds = panel.getBoundingClientRect();
|
|
2293
3005
|
sceneRight = width > 760 && !panel.classList.contains("is-collapsed") ? panelBounds.left - 14 : width;
|
|
2294
3006
|
sceneBottom = width <= 760 && !panel.classList.contains("is-collapsed") ? panelBounds.top - 12 : height;
|
|
@@ -2304,11 +3016,13 @@
|
|
|
2304
3016
|
panX = toX - sceneCenterX - (fromX - sceneCenterX - panX) * scale;
|
|
2305
3017
|
panY = toY - sceneCenterY - (fromY - sceneCenterY - panY) * scale;
|
|
2306
3018
|
zoom = clampedZoom;
|
|
3019
|
+
rebaseExplorerTravelAdmission();
|
|
2307
3020
|
}
|
|
2308
3021
|
|
|
2309
3022
|
function panBy(dx, dy) {
|
|
2310
3023
|
panX += dx;
|
|
2311
3024
|
panY += dy;
|
|
3025
|
+
rebaseExplorerTravelAdmission();
|
|
2312
3026
|
}
|
|
2313
3027
|
|
|
2314
3028
|
function isEditableTarget(target) {
|
|
@@ -2339,13 +3053,15 @@
|
|
|
2339
3053
|
function handleViewShortcut(event) {
|
|
2340
3054
|
if (pointers.size > 0) return false;
|
|
2341
3055
|
if (event.metaKey || event.ctrlKey || event.altKey) return false;
|
|
3056
|
+
const uiHidden = document.body.classList.contains("is-ui-hidden");
|
|
2342
3057
|
if (isEditableTarget(event.target)) return false;
|
|
2343
3058
|
if (event.key === "+" || event.key === "=") { cancelCameraFlight(); zoomBetween(zoom * ZOOM_STEP, sceneCenterX, sceneCenterY); }
|
|
2344
3059
|
else if (event.key === "-") { cancelCameraFlight(); zoomBetween(zoom / ZOOM_STEP, sceneCenterX, sceneCenterY); }
|
|
2345
3060
|
else if (event.key === "0") resetView();
|
|
2346
3061
|
else if (event.key.toLowerCase() === "p") { cancelCameraFlight(); setNavigationMode(navigationMode === "pan" ? "orbit" : "pan"); }
|
|
2347
|
-
else if (event.key === "/") focusSearch();
|
|
2348
|
-
else if (event.key === "
|
|
3062
|
+
else if (event.key === "/") { if (uiHidden) setUiHidden(false); focusSearch(); }
|
|
3063
|
+
else if (event.key.toLowerCase() === "h") setUiHidden(!uiHidden);
|
|
3064
|
+
else if (event.key === "?" && !uiHidden) { if (!event.repeat) toggleHelp(); }
|
|
2349
3065
|
else if ((event.key === "Enter" || event.key.toLowerCase() === "f") && selectedPoint?.category === "dependencies") {
|
|
2350
3066
|
if (event.key === "Enter" && event.target !== canvas && event.target !== document.body) return false;
|
|
2351
3067
|
if (selectedPoint.systemHub && !selectedPoint.packageHub) focusDependencySystem(selectedPoint.systemIndex);
|
|
@@ -2391,14 +3107,7 @@
|
|
|
2391
3107
|
canvas.classList.toggle("is-pan", panning);
|
|
2392
3108
|
}
|
|
2393
3109
|
|
|
2394
|
-
function
|
|
2395
|
-
const position = point.position;
|
|
2396
|
-
let anchor = null;
|
|
2397
|
-
if (expandedPackageIndex !== null && point.packageIndex === expandedPackageIndex) anchor = packageAnchors[expandedPackageIndex];
|
|
2398
|
-
else if (expandedPackageIndex === null && expandedSystemIndex !== null && point.systemIndex === expandedSystemIndex) anchor = systemAnchors[expandedSystemIndex];
|
|
2399
|
-
const positionX = anchor ? anchor[0] + (position[0] - anchor[0]) * DEPENDENCY_EXPANSION : position[0];
|
|
2400
|
-
const positionY = anchor ? anchor[1] + (position[1] - anchor[1]) * DEPENDENCY_EXPANSION : position[1];
|
|
2401
|
-
const positionZ = anchor ? anchor[2] + (position[2] - anchor[2]) * DEPENDENCY_EXPANSION : position[2];
|
|
3110
|
+
function projectCoordinates(positionX, positionY, positionZ, matrix, out, camera = null) {
|
|
2402
3111
|
const [cy, sy, cp, sp] = matrix;
|
|
2403
3112
|
const x1 = positionX * cy - positionZ * sy;
|
|
2404
3113
|
const z1 = positionX * sy + positionZ * cy;
|
|
@@ -2406,14 +3115,552 @@
|
|
|
2406
3115
|
const z2 = positionY * sp + z1 * cp;
|
|
2407
3116
|
const depth = cameraDistance - z2;
|
|
2408
3117
|
if (depth <= 35) return null;
|
|
2409
|
-
const perspective = cameraFocalLength / depth * zoom;
|
|
3118
|
+
const perspective = cameraFocalLength / depth * (camera?.zoom ?? zoom);
|
|
2410
3119
|
const projected = out || [0, 0, 0];
|
|
2411
|
-
projected[0] = sceneCenterX + panX + x1 * perspective;
|
|
2412
|
-
projected[1] = sceneCenterY + panY + y2 * perspective;
|
|
3120
|
+
projected[0] = sceneCenterX + (camera?.panX ?? panX) + x1 * perspective;
|
|
3121
|
+
projected[1] = sceneCenterY + (camera?.panY ?? panY) + y2 * perspective;
|
|
2413
3122
|
projected[2] = perspective;
|
|
2414
3123
|
return projected;
|
|
2415
3124
|
}
|
|
2416
3125
|
|
|
3126
|
+
function dependencyExpansionAnchor(packageIndex, systemIndex) {
|
|
3127
|
+
if (expandedPackageIndex !== null && packageIndex === expandedPackageIndex) return packageAnchors[expandedPackageIndex];
|
|
3128
|
+
if (expandedPackageIndex === null && expandedSystemIndex !== null && systemIndex === expandedSystemIndex) return systemAnchors[expandedSystemIndex];
|
|
3129
|
+
return null;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function scenePointWorldPosition(renderIndex, spinElapsed = dependencySpinElapsed, out = [0, 0, 0]) {
|
|
3133
|
+
const offset = renderIndex * SCENE_POINT_STRIDE;
|
|
3134
|
+
let positionX = sceneData[offset];
|
|
3135
|
+
let positionY = sceneData[offset + 1];
|
|
3136
|
+
let positionZ = sceneData[offset + 2];
|
|
3137
|
+
const packageIndex = sceneData[offset + 7];
|
|
3138
|
+
const systemIndex = sceneData[offset + 8];
|
|
3139
|
+
if (sceneData[offset + 5] === categoryCodes.dependencies && sceneData[offset + 6] < 4 && packageIndex >= 0) {
|
|
3140
|
+
const spun = dependencySpunPosition(positionX, positionY, positionZ, packageIndex, spinElapsed, dependencySpinScratch);
|
|
3141
|
+
positionX = spun[0];
|
|
3142
|
+
positionY = spun[1];
|
|
3143
|
+
positionZ = spun[2];
|
|
3144
|
+
}
|
|
3145
|
+
const anchor = dependencyExpansionAnchor(packageIndex, systemIndex);
|
|
3146
|
+
out[0] = anchor ? anchor[0] + (positionX - anchor[0]) * DEPENDENCY_EXPANSION : positionX;
|
|
3147
|
+
out[1] = anchor ? anchor[1] + (positionY - anchor[1]) * DEPENDENCY_EXPANSION : positionY;
|
|
3148
|
+
out[2] = anchor ? anchor[2] + (positionZ - anchor[2]) * DEPENDENCY_EXPANSION : positionZ;
|
|
3149
|
+
return out;
|
|
3150
|
+
}
|
|
3151
|
+
|
|
3152
|
+
function projectScenePoint(renderIndex, matrix, out, camera = null, spinElapsed = dependencySpinElapsed) {
|
|
3153
|
+
const position = scenePointWorldPosition(renderIndex, spinElapsed, scenePointPositionScratch);
|
|
3154
|
+
return projectCoordinates(position[0], position[1], position[2], matrix, out, camera);
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
function project(point, matrix, out) {
|
|
3158
|
+
const position = point.position;
|
|
3159
|
+
const anchor = dependencyExpansionAnchor(point.packageIndex, point.systemIndex);
|
|
3160
|
+
const positionX = anchor ? anchor[0] + (position[0] - anchor[0]) * DEPENDENCY_EXPANSION : position[0];
|
|
3161
|
+
const positionY = anchor ? anchor[1] + (position[1] - anchor[1]) * DEPENDENCY_EXPANSION : position[1];
|
|
3162
|
+
const positionZ = anchor ? anchor[2] + (position[2] - anchor[2]) * DEPENDENCY_EXPANSION : position[2];
|
|
3163
|
+
return projectCoordinates(positionX, positionY, positionZ, matrix, out);
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
const travelEpisodes = [];
|
|
3167
|
+
let travelScheduleMinute = 0;
|
|
3168
|
+
let travelScheduleComplete = false;
|
|
3169
|
+
let explorerTravelAdmissionOrigin = null;
|
|
3170
|
+
let explorerTravelElapsed = 0;
|
|
3171
|
+
let explorerTravelLastTimestamp = null;
|
|
3172
|
+
let travelOverlayDirty = false;
|
|
3173
|
+
|
|
3174
|
+
function resetExplorerTravel() {
|
|
3175
|
+
if (!interactiveMode) return;
|
|
3176
|
+
travelEpisodes.length = 0;
|
|
3177
|
+
travelScheduleMinute = 0;
|
|
3178
|
+
travelScheduleComplete = false;
|
|
3179
|
+
explorerTravelAdmissionOrigin = null;
|
|
3180
|
+
explorerTravelElapsed = 0;
|
|
3181
|
+
explorerTravelLastTimestamp = null;
|
|
3182
|
+
}
|
|
3183
|
+
|
|
3184
|
+
function travelLinksShareEndpoint(left, right) {
|
|
3185
|
+
return left.departureIndex === right.departureIndex ||
|
|
3186
|
+
left.departureIndex === right.arrivalIndex ||
|
|
3187
|
+
left.arrivalIndex === right.departureIndex ||
|
|
3188
|
+
left.arrivalIndex === right.arrivalIndex;
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
function travelEpisodeSeed(episodeIndex) {
|
|
3192
|
+
return hash(hash(travelScheduleSeed, travelScheduleMinute + 1), episodeIndex + 1);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
function travelLinkCandidatesForEpisode(episodeIndex, selected) {
|
|
3196
|
+
const episodeSeed = travelEpisodeSeed(episodeIndex);
|
|
3197
|
+
const preferencePhase = hash(travelScheduleSeed, travelScheduleMinute + 1) & 3;
|
|
3198
|
+
const dependencyPreferred = (episodeIndex + preferencePhase) % 4 !== 0;
|
|
3199
|
+
const primaryPool = dependencyPreferred ? dependencyTravelLinkIndices : workspaceTravelLinkIndices;
|
|
3200
|
+
const secondaryPool = dependencyPreferred ? workspaceTravelLinkIndices : dependencyTravelLinkIndices;
|
|
3201
|
+
const candidates = [];
|
|
3202
|
+
const avoidSelected = selected.size < constantReferenceLinks.length;
|
|
3203
|
+
for (const [pool, channel] of [
|
|
3204
|
+
[primaryPool, TRAVEL_PRESET.candidateChannel],
|
|
3205
|
+
[secondaryPool, TRAVEL_PRESET.candidateChannel + 1],
|
|
3206
|
+
]) {
|
|
3207
|
+
const start = Math.floor(unit(episodeSeed, channel) * pool.length);
|
|
3208
|
+
for (let offset = 0; offset < pool.length && candidates.length < TRAVEL_PRESET.admissionCandidateLimit; offset += 1) {
|
|
3209
|
+
const linkIndex = pool[(start + offset) % pool.length];
|
|
3210
|
+
if (avoidSelected && selected.has(linkIndex)) continue;
|
|
3211
|
+
candidates.push(linkIndex);
|
|
3212
|
+
}
|
|
3213
|
+
}
|
|
3214
|
+
return candidates;
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
function travelEpisodeStartsAt(episodeIndex) {
|
|
3218
|
+
const episodeSeed = travelEpisodeSeed(episodeIndex);
|
|
3219
|
+
const baseInterval = TRAVEL_PRESET.flightDurationMs / travelFlightLimit;
|
|
3220
|
+
const minuteStart = travelScheduleMinute * SHOWCASE_PRESET.durationMs;
|
|
3221
|
+
let startsAt;
|
|
3222
|
+
if (episodeIndex === 0) {
|
|
3223
|
+
startsAt = minuteStart + baseInterval * (
|
|
3224
|
+
TRAVEL_PRESET.initialDelayMin +
|
|
3225
|
+
unit(episodeSeed, TRAVEL_PRESET.initialDelayChannel) * TRAVEL_PRESET.initialDelayRange
|
|
3226
|
+
);
|
|
3227
|
+
} else {
|
|
3228
|
+
startsAt = travelEpisodes[episodeIndex - 1].startsAt + baseInterval * (
|
|
3229
|
+
TRAVEL_PRESET.intervalMin +
|
|
3230
|
+
unit(episodeSeed, TRAVEL_PRESET.intervalChannel) * TRAVEL_PRESET.intervalRange
|
|
3231
|
+
);
|
|
3232
|
+
}
|
|
3233
|
+
if (episodeIndex >= travelFlightLimit) {
|
|
3234
|
+
const handoffAt = travelEpisodes[episodeIndex - travelFlightLimit].startsAt +
|
|
3235
|
+
TRAVEL_PRESET.flightDurationMs + TRAVEL_PRESET.handoffGapMinMs +
|
|
3236
|
+
unit(episodeSeed, TRAVEL_PRESET.handoffGapChannel) * TRAVEL_PRESET.handoffGapRangeMs;
|
|
3237
|
+
startsAt = Math.max(startsAt, handoffAt);
|
|
3238
|
+
}
|
|
3239
|
+
const latestStart = minuteStart + SHOWCASE_PRESET.durationMs -
|
|
3240
|
+
TRAVEL_PRESET.flightDurationMs - TRAVEL_PRESET.loopQuietMs;
|
|
3241
|
+
return startsAt <= latestStart ? startsAt : minuteStart + SHOWCASE_PRESET.durationMs;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
function travelEpisodeUpperBound(elapsed) {
|
|
3245
|
+
let low = 0;
|
|
3246
|
+
let high = travelEpisodes.length;
|
|
3247
|
+
while (low < high) {
|
|
3248
|
+
const middle = (low + high) >>> 1;
|
|
3249
|
+
if (travelEpisodes[middle].startsAt <= elapsed) low = middle + 1;
|
|
3250
|
+
else high = middle;
|
|
3251
|
+
}
|
|
3252
|
+
return low;
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
function travelEpisodesThrough(elapsed) {
|
|
3256
|
+
if (!constantReferenceLinks.length || !Number.isFinite(elapsed)) return [];
|
|
3257
|
+
const normalizedElapsed = showcaseMode
|
|
3258
|
+
? ((elapsed % SHOWCASE_PRESET.durationMs) + SHOWCASE_PRESET.durationMs) % SHOWCASE_PRESET.durationMs
|
|
3259
|
+
: Math.max(0, elapsed);
|
|
3260
|
+
ensureTravelEpisodesThrough(normalizedElapsed);
|
|
3261
|
+
return travelEpisodes.slice(0, travelEpisodeUpperBound(normalizedElapsed));
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
function travelStatesAt(elapsed) {
|
|
3265
|
+
if (!constantReferenceLinks.length || !Number.isFinite(elapsed)) return [];
|
|
3266
|
+
const normalizedElapsed = showcaseMode
|
|
3267
|
+
? ((elapsed % SHOWCASE_PRESET.durationMs) + SHOWCASE_PRESET.durationMs) % SHOWCASE_PRESET.durationMs
|
|
3268
|
+
: Math.max(0, elapsed);
|
|
3269
|
+
ensureTravelEpisodesThrough(normalizedElapsed);
|
|
3270
|
+
const states = [];
|
|
3271
|
+
for (let index = travelEpisodeUpperBound(normalizedElapsed) - 1; index >= 0; index -= 1) {
|
|
3272
|
+
const episode = travelEpisodes[index];
|
|
3273
|
+
const flightElapsed = normalizedElapsed - episode.startsAt;
|
|
3274
|
+
if (flightElapsed >= TRAVEL_PRESET.flightDurationMs) break;
|
|
3275
|
+
if (!episode.route) continue;
|
|
3276
|
+
const rawProgress = flightElapsed / TRAVEL_PRESET.flightDurationMs;
|
|
3277
|
+
const progress = rawProgress - TRAVEL_PRESET.progressEase * Math.sin(Math.PI * 2 * rawProgress);
|
|
3278
|
+
const fadeInProgress = Math.min(1, rawProgress / TRAVEL_PRESET.fadeInFraction);
|
|
3279
|
+
const fadeOutProgress = Math.max(0, (rawProgress - TRAVEL_PRESET.fadeOutStart) / (1 - TRAVEL_PRESET.fadeOutStart));
|
|
3280
|
+
const fadeIn = fadeInProgress * fadeInProgress * (3 - 2 * fadeInProgress);
|
|
3281
|
+
const fadeOut = 1 - fadeOutProgress * fadeOutProgress * (3 - 2 * fadeOutProgress);
|
|
3282
|
+
states.push({ episode, progress, visibility: fadeIn * fadeOut });
|
|
3283
|
+
}
|
|
3284
|
+
return states.reverse();
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
function travelEmphasis(category) {
|
|
3288
|
+
if (!interactiveMode) return 1;
|
|
3289
|
+
if (expandedPackageIndex !== null || expandedSystemIndex !== null) return contextVisibility.package;
|
|
3290
|
+
if (selectionLocked && selectedPoint) return contextVisibility.selection;
|
|
3291
|
+
if (focusedCategory) return category === focusedCategory ? 1 : contextVisibility.category;
|
|
3292
|
+
return 1;
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
function quadraticCoordinate(start, control, end, progress) {
|
|
3296
|
+
const inverse = 1 - progress;
|
|
3297
|
+
return inverse * inverse * start + 2 * inverse * progress * control + progress * progress * end;
|
|
3298
|
+
}
|
|
3299
|
+
|
|
3300
|
+
function travelGuideFits(route, right, bottom, inset) {
|
|
3301
|
+
return Math.min(route.departure[0], route.controlX, route.arrival[0]) >= inset &&
|
|
3302
|
+
Math.max(route.departure[0], route.controlX, route.arrival[0]) <= right - inset &&
|
|
3303
|
+
Math.min(route.departure[1], route.controlY, route.arrival[1]) >= inset &&
|
|
3304
|
+
Math.max(route.departure[1], route.controlY, route.arrival[1]) <= bottom - inset;
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
function travelGeometry(departure, arrival, arcDirection) {
|
|
3308
|
+
const dx = arrival[0] - departure[0];
|
|
3309
|
+
const dy = arrival[1] - departure[1];
|
|
3310
|
+
const distance = Math.hypot(dx, dy);
|
|
3311
|
+
if (distance < 1) return null;
|
|
3312
|
+
|
|
3313
|
+
const midpointX = (departure[0] + arrival[0]) / 2;
|
|
3314
|
+
const midpointY = (departure[1] + arrival[1]) / 2;
|
|
3315
|
+
const arcHeight = Math.min(
|
|
3316
|
+
TRAVEL_PRESET.arcHeightMax,
|
|
3317
|
+
Math.max(TRAVEL_PRESET.arcHeightMin, distance * TRAVEL_PRESET.arcHeightPercent / 100),
|
|
3318
|
+
);
|
|
3319
|
+
const direction = arcDirection < 0 ? -1 : 1;
|
|
3320
|
+
return {
|
|
3321
|
+
departure,
|
|
3322
|
+
arrival,
|
|
3323
|
+
controlX: midpointX - dy / distance * arcHeight * direction,
|
|
3324
|
+
controlY: midpointY + dx / distance * arcHeight * direction,
|
|
3325
|
+
};
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
function prepareTravelGeometry(link, episodeIndex, departure, arrival, right, bottom, inset = 8) {
|
|
3329
|
+
const visible = point => point[0] >= inset && point[0] <= right - inset && point[1] >= inset && point[1] <= bottom - inset;
|
|
3330
|
+
if (!visible(departure) || !visible(arrival)) return null;
|
|
3331
|
+
|
|
3332
|
+
const positiveRoute = travelGeometry(departure, arrival, 1);
|
|
3333
|
+
if (!positiveRoute) return null;
|
|
3334
|
+
const distance = Math.hypot(arrival[0] - departure[0], arrival[1] - departure[1]);
|
|
3335
|
+
if (distance < TRAVEL_PRESET.minimumRouteLengthPx) return null;
|
|
3336
|
+
const midpointX = (departure[0] + arrival[0]) / 2;
|
|
3337
|
+
const midpointY = (departure[1] + arrival[1]) / 2;
|
|
3338
|
+
const negativeX = midpointX * 2 - positiveRoute.controlX;
|
|
3339
|
+
const negativeY = midpointY * 2 - positiveRoute.controlY;
|
|
3340
|
+
const centerX = right / 2;
|
|
3341
|
+
const centerY = bottom / 2;
|
|
3342
|
+
const positiveClearance = Math.hypot(positiveRoute.controlX - centerX, positiveRoute.controlY - centerY);
|
|
3343
|
+
const negativeClearance = Math.hypot(negativeX - centerX, negativeY - centerY);
|
|
3344
|
+
const linkSeed = hash((link.arrivalIndex ^ Math.imul(link.departureIndex + 1, 0x9e3779b9)) >>> 0);
|
|
3345
|
+
const seededDirection = unit(
|
|
3346
|
+
hash(linkSeed, episodeIndex + 1),
|
|
3347
|
+
TRAVEL_PRESET.arcDirectionChannel,
|
|
3348
|
+
) < 0.5 ? -1 : 1;
|
|
3349
|
+
const arcDirection = Math.abs(positiveClearance - negativeClearance) < 1
|
|
3350
|
+
? seededDirection
|
|
3351
|
+
: positiveClearance > negativeClearance ? 1 : -1;
|
|
3352
|
+
return arcDirection > 0 ? positiveRoute : travelGeometry(departure, arrival, -1);
|
|
3353
|
+
}
|
|
3354
|
+
|
|
3355
|
+
function beginExplorerTravelAdmission(elapsed) {
|
|
3356
|
+
if (explorerTravelAdmissionOrigin) return;
|
|
3357
|
+
const yawDirection = screenRotationYawSign(pitch);
|
|
3358
|
+
explorerTravelAdmissionOrigin = {
|
|
3359
|
+
yaw: yaw - yawDirection * DRIFT_RADIANS_PER_SECOND * elapsed / 1000,
|
|
3360
|
+
yawDirection,
|
|
3361
|
+
pitch,
|
|
3362
|
+
zoom,
|
|
3363
|
+
panX,
|
|
3364
|
+
panY,
|
|
3365
|
+
spinElapsed: dependencySpinElapsed - elapsed,
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
|
|
3369
|
+
function rebaseExplorerTravelAdmission() {
|
|
3370
|
+
if (!explorerTravelAdmissionOrigin) return;
|
|
3371
|
+
const yawDirection = screenRotationYawSign(pitch);
|
|
3372
|
+
explorerTravelAdmissionOrigin.yaw = yaw - yawDirection * DRIFT_RADIANS_PER_SECOND *
|
|
3373
|
+
explorerTravelElapsed / 1000;
|
|
3374
|
+
explorerTravelAdmissionOrigin.yawDirection = yawDirection;
|
|
3375
|
+
explorerTravelAdmissionOrigin.pitch = pitch;
|
|
3376
|
+
explorerTravelAdmissionOrigin.zoom = zoom;
|
|
3377
|
+
explorerTravelAdmissionOrigin.panX = panX;
|
|
3378
|
+
explorerTravelAdmissionOrigin.panY = panY;
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
function travelAdmissionCameraAt(elapsed) {
|
|
3382
|
+
if (showcaseMode) return showcaseCameraState(showcaseFrameProgress(elapsed));
|
|
3383
|
+
const origin = explorerTravelAdmissionOrigin;
|
|
3384
|
+
return {
|
|
3385
|
+
yaw: origin.yaw + origin.yawDirection * DRIFT_RADIANS_PER_SECOND * elapsed / 1000,
|
|
3386
|
+
pitch: origin.pitch,
|
|
3387
|
+
zoom: origin.zoom,
|
|
3388
|
+
panX: origin.panX,
|
|
3389
|
+
panY: origin.panY,
|
|
3390
|
+
};
|
|
3391
|
+
}
|
|
3392
|
+
|
|
3393
|
+
function unprojectCoordinates(screenX, screenY, depth, matrix, camera) {
|
|
3394
|
+
const [cy, sy, cp, sp] = matrix;
|
|
3395
|
+
const perspective = cameraFocalLength / depth * camera.zoom;
|
|
3396
|
+
const x1 = (screenX - sceneCenterX - camera.panX) / perspective;
|
|
3397
|
+
const y2 = (screenY - sceneCenterY - camera.panY) / perspective;
|
|
3398
|
+
const z2 = cameraDistance - depth;
|
|
3399
|
+
const positionY = y2 * cp + z2 * sp;
|
|
3400
|
+
const z1 = -y2 * sp + z2 * cp;
|
|
3401
|
+
return [x1 * cy + z1 * sy, positionY, -x1 * sy + z1 * cy];
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
function prepareTravelRoute(episodeIndex, episode, right, bottom) {
|
|
3405
|
+
const link = constantReferenceLinks[episode.linkIndex];
|
|
3406
|
+
const departureCategory = travelEndpointCategory(link.departureIndex);
|
|
3407
|
+
const arrivalCategory = travelEndpointCategory(link.arrivalIndex);
|
|
3408
|
+
if (!visibleCategories[departureCategory] || !visibleCategories[arrivalCategory]) return null;
|
|
3409
|
+
const midpoint = episode.startsAt + TRAVEL_PRESET.flightDurationMs / 2;
|
|
3410
|
+
const camera = travelAdmissionCameraAt(midpoint);
|
|
3411
|
+
const matrix = [
|
|
3412
|
+
Math.cos(camera.yaw),
|
|
3413
|
+
Math.sin(camera.yaw),
|
|
3414
|
+
Math.cos(camera.pitch),
|
|
3415
|
+
Math.sin(camera.pitch),
|
|
3416
|
+
];
|
|
3417
|
+
const spinOrigin = showcaseMode ? 0 : explorerTravelAdmissionOrigin.spinElapsed;
|
|
3418
|
+
const departure = scenePointWorldPosition(
|
|
3419
|
+
link.departureIndex,
|
|
3420
|
+
spinOrigin + episode.startsAt,
|
|
3421
|
+
);
|
|
3422
|
+
const arrival = scenePointWorldPosition(
|
|
3423
|
+
link.arrivalIndex,
|
|
3424
|
+
spinOrigin + episode.startsAt + TRAVEL_PRESET.flightDurationMs,
|
|
3425
|
+
);
|
|
3426
|
+
const projectedDeparture = projectCoordinates(
|
|
3427
|
+
departure[0], departure[1], departure[2], matrix, [0, 0, 0], camera,
|
|
3428
|
+
);
|
|
3429
|
+
const projectedArrival = projectCoordinates(
|
|
3430
|
+
arrival[0], arrival[1], arrival[2], matrix, [0, 0, 0], camera,
|
|
3431
|
+
);
|
|
3432
|
+
if (!projectedDeparture || !projectedArrival) return null;
|
|
3433
|
+
const admittedRoute = prepareTravelGeometry(
|
|
3434
|
+
link,
|
|
3435
|
+
episodeIndex,
|
|
3436
|
+
projectedDeparture,
|
|
3437
|
+
projectedArrival,
|
|
3438
|
+
right,
|
|
3439
|
+
bottom,
|
|
3440
|
+
TRAVEL_PRESET.admissionInsetPx,
|
|
3441
|
+
);
|
|
3442
|
+
if (!admittedRoute || !travelGuideFits(
|
|
3443
|
+
admittedRoute,
|
|
3444
|
+
right,
|
|
3445
|
+
bottom,
|
|
3446
|
+
TRAVEL_PRESET.admissionInsetPx,
|
|
3447
|
+
)) return null;
|
|
3448
|
+
const departureDepth = cameraFocalLength * camera.zoom / projectedDeparture[2];
|
|
3449
|
+
const arrivalDepth = cameraFocalLength * camera.zoom / projectedArrival[2];
|
|
3450
|
+
const control = unprojectCoordinates(
|
|
3451
|
+
admittedRoute.controlX,
|
|
3452
|
+
admittedRoute.controlY,
|
|
3453
|
+
(departureDepth + arrivalDepth) / 2,
|
|
3454
|
+
matrix,
|
|
3455
|
+
camera,
|
|
3456
|
+
);
|
|
3457
|
+
return { departure, control, arrival };
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
function ensureTravelEpisodesThrough(elapsed) {
|
|
3461
|
+
const minute = Math.floor(elapsed / SHOWCASE_PRESET.durationMs);
|
|
3462
|
+
if (travelScheduleMinute !== minute) {
|
|
3463
|
+
travelEpisodes.length = 0;
|
|
3464
|
+
travelScheduleMinute = minute;
|
|
3465
|
+
travelScheduleComplete = false;
|
|
3466
|
+
}
|
|
3467
|
+
if (!showcaseMode) beginExplorerTravelAdmission(elapsed);
|
|
3468
|
+
const right = interactiveMode ? sceneRight : width;
|
|
3469
|
+
const bottom = interactiveMode ? sceneBottom : height;
|
|
3470
|
+
while (!travelScheduleComplete && (!travelEpisodes.length || travelEpisodes.at(-1).startsAt <= elapsed)) {
|
|
3471
|
+
const episodeIndex = travelEpisodes.length;
|
|
3472
|
+
const startsAt = travelEpisodeStartsAt(episodeIndex);
|
|
3473
|
+
if (startsAt >= (minute + 1) * SHOWCASE_PRESET.durationMs) {
|
|
3474
|
+
travelScheduleComplete = true;
|
|
3475
|
+
break;
|
|
3476
|
+
}
|
|
3477
|
+
const overlapping = [];
|
|
3478
|
+
for (let index = travelEpisodes.length - 1; index >= 0; index -= 1) {
|
|
3479
|
+
const other = travelEpisodes[index];
|
|
3480
|
+
if (other.startsAt + TRAVEL_PRESET.flightDurationMs <= startsAt) break;
|
|
3481
|
+
if (other.route) overlapping.push(other);
|
|
3482
|
+
}
|
|
3483
|
+
const selected = new Set(overlapping.map(episode => episode.linkIndex));
|
|
3484
|
+
const candidates = travelLinkCandidatesForEpisode(episodeIndex, selected);
|
|
3485
|
+
let chosen = null;
|
|
3486
|
+
for (const linkIndex of candidates) {
|
|
3487
|
+
const episode = {
|
|
3488
|
+
linkIndex,
|
|
3489
|
+
startsAt,
|
|
3490
|
+
route: null,
|
|
3491
|
+
};
|
|
3492
|
+
chosen ||= episode;
|
|
3493
|
+
if (overlapping.length >= travelFlightLimit || overlapping.some(other => travelLinksShareEndpoint(
|
|
3494
|
+
constantReferenceLinks[other.linkIndex],
|
|
3495
|
+
constantReferenceLinks[episode.linkIndex],
|
|
3496
|
+
))) {
|
|
3497
|
+
continue;
|
|
3498
|
+
}
|
|
3499
|
+
const route = prepareTravelRoute(episodeIndex, episode, right, bottom);
|
|
3500
|
+
if (!route) continue;
|
|
3501
|
+
episode.route = route;
|
|
3502
|
+
chosen = episode;
|
|
3503
|
+
break;
|
|
3504
|
+
}
|
|
3505
|
+
if (!chosen) break;
|
|
3506
|
+
travelEpisodes.push(chosen);
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
function projectTravelRoutePoint(route, progress, matrix, out) {
|
|
3511
|
+
return projectCoordinates(
|
|
3512
|
+
quadraticCoordinate(route.departure[0], route.control[0], route.arrival[0], progress),
|
|
3513
|
+
quadraticCoordinate(route.departure[1], route.control[1], route.arrival[1], progress),
|
|
3514
|
+
quadraticCoordinate(route.departure[2], route.control[2], route.arrival[2], progress),
|
|
3515
|
+
matrix,
|
|
3516
|
+
out,
|
|
3517
|
+
);
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
function drawTravelHead(context, x, y, angle, colourChannels, emphasis) {
|
|
3521
|
+
const length = TRAVEL_PRESET.headLengthPx;
|
|
3522
|
+
const halfWidth = TRAVEL_PRESET.headWidthPx / 2;
|
|
3523
|
+
context.save();
|
|
3524
|
+
context.translate(x, y);
|
|
3525
|
+
context.rotate(angle);
|
|
3526
|
+
context.shadowColor = `rgba(${colourChannels},${(TRAVEL_PRESET.headGlowAlpha * emphasis).toFixed(4)})`;
|
|
3527
|
+
context.shadowBlur = TRAVEL_PRESET.headGlowBlur;
|
|
3528
|
+
context.fillStyle = `rgba(${colourChannels},${(TRAVEL_PRESET.headAlpha * emphasis).toFixed(4)})`;
|
|
3529
|
+
context.beginPath();
|
|
3530
|
+
context.moveTo(-length, 0);
|
|
3531
|
+
context.bezierCurveTo(-length * 0.66, -halfWidth * 0.08, -length * 0.43, -halfWidth, -length * 0.18, -halfWidth);
|
|
3532
|
+
context.bezierCurveTo(-length * 0.06, -halfWidth, 0, -halfWidth * 0.52, 0, 0);
|
|
3533
|
+
context.bezierCurveTo(0, halfWidth * 0.52, -length * 0.06, halfWidth, -length * 0.18, halfWidth);
|
|
3534
|
+
context.bezierCurveTo(-length * 0.43, halfWidth, -length * 0.66, halfWidth * 0.08, -length, 0);
|
|
3535
|
+
context.closePath();
|
|
3536
|
+
context.fill();
|
|
3537
|
+
context.restore();
|
|
3538
|
+
}
|
|
3539
|
+
|
|
3540
|
+
function travelCategoryAlpha(category) {
|
|
3541
|
+
if (category === "dependencies") return 0.82;
|
|
3542
|
+
if (category === "tests") return 1.08;
|
|
3543
|
+
return 1;
|
|
3544
|
+
}
|
|
3545
|
+
|
|
3546
|
+
function hideTravelOverlay() {
|
|
3547
|
+
if (travelOverlayDirty) {
|
|
3548
|
+
travelContext.clearRect(0, 0, width, height);
|
|
3549
|
+
travelOverlayDirty = false;
|
|
3550
|
+
}
|
|
3551
|
+
}
|
|
3552
|
+
|
|
3553
|
+
function explorerTravelElapsedAt(timestamp, enabled) {
|
|
3554
|
+
if (!enabled) {
|
|
3555
|
+
resetExplorerTravel();
|
|
3556
|
+
return 0;
|
|
3557
|
+
}
|
|
3558
|
+
if (!Number.isFinite(timestamp)) return explorerTravelElapsed;
|
|
3559
|
+
const delta = explorerTravelLastTimestamp === null
|
|
3560
|
+
? 1000 / 60
|
|
3561
|
+
: clamp(timestamp - explorerTravelLastTimestamp, 0, MAX_DRIFT_DELTA_MS);
|
|
3562
|
+
explorerTravelElapsed += delta;
|
|
3563
|
+
explorerTravelLastTimestamp = timestamp;
|
|
3564
|
+
return explorerTravelElapsed;
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
function drawTravelOverlay(elapsed, enabled) {
|
|
3568
|
+
if (!enabled || reducedMotionQuery.matches) {
|
|
3569
|
+
hideTravelOverlay();
|
|
3570
|
+
return;
|
|
3571
|
+
}
|
|
3572
|
+
if (!constantReferenceLinks.length || !Number.isFinite(elapsed)) {
|
|
3573
|
+
hideTravelOverlay();
|
|
3574
|
+
return;
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
const states = travelStatesAt(elapsed);
|
|
3578
|
+
if (!states.length) {
|
|
3579
|
+
hideTravelOverlay();
|
|
3580
|
+
return;
|
|
3581
|
+
}
|
|
3582
|
+
if (travelOverlayDirty) {
|
|
3583
|
+
travelContext.clearRect(0, 0, width, height);
|
|
3584
|
+
travelOverlayDirty = false;
|
|
3585
|
+
}
|
|
3586
|
+
let drewTravel = false;
|
|
3587
|
+
travelContext.save();
|
|
3588
|
+
travelContext.globalCompositeOperation = "lighter";
|
|
3589
|
+
travelContext.lineCap = "round";
|
|
3590
|
+
travelContext.shadowColor = "transparent";
|
|
3591
|
+
travelContext.shadowBlur = 0;
|
|
3592
|
+
const matrix = viewMatrix();
|
|
3593
|
+
for (const state of states) {
|
|
3594
|
+
const { episode, progress, visibility } = state;
|
|
3595
|
+
if (visibility < TRAVEL_PRESET.minimumVisibility) continue;
|
|
3596
|
+
const route = episode.route;
|
|
3597
|
+
const head = projectTravelRoutePoint(route, progress, matrix, [0, 0, 0]);
|
|
3598
|
+
const tangentProgress = progress < 0.999 ? progress + 0.001 : progress - 0.001;
|
|
3599
|
+
const tangent = projectTravelRoutePoint(route, tangentProgress, matrix, [0, 0, 0]);
|
|
3600
|
+
const pixelsPerProgress = head && tangent
|
|
3601
|
+
? Math.max(1, Math.hypot(tangent[0] - head[0], tangent[1] - head[1]) /
|
|
3602
|
+
Math.abs(tangentProgress - progress))
|
|
3603
|
+
: TRAVEL_PRESET.tailLengthPx / TRAVEL_PRESET.tailFraction;
|
|
3604
|
+
const link = constantReferenceLinks[episode.linkIndex];
|
|
3605
|
+
const departureCategory = travelEndpointCategory(link.departureIndex);
|
|
3606
|
+
const arrivalCategory = travelEndpointCategory(link.arrivalIndex);
|
|
3607
|
+
const departureColour = colours[departureCategory];
|
|
3608
|
+
const departureColourChannels = departureColour.join(",");
|
|
3609
|
+
const emphasis = Math.min(travelEmphasis(departureCategory), travelEmphasis(arrivalCategory)) *
|
|
3610
|
+
travelCategoryAlpha(departureCategory) * visibility;
|
|
3611
|
+
|
|
3612
|
+
const wakeEnd = Math.max(
|
|
3613
|
+
0,
|
|
3614
|
+
progress - TRAVEL_PRESET.headLengthPx * (1 - TRAVEL_PRESET.tailHeadOverlap) / pixelsPerProgress,
|
|
3615
|
+
);
|
|
3616
|
+
const drawnTailStart = Math.max(
|
|
3617
|
+
0,
|
|
3618
|
+
wakeEnd - TRAVEL_PRESET.tailFraction,
|
|
3619
|
+
wakeEnd - TRAVEL_PRESET.tailLengthPx / pixelsPerProgress,
|
|
3620
|
+
);
|
|
3621
|
+
let previous = [0, 0, 0];
|
|
3622
|
+
let current = [0, 0, 0];
|
|
3623
|
+
let previousVisible = Boolean(projectTravelRoutePoint(route, drawnTailStart, matrix, previous));
|
|
3624
|
+
for (let segment = 1; segment <= TRAVEL_PRESET.tailSegments; segment += 1) {
|
|
3625
|
+
const position = segment / TRAVEL_PRESET.tailSegments;
|
|
3626
|
+
const weight = 0.12 + 0.88 * Math.pow(position, 1.45);
|
|
3627
|
+
const segmentProgress = drawnTailStart + (wakeEnd - drawnTailStart) * position;
|
|
3628
|
+
const currentVisible = Boolean(projectTravelRoutePoint(route, segmentProgress, matrix, current));
|
|
3629
|
+
if (previousVisible && currentVisible) {
|
|
3630
|
+
travelContext.beginPath();
|
|
3631
|
+
travelContext.moveTo(previous[0], previous[1]);
|
|
3632
|
+
travelContext.lineTo(current[0], current[1]);
|
|
3633
|
+
travelContext.strokeStyle = `rgba(${departureColourChannels},${(TRAVEL_PRESET.tailAlpha * emphasis * weight).toFixed(4)})`;
|
|
3634
|
+
travelContext.lineWidth = TRAVEL_PRESET.lineWidth * (0.28 + weight * 0.72);
|
|
3635
|
+
travelContext.stroke();
|
|
3636
|
+
drewTravel = true;
|
|
3637
|
+
}
|
|
3638
|
+
const swap = previous;
|
|
3639
|
+
previous = current;
|
|
3640
|
+
current = swap;
|
|
3641
|
+
previousVisible = currentVisible;
|
|
3642
|
+
}
|
|
3643
|
+
if (!head || !tangent) continue;
|
|
3644
|
+
const tangentX = tangentProgress > progress ? tangent[0] - head[0] : head[0] - tangent[0];
|
|
3645
|
+
const tangentY = tangentProgress > progress ? tangent[1] - head[1] : head[1] - tangent[1];
|
|
3646
|
+
drawTravelHead(
|
|
3647
|
+
travelContext,
|
|
3648
|
+
head[0],
|
|
3649
|
+
head[1],
|
|
3650
|
+
Math.atan2(tangentY, tangentX),
|
|
3651
|
+
departureColourChannels,
|
|
3652
|
+
emphasis,
|
|
3653
|
+
);
|
|
3654
|
+
drewTravel = true;
|
|
3655
|
+
}
|
|
3656
|
+
travelContext.restore();
|
|
3657
|
+
if (!drewTravel) {
|
|
3658
|
+
hideTravelOverlay();
|
|
3659
|
+
return;
|
|
3660
|
+
}
|
|
3661
|
+
travelOverlayDirty = true;
|
|
3662
|
+
}
|
|
3663
|
+
|
|
2417
3664
|
function updateZoomReadout() {
|
|
2418
3665
|
zoomReadout ||= document.getElementById("zoom-level");
|
|
2419
3666
|
const text = `${Math.round(zoom * 100)}%`;
|
|
@@ -2422,9 +3669,14 @@
|
|
|
2422
3669
|
zoomReadout.value = text;
|
|
2423
3670
|
}
|
|
2424
3671
|
|
|
2425
|
-
function updateExplorerOverlay() {
|
|
2426
|
-
|
|
2427
|
-
|
|
3672
|
+
function updateExplorerOverlay(timestamp) {
|
|
3673
|
+
if (constantReferenceLinks.length) {
|
|
3674
|
+
const travelEnabled = drifting;
|
|
3675
|
+
drawTravelOverlay(explorerTravelElapsedAt(timestamp, travelEnabled), travelEnabled);
|
|
3676
|
+
} else {
|
|
3677
|
+
hideTravelOverlay();
|
|
3678
|
+
}
|
|
3679
|
+
if (document.body.classList.contains("is-ui-hidden") || !selectedPoint) return;
|
|
2428
3680
|
const screen = screenDataFor(selectedPoint, viewMatrix(), 0);
|
|
2429
3681
|
if (!screen) return;
|
|
2430
3682
|
const [x, y, size] = screen;
|
|
@@ -2437,41 +3689,58 @@
|
|
|
2437
3689
|
context.strokeStyle = colourStyles[selectedPoint.category]; context.lineWidth = 1; context.stroke();
|
|
2438
3690
|
context.globalAlpha = 1;
|
|
2439
3691
|
context.globalCompositeOperation = "source-over";
|
|
3692
|
+
travelOverlayDirty = true;
|
|
2440
3693
|
}
|
|
2441
3694
|
|
|
2442
3695
|
function render(timestamp) {
|
|
2443
3696
|
animationFrame = 0;
|
|
2444
3697
|
if (showcaseMode) {
|
|
3698
|
+
const elapsed = showcaseStartedAt === null ? 0 : Math.max(0, timestamp - showcaseStartedAt);
|
|
3699
|
+
dependencySpinElapsed = reducedMotionQuery.matches ? 0 : elapsed % SHOWCASE_PRESET.durationMs;
|
|
2445
3700
|
if (showcaseRenderer) showcaseRenderer.render();
|
|
3701
|
+
if (constantReferenceLinks.length) drawTravelOverlay(elapsed, Boolean(showcaseRenderer));
|
|
2446
3702
|
return;
|
|
2447
3703
|
}
|
|
2448
3704
|
if (!explorerRenderer) return;
|
|
2449
3705
|
const driftAdvanced = advanceExplorerDrift(timestamp);
|
|
3706
|
+
const spinAdvanced = advanceDependencySpin(timestamp);
|
|
2450
3707
|
updateCameraFlight(timestamp);
|
|
2451
3708
|
updateZoomReadout();
|
|
2452
3709
|
explorerRenderer.render();
|
|
2453
|
-
updateExplorerOverlay();
|
|
3710
|
+
updateExplorerOverlay(timestamp);
|
|
2454
3711
|
if (selectedPoint) {
|
|
2455
3712
|
if (cameraFlight) tooltip.hidden = true;
|
|
2456
3713
|
else positionTooltip(selectedPoint);
|
|
2457
3714
|
}
|
|
2458
|
-
if (cameraFlight || driftAdvanced) requestRender();
|
|
3715
|
+
if (cameraFlight || driftAdvanced || spinAdvanced) requestRender();
|
|
2459
3716
|
}
|
|
2460
3717
|
|
|
2461
3718
|
function requestRender() {
|
|
2462
3719
|
if (!animationFrame) animationFrame = requestAnimationFrame(render);
|
|
2463
3720
|
}
|
|
2464
3721
|
|
|
2465
|
-
|
|
3722
|
+
const showcaseCameraScratch = {};
|
|
3723
|
+
|
|
3724
|
+
function showcaseCameraState(progress, out = {}) {
|
|
2466
3725
|
const wrapped = ((Number(progress) % 1) + 1) % 1;
|
|
2467
3726
|
const yawSign = screenRotationYawSign(SHOWCASE_PRESET.elevationDegrees * Math.PI / 180);
|
|
2468
3727
|
const phase = wrapped * Math.PI * 2 * SHOWCASE_PRESET.turns * yawSign;
|
|
2469
3728
|
const viewportScale = Math.min(width / SHOWCASE_PRESET.layoutReferenceWidth, height / SHOWCASE_PRESET.layoutReferenceHeight);
|
|
2470
|
-
yaw = SHOWCASE_PRESET.startAngleDegrees * Math.PI / 180 + phase;
|
|
2471
|
-
pitch = (SHOWCASE_PRESET.elevationDegrees + Math.sin(phase) * SHOWCASE_PRESET.elevationSwayDegrees) * Math.PI / 180;
|
|
2472
|
-
zoom = SHOWCASE_PRESET.zoom * (1 + ((1 - Math.cos(phase)) / 2) * SHOWCASE_PRESET.zoomBreathPercent / 100) * viewportScale;
|
|
2473
|
-
panX = 0;
|
|
2474
|
-
panY = 0;
|
|
3729
|
+
out.yaw = SHOWCASE_PRESET.startAngleDegrees * Math.PI / 180 + phase;
|
|
3730
|
+
out.pitch = (SHOWCASE_PRESET.elevationDegrees + Math.sin(phase) * SHOWCASE_PRESET.elevationSwayDegrees) * Math.PI / 180;
|
|
3731
|
+
out.zoom = SHOWCASE_PRESET.zoom * (1 + ((1 - Math.cos(phase)) / 2) * SHOWCASE_PRESET.zoomBreathPercent / 100) * viewportScale;
|
|
3732
|
+
out.panX = 0;
|
|
3733
|
+
out.panY = 0;
|
|
3734
|
+
return out;
|
|
3735
|
+
}
|
|
3736
|
+
|
|
3737
|
+
function applyShowcaseCamera(progress) {
|
|
3738
|
+
const camera = showcaseCameraState(progress, showcaseCameraScratch);
|
|
3739
|
+
yaw = camera.yaw;
|
|
3740
|
+
pitch = camera.pitch;
|
|
3741
|
+
zoom = camera.zoom;
|
|
3742
|
+
panX = camera.panX;
|
|
3743
|
+
panY = camera.panY;
|
|
2475
3744
|
}
|
|
2476
3745
|
|
|
2477
3746
|
function hideShowcaseAnnotation() {
|
|
@@ -2513,12 +3782,7 @@
|
|
|
2513
3782
|
return null;
|
|
2514
3783
|
}
|
|
2515
3784
|
|
|
2516
|
-
function
|
|
2517
|
-
if (reducedMotionQuery.matches || !showcaseAnnotation || showcaseStartedAt === null) {
|
|
2518
|
-
hideShowcaseAnnotation();
|
|
2519
|
-
return;
|
|
2520
|
-
}
|
|
2521
|
-
const elapsed = Math.max(0, timestamp - showcaseStartedAt);
|
|
3785
|
+
function trackShowcaseAnnotation(elapsed) {
|
|
2522
3786
|
const slot = Math.floor(elapsed / SHOWCASE_ANNOTATION_PRESET.slotDurationMs);
|
|
2523
3787
|
const slotElapsed = elapsed % SHOWCASE_ANNOTATION_PRESET.slotDurationMs;
|
|
2524
3788
|
const matrix = viewMatrix();
|
|
@@ -2534,31 +3798,44 @@
|
|
|
2534
3798
|
showcaseAnnotationName.textContent = annotation.name;
|
|
2535
3799
|
}
|
|
2536
3800
|
}
|
|
2537
|
-
if (!activeShowcaseAnnotation) return;
|
|
3801
|
+
if (!activeShowcaseAnnotation) return null;
|
|
2538
3802
|
|
|
2539
3803
|
const projected = project(activeShowcaseAnnotation.annotation.point, matrix);
|
|
2540
|
-
if (!projected)
|
|
3804
|
+
if (!projected) return null;
|
|
3805
|
+
const [x, y] = projected;
|
|
3806
|
+
showcaseAnnotation.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
3807
|
+
return { slotElapsed, fits: showcaseAnnotationFits(x, y, activeShowcaseAnnotation.side) };
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
function updateShowcaseAnnotation(timestamp) {
|
|
3811
|
+
if (reducedMotionQuery.matches || !showcaseAnnotation || showcaseStartedAt === null) {
|
|
2541
3812
|
hideShowcaseAnnotation();
|
|
2542
3813
|
return;
|
|
2543
3814
|
}
|
|
2544
|
-
const
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
3815
|
+
const tracked = trackShowcaseAnnotation(Math.max(0, timestamp - showcaseStartedAt));
|
|
3816
|
+
if (!tracked) {
|
|
3817
|
+
hideShowcaseAnnotation();
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
const revealed = tracked.slotElapsed >= SHOWCASE_ANNOTATION_PRESET.revealStartMs &&
|
|
3821
|
+
tracked.slotElapsed <= SHOWCASE_ANNOTATION_PRESET.revealEndMs &&
|
|
3822
|
+
tracked.fits;
|
|
2549
3823
|
showcaseAnnotation.classList.toggle("is-visible", revealed);
|
|
2550
3824
|
document.documentElement.dataset.showcaseAnnotation = revealed
|
|
2551
3825
|
? activeShowcaseAnnotation.annotation.name
|
|
2552
3826
|
: "hidden";
|
|
2553
3827
|
}
|
|
2554
3828
|
|
|
3829
|
+
function showcaseFrameProgress(elapsed) {
|
|
3830
|
+
const frameCount = SHOWCASE_PRESET.targetFps * SHOWCASE_PRESET.durationMs / 1000;
|
|
3831
|
+
const rawProgress = (elapsed % SHOWCASE_PRESET.durationMs) / SHOWCASE_PRESET.durationMs;
|
|
3832
|
+
return Math.floor(rawProgress * frameCount) / frameCount;
|
|
3833
|
+
}
|
|
3834
|
+
|
|
2555
3835
|
function renderShowcase(timestamp) {
|
|
2556
|
-
if (!showcaseRenderer) return;
|
|
3836
|
+
if (!showcaseRenderer || clipMode) return;
|
|
2557
3837
|
showcaseStartedAt ??= timestamp;
|
|
2558
|
-
|
|
2559
|
-
const rawProgress = ((timestamp - showcaseStartedAt) % SHOWCASE_PRESET.durationMs) / SHOWCASE_PRESET.durationMs;
|
|
2560
|
-
const progress = Math.floor(rawProgress * frameCount) / frameCount;
|
|
2561
|
-
applyShowcaseCamera(progress);
|
|
3838
|
+
applyShowcaseCamera(showcaseFrameProgress(timestamp - showcaseStartedAt));
|
|
2562
3839
|
render(timestamp);
|
|
2563
3840
|
if (!showcaseRenderer) return;
|
|
2564
3841
|
if (showcaseDetails) updateShowcaseAnnotation(timestamp);
|
|
@@ -2566,7 +3843,72 @@
|
|
|
2566
3843
|
if (!reducedMotionQuery.matches) animationFrame = requestAnimationFrame(renderShowcase);
|
|
2567
3844
|
}
|
|
2568
3845
|
|
|
3846
|
+
// Clip export drives Showcase frames from an external capture process
|
|
3847
|
+
// (`rubylens clip`). Frames are a pure function of (frameIndex, fps):
|
|
3848
|
+
// annotation choreography runs off the same synthetic clock with inline
|
|
3849
|
+
// opacity, because live CSS fades would tie captures to wall-clock time.
|
|
3850
|
+
function showcaseClipAnnotationOpacity(slotElapsed) {
|
|
3851
|
+
const preset = SHOWCASE_ANNOTATION_PRESET;
|
|
3852
|
+
if (slotElapsed < preset.revealStartMs) return 0;
|
|
3853
|
+
if (slotElapsed <= preset.revealEndMs) {
|
|
3854
|
+
const linear = Math.min(1, (slotElapsed - preset.revealStartMs) / preset.fadeInMs);
|
|
3855
|
+
return 1 - (1 - linear) ** 3;
|
|
3856
|
+
}
|
|
3857
|
+
const linear = Math.min(1, (slotElapsed - preset.revealEndMs) / preset.fadeOutMs);
|
|
3858
|
+
return Math.max(0, 1 - linear * linear);
|
|
3859
|
+
}
|
|
3860
|
+
|
|
3861
|
+
function updateShowcaseClipAnnotation(elapsed) {
|
|
3862
|
+
if (!showcaseAnnotation) return;
|
|
3863
|
+
const tracked = trackShowcaseAnnotation(elapsed);
|
|
3864
|
+
const opacity = tracked && tracked.fits ? showcaseClipAnnotationOpacity(tracked.slotElapsed) : 0;
|
|
3865
|
+
showcaseAnnotation.classList.toggle("is-visible", opacity > 0);
|
|
3866
|
+
showcaseAnnotation.style.opacity = opacity.toFixed(4);
|
|
3867
|
+
document.documentElement.dataset.showcaseAnnotation = opacity > 0
|
|
3868
|
+
? activeShowcaseAnnotation.annotation.name
|
|
3869
|
+
: "hidden";
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
function beginShowcaseClip() {
|
|
3873
|
+
if (!showcaseMode) return { status: "not-showcase" };
|
|
3874
|
+
if (!showcaseRenderer) return { status: "renderer-unavailable" };
|
|
3875
|
+
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
3876
|
+
animationFrame = 0;
|
|
3877
|
+
clipMode = true;
|
|
3878
|
+
showcaseStartedAt = 0;
|
|
3879
|
+
showcaseAnnotationSlot = -1;
|
|
3880
|
+
activeShowcaseAnnotation = null;
|
|
3881
|
+
hideShowcaseAnnotation();
|
|
3882
|
+
drawTravelOverlay(0, false);
|
|
3883
|
+
if (showcaseAnnotation) {
|
|
3884
|
+
showcaseAnnotation.hidden = !showcaseDetails || !renderedShowcaseAnnotations.length;
|
|
3885
|
+
showcaseAnnotation.style.opacity = "0";
|
|
3886
|
+
}
|
|
3887
|
+
document.documentElement.dataset.rubylensClip = "true";
|
|
3888
|
+
document.documentElement.dataset.showcaseMotion = "clip";
|
|
3889
|
+
return {
|
|
3890
|
+
status: "ok",
|
|
3891
|
+
durationMs: SHOWCASE_PRESET.durationMs,
|
|
3892
|
+
stageWidth: SHOWCASE_PRESET.stageWidth,
|
|
3893
|
+
stageHeight: SHOWCASE_PRESET.stageHeight,
|
|
3894
|
+
details: showcaseDetails,
|
|
3895
|
+
};
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
function renderShowcaseClipFrame(frameIndex, fps) {
|
|
3899
|
+
if (!clipMode || !showcaseRenderer) return Promise.reject(new Error("clip mode is not active"));
|
|
3900
|
+
const elapsed = frameIndex * 1000 / fps;
|
|
3901
|
+
applyShowcaseCamera(showcaseFrameProgress(elapsed));
|
|
3902
|
+
render(elapsed);
|
|
3903
|
+
if (showcaseDetails) updateShowcaseClipAnnotation(elapsed);
|
|
3904
|
+
return new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
3905
|
+
document.documentElement.dataset.clipFrame = String(frameIndex);
|
|
3906
|
+
resolve(frameIndex);
|
|
3907
|
+
})));
|
|
3908
|
+
}
|
|
3909
|
+
|
|
2569
3910
|
function startShowcase() {
|
|
3911
|
+
if (clipMode) return;
|
|
2570
3912
|
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
2571
3913
|
animationFrame = 0;
|
|
2572
3914
|
showcaseStartedAt = null;
|
|
@@ -2604,7 +3946,7 @@
|
|
|
2604
3946
|
});
|
|
2605
3947
|
const stats = document.querySelector(".cinema-stats");
|
|
2606
3948
|
const secondary = document.getElementById("cinema-secondary");
|
|
2607
|
-
secondary.textContent = `Tests · ${counted(tests[0], "class", "classes")} · ${counted(tests[2], "method", "methods")} · ${counted(
|
|
3949
|
+
secondary.textContent = `Tests · ${counted(tests[0], "class", "classes")} · ${counted(tests[2], "method", "methods")} · ${counted(packageCount, "dependency gem", "dependency gems")} in orbit`;
|
|
2608
3950
|
stats.hidden = false;
|
|
2609
3951
|
secondary.hidden = false;
|
|
2610
3952
|
}
|
|
@@ -2613,6 +3955,7 @@
|
|
|
2613
3955
|
const rendererUnavailable = document.documentElement.dataset.explorerRenderer === "unavailable";
|
|
2614
3956
|
drifting = interactiveMode && !rendererUnavailable && driftRequested && !reducedMotionQuery.matches;
|
|
2615
3957
|
lastDriftTimestamp = null;
|
|
3958
|
+
lastDependencySpinTimestamp = null;
|
|
2616
3959
|
const motion = document.getElementById("motion");
|
|
2617
3960
|
motion.disabled = rendererUnavailable || reducedMotionQuery.matches;
|
|
2618
3961
|
if (rendererUnavailable) {
|
|
@@ -2701,6 +4044,7 @@
|
|
|
2701
4044
|
else {
|
|
2702
4045
|
yaw += dx * .006;
|
|
2703
4046
|
pitch = clamp(pitch + dy * .004, -TOP_DOWN_PITCH, TOP_DOWN_PITCH);
|
|
4047
|
+
rebaseExplorerTravelAdmission();
|
|
2704
4048
|
}
|
|
2705
4049
|
gesture.lastX = event.clientX;
|
|
2706
4050
|
gesture.lastY = event.clientY;
|
|
@@ -2714,6 +4058,12 @@
|
|
|
2714
4058
|
dragging = pointers.size > 0;
|
|
2715
4059
|
canvas.classList.remove("is-dragging-pan");
|
|
2716
4060
|
if (wasTap) {
|
|
4061
|
+
if (document.body.classList.contains("is-ui-hidden")) {
|
|
4062
|
+
doubleClickTarget = null;
|
|
4063
|
+
restoreTapDoubleClickDeadline = event.timeStamp + 1000;
|
|
4064
|
+
setUiHidden(false);
|
|
4065
|
+
return;
|
|
4066
|
+
}
|
|
2717
4067
|
const point = hoverTargetAt(event.clientX, event.clientY);
|
|
2718
4068
|
const rememberedTapIsFresh = doubleClickTarget &&
|
|
2719
4069
|
event.timeStamp - doubleClickTarget.at <= 1000 &&
|
|
@@ -2753,6 +4103,11 @@
|
|
|
2753
4103
|
}, { passive: false });
|
|
2754
4104
|
canvas.addEventListener("dblclick", event => {
|
|
2755
4105
|
if (pointers.size > 0) return;
|
|
4106
|
+
if (event.timeStamp <= restoreTapDoubleClickDeadline) {
|
|
4107
|
+
restoreTapDoubleClickDeadline = 0;
|
|
4108
|
+
doubleClickTarget = null;
|
|
4109
|
+
return;
|
|
4110
|
+
}
|
|
2756
4111
|
const remembered = doubleClickTarget;
|
|
2757
4112
|
doubleClickTarget = null;
|
|
2758
4113
|
const rememberedPoint = remembered &&
|
|
@@ -2793,6 +4148,7 @@
|
|
|
2793
4148
|
document.getElementById("zoom-in").addEventListener("click", () => { if (pointers.size === 0) { cancelCameraFlight(); zoomBetween(zoom * ZOOM_STEP, sceneCenterX, sceneCenterY); } requestRender(); });
|
|
2794
4149
|
document.getElementById("zoom-out").addEventListener("click", () => { if (pointers.size === 0) { cancelCameraFlight(); zoomBetween(zoom / ZOOM_STEP, sceneCenterX, sceneCenterY); } requestRender(); });
|
|
2795
4150
|
document.getElementById("reset-view").addEventListener("click", resetView);
|
|
4151
|
+
uiToggle.addEventListener("click", () => setUiHidden(!document.body.classList.contains("is-ui-hidden")));
|
|
2796
4152
|
document.getElementById("help-open").addEventListener("click", toggleHelp);
|
|
2797
4153
|
helpClose.addEventListener("click", closeHelp);
|
|
2798
4154
|
helpOverlay.addEventListener("click", event => { if (event.target === helpOverlay) closeHelp(); });
|
|
@@ -2818,7 +4174,7 @@
|
|
|
2818
4174
|
} else {
|
|
2819
4175
|
document.title = `RubyLens · ${model.projectName}`;
|
|
2820
4176
|
if (explorerRenderer) {
|
|
2821
|
-
canvas.setAttribute("aria-label", `Interactive three-dimensional stellar artwork of ${model.projectName}. Hover class or module stars, dependency systems, or gem clouds for details. Selections open a top-down view that keeps the selected target and Core visible. Double-click a dependency system or gem cloud, press Enter or F on its selected marker, or tap that marker again to expand it. Drag to orbit, Shift-drag or Pan mode to move, scroll or pinch to zoom at a point, use arrow keys to move the view, Space to pause or resume drift, 0 to reset, slash to search, and question mark for the full shortcut list.`);
|
|
4177
|
+
canvas.setAttribute("aria-label", `Interactive three-dimensional stellar artwork of ${model.projectName}. Hover class or module stars, dependency systems, or gem clouds for details. Selections open a top-down view that keeps the selected target and Core visible. Double-click a dependency system or gem cloud, press Enter or F on its selected marker, or tap that marker again to expand it. Drag to orbit, Shift-drag or Pan mode to move, scroll or pinch to zoom at a point, use arrow keys to move the view, Space to pause or resume drift, H to hide or show the interface, 0 to reset, slash to search, and question mark for the full shortcut list.`);
|
|
2822
4178
|
}
|
|
2823
4179
|
populateWarningDisclosure();
|
|
2824
4180
|
applyCameraTarget(DEFAULT_CAMERA);
|