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.
@@ -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 DEPENDENCY_CLOUD_THREASHOLD = 18;
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 LEGACY_MORPHOLOGY = Object.freeze({
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": 35,
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
- if (normalizedSeed === 0) return LEGACY_MORPHOLOGY;
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(raw, fallbackPhaseSeed = 0) {
178
- const row = Array.isArray(raw)
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, 700) / 1000 : 0,
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 (!activeMorphology.legacy) {
238
- if (activeMorphology.family === MORPHOLOGY_FAMILY.elliptical) {
239
- coreExtent = 36;
240
- testExtent = 52;
241
- } else if (activeMorphology.family === MORPHOLOGY_FAMILY.lenticular) {
242
- const discProgress = clamp((.42 - activeMorphology.bulgeShare) / .08, 0, 1);
243
- coreExtent = 42 + 2 * discProgress;
244
- testExtent = 54 + 4 * discProgress;
245
- } else if (activeMorphology.family === MORPHOLOGY_FAMILY.irregular) {
246
- const centerSpread = 12 + 18 * activeMorphology.clumpSpread;
247
- coreExtent = centerSpread + 24;
248
- testExtent = centerSpread + 32;
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[3] === 1 ? 0 : 1), 0);
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 && !morphology.legacy
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
- function spiralArmTheta(seed, radial, channel) {
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 barEnd = arm % 2;
305
- const branch = Math.floor(arm / 2);
306
- const branchesAtEnd = Math.ceil((morphology.armCount - barEnd) / 2);
307
- const branchOffset = (branch - (branchesAtEnd - 1) / 2) * Math.PI / Math.max(3, morphology.armCount);
308
- origin = morphology.phase + barEnd * Math.PI + branchOffset;
309
- armRadial = Math.max(0, radial - (12 + morphology.barLength * 24) * .55);
310
- } else {
311
- origin = morphology.phase + arm * Math.PI * 2 / morphology.armCount;
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
- return origin + armRadial * morphology.winding + normal(seed, channel + 1) * scatter;
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
- const halfLength = 12 + morphology.barLength * 24;
334
- const along = (unit(seed, 34) * 2 - 1) * halfLength;
335
- const across = normal(seed, 35) * (1 + Math.abs(along) * .025);
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 && radial < 12 + morphology.barLength * 24 && unit(seed, 33) < .72) {
368
- return barredCorePosition(seed, vertical, scale);
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
- const inArm = coreDiscUsesArm(seed, bulge);
371
- const theta = inArm
372
- ? spiralArmTheta(seed, radial, 31)
373
- : morphology.phase + unit(seed, 4) * Math.PI * 2 + radial * .04;
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
- : 17 + Math.min(45, -14 * Math.log(Math.max(1e-5, 1 - unit(seed, 7))));
394
- const inArm = spiralMorphology && unit(seed, 9) < morphology.armFraction;
395
- const theta = inArm
396
- ? spiralArmTheta(seed, radial, 8)
397
- : morphology.phase + unit(seed, 10) * Math.PI * 2;
398
- const vertical = normal(seed, 11) * (1.4 + radial * .035);
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 < DEPENDENCY_CLOUD_THREASHOLD,
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(unit(seed, 19), 1.55)
471
- : radius * (.2 + .72 * Math.sqrt(unit(seed, 19)));
472
- const vertical = clamp(normal(seed, 20), -2.2, 2.2) * radius * (bulge ? .13 : .055);
473
- if (cloud.family === MORPHOLOGY_FAMILY.barredSpiral && !bulge && radial < radius * cloud.barLength && unit(seed, 21) < .74) {
474
- const along = (unit(seed, 22) * 2 - 1) * radius * cloud.barLength;
475
- const across = clamp(normal(seed, 23), -2.2, 2.2) * radius * .055;
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, 21) * armCount);
486
- const armOrigin = cloud.family === MORPHOLOGY_FAMILY.barredSpiral ? arm % 2 * Math.PI : arm * Math.PI * 2 / armCount;
487
- const inArm = unit(seed, 22) < cloud.armFraction;
488
- const radialShare = radial / radius;
489
- const theta = inArm
490
- ? cloud.phase + armOrigin + radialShare * cloud.winding * 42 + normal(seed, 23) * .17
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
- return boundedDependencyOffset(Math.cos(theta) * radial, vertical, Math.sin(theta) * radial, radius);
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.min(9, Math.sqrt(declarationCount) * .055)];
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.min(9, Math.sqrt(row[3]) * .055);
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] + offset[0],
535
- anchor[1] + offset[1],
536
- anchor[2] + offset[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[3] === 1 ? "tests" : "core";
587
- const values = row.slice(4, 10);
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[2] === 0 ? "Class" : "Module", rubyCounts: row.slice(10, 14), instanceVariableCount: row[14] || 0, values }
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 totals = model.totals || {
642
- namespaces: model.namespaces.length,
643
- packages: model.packages.length,
644
- dependencyStars: model.dependencyStars.length,
645
- renderedDependencyStars: model.dependencyStars.length,
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
- const exactDependencyDeclarations = Math.max(0, Number(totals.dependencyStars) || 0);
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
- // Largest sprite is an unexpanded hub glow: radius 5.2 * 3.4 CSS pixels.
820
- const maxSpriteCssSize = 5.2 * 3.4 * 2 + 2;
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 = a_position.x * cy - a_position.z * sy;
882
- float z1 = a_position.x * sy + a_position.z * cy;
883
- float y2 = a_position.y * cp - z1 * sp;
884
- float z2 = a_position.y * sp + z1 * cp;
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 = a_category > 1.5 ? float(${SHOWCASE_DEPENDENCY_PRESET.starAlphaScale}) : 1.0;
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 = a_category < 0.5
1464
+ vec3 colour = categoryCode < 0.5
901
1465
  ? ${glslVec3(colours.core)} / 255.0
902
- : (a_category < 1.5 ? ${glslVec3(colours.tests)} / 255.0 : ${glslVec3(colours.dependencies)} / 255.0);
903
-
904
- if (u_pass == 0) {
905
- if (size <= 1.35 || u_glow <= 0.0) { hidePoint(); return; }
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, sceneData, gl.STATIC_DRAW);
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
- // Largest sprite is an unexpanded hub glow: radius 5.2 * 3.4 CSS pixels.
1042
- const maxSpriteCssSize = 5.2 * 3.4 * 2 + 2;
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
- int category = int(a_category + 0.5);
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
- vec3 position = a_position;
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 visibleAlpha = (focusedDependencyPoint ? focusedAlpha : a_alpha * emphasis) * u_exposure;
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 = a_category < 0.5
1792
+ vec3 colour = categoryCode < 0.5
1143
1793
  ? ${glslVec3(colours.core)} / 255.0
1144
- : (a_category < 1.5 ? ${glslVec3(colours.tests)} / 255.0 : ${glslVec3(colours.dependencies)} / 255.0);
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 = focusedDependencyPoint ? 2.2 - u_deepDetail * 0.8 : 3.4 - u_deepDetail * 1.3;
1798
+ float glowScale = 3.4 - u_deepDetail * 1.3;
1149
1799
  radius = size * glowScale;
1150
- alpha = visibleAlpha * (focusedDependencyPoint ? 0.045 : 0.055);
1800
+ alpha = visibleAlpha * 0.055 * (1.0 - 0.78 * u_deepDetail);
1151
1801
  } else if (u_pass == 1) {
1152
- radius = (!detailed || size < 0.85) ? 0.5 : size;
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, sceneData, gl.STATIC_DRAW);
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", "u_pass",
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 = totals.packages - directGemCount;
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", summary: `${totals.packages.toLocaleString()} dependency gems`, rubyCounts: dependencyRubyCounts, metricIndexes: allRubyMetricIndexes, note: `${directGemCount.toLocaleString()} direct · ${transitiveGemCount.toLocaleString()} transitive`, focusZoom: .72 },
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.textContent = collapsed ? "Show" : "Hide";
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
- expandedSystemIndex = point.systemIndex >= 0 ? point.systemIndex : null;
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 && !sampling && warningTotal === 0;
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 ${totals.packages.toLocaleString()} ${totals.packages === 1 ? "gem" : "gems"} remain complete.`,
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 = "Search Core, Tests, and Gems.";
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
- heading.append(title);
2147
- if (meta.summary) {
2148
- const summaryText = document.createElement("small");
2149
- summaryText.textContent = meta.summary;
2150
- heading.append(summaryText);
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(swatch, heading);
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
- visibilityText.textContent = "Visible";
2168
- visibility.append(checkbox, visibilityText);
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 = "Ruby code highlights";
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 === "?") { if (!event.repeat) toggleHelp(); }
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 project(point, matrix, out) {
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
- context.clearRect(0, 0, width, height);
2427
- if (!selectedPoint) return;
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
- function applyShowcaseCamera(progress) {
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 updateShowcaseAnnotation(timestamp) {
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 [x, y] = projected;
2545
- showcaseAnnotation.style.transform = `translate3d(${x}px, ${y}px, 0)`;
2546
- const revealed = slotElapsed >= SHOWCASE_ANNOTATION_PRESET.revealStartMs &&
2547
- slotElapsed <= SHOWCASE_ANNOTATION_PRESET.revealEndMs &&
2548
- showcaseAnnotationFits(x, y, activeShowcaseAnnotation.side);
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
- const frameCount = SHOWCASE_PRESET.targetFps * SHOWCASE_PRESET.durationMs / 1000;
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(totals.packages, "dependency gem", "dependency gems")} in orbit`;
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);