rubylens 0.1.0.pre.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +67 -0
- data/assets/runtime/report.js +1409 -0
- data/assets/shells/report.html +34 -0
- data/assets/shells/showcase.html +26 -0
- data/assets/styles/report.css +85 -0
- data/assets/styles/showcase.css +15 -0
- data/exe/rubylens +6 -0
- data/lib/rubylens/art_model_builder.rb +94 -0
- data/lib/rubylens/cli.rb +104 -0
- data/lib/rubylens/errors.rb +6 -0
- data/lib/rubylens/generator.rb +56 -0
- data/lib/rubylens/git_repository.rb +106 -0
- data/lib/rubylens/index/manifest.rb +231 -0
- data/lib/rubylens/index/rubydex_adapter.rb +322 -0
- data/lib/rubylens/model/dependency_aggregation.rb +105 -0
- data/lib/rubylens/paths.rb +13 -0
- data/lib/rubylens/report_asset_assembler.rb +38 -0
- data/lib/rubylens/report_writer.rb +61 -0
- data/lib/rubylens/showcase_generator.rb +41 -0
- data/lib/rubylens/showcase_model.rb +41 -0
- data/lib/rubylens/showcase_writer.rb +27 -0
- data/lib/rubylens/version.rb +5 -0
- data/lib/rubylens.rb +33 -0
- metadata +99 -0
|
@@ -0,0 +1,1409 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const model = JSON.parse(atob("{{MODEL_BASE64}}"));
|
|
3
|
+
const showcaseMode = document.body.dataset.rubylensMode === "showcase";
|
|
4
|
+
const interactiveMode = !showcaseMode;
|
|
5
|
+
const canvas = document.getElementById("cosmos");
|
|
6
|
+
const context = canvas.getContext("2d", { alpha: false });
|
|
7
|
+
const showcaseStage = document.getElementById("showcase-stage");
|
|
8
|
+
const panel = document.getElementById("panel");
|
|
9
|
+
const panelBody = document.getElementById("panel-body");
|
|
10
|
+
const panelToggle = document.getElementById("panel-toggle");
|
|
11
|
+
const tooltip = document.getElementById("tooltip");
|
|
12
|
+
const tooltipCategory = document.getElementById("tooltip-category");
|
|
13
|
+
const tooltipName = document.getElementById("tooltip-name");
|
|
14
|
+
const tooltipContext = document.getElementById("tooltip-context");
|
|
15
|
+
const tooltipMetrics = document.getElementById("tooltip-metrics");
|
|
16
|
+
const fields = ["ancestorDepth", "definitionSites", "reopenings", "descendants", "references", "members"];
|
|
17
|
+
const rubyMetricLabels = ["Classes", "Modules", "Methods", "Constants"];
|
|
18
|
+
const signalWeights = {
|
|
19
|
+
core: { ancestorDepth: .28, definitionSites: .2, reopenings: .18, descendants: .72, references: .82, members: .7 },
|
|
20
|
+
tests: { ancestorDepth: .18, definitionSites: .25, reopenings: .18, descendants: .42, references: .85, members: .55 },
|
|
21
|
+
dependencies: { ancestorDepth: .12, definitionSites: .35, reopenings: .2, descendants: .32, references: .48, members: .4 },
|
|
22
|
+
};
|
|
23
|
+
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, expandedPackageIndex = null, activeFactButton = null, navigationMode = "orbit", cameraFlight = null, showcaseStartedAt = null, showcaseRenderer = null;
|
|
24
|
+
const MIN_ZOOM = .35, MAX_ZOOM = 40, ZOOM_STEP = 1.7, DEPENDENCY_EXPANSION = 2.35, SHOWCASE_POINT_LIMIT = 50_000;
|
|
25
|
+
const SHOWCASE_PRESET = Object.freeze({
|
|
26
|
+
"stageWidth": 1920,
|
|
27
|
+
"stageHeight": 1080,
|
|
28
|
+
"durationMs": 60000,
|
|
29
|
+
"targetFps": 60,
|
|
30
|
+
"turns": 1,
|
|
31
|
+
"direction": "clockwise",
|
|
32
|
+
"startAngleDegrees": -54,
|
|
33
|
+
"elevationDegrees": -25,
|
|
34
|
+
"elevationSwayDegrees": 1.5,
|
|
35
|
+
"zoom": 1.6,
|
|
36
|
+
"zoomBreathPercent": 0,
|
|
37
|
+
"centerXPercent": 49,
|
|
38
|
+
"centerYPercent": 67,
|
|
39
|
+
"starBrightnessPercent": 75,
|
|
40
|
+
"pointGlowPercent": 35,
|
|
41
|
+
"backgroundGlowPercent": 200,
|
|
42
|
+
"textScalePercent": 80,
|
|
43
|
+
"layoutReferenceWidth": 720,
|
|
44
|
+
"layoutReferenceHeight": 405,
|
|
45
|
+
"mastheadLeft": 44,
|
|
46
|
+
"mastheadTop": 40,
|
|
47
|
+
"mastheadWidth": 632
|
|
48
|
+
});
|
|
49
|
+
const TOP_DOWN_PITCH = Math.PI / 2;
|
|
50
|
+
const contextVisibility = { selection: .75, category: .16, package: .75 };
|
|
51
|
+
const pointers = new Map();
|
|
52
|
+
const visibleCategories = { core: true, tests: true, dependencies: true };
|
|
53
|
+
const visibilityInputs = {};
|
|
54
|
+
const focusButtons = {};
|
|
55
|
+
const excludedTriviaNames = new Set(["Object", "Kernel", "BasicObject"]);
|
|
56
|
+
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
57
|
+
let drifting = interactiveMode && !reducedMotionQuery.matches;
|
|
58
|
+
const colours = { core: [244, 82, 132], tests: [87, 204, 255], dependencies: [255, 184, 77] };
|
|
59
|
+
|
|
60
|
+
const hash = (seed, channel = 0) => {
|
|
61
|
+
let value = (seed ^ (channel * 0x9e3779b9)) >>> 0;
|
|
62
|
+
value = Math.imul(value ^ value >>> 16, 0x21f0aaad);
|
|
63
|
+
value = Math.imul(value ^ value >>> 15, 0x735a2d97);
|
|
64
|
+
return (value ^ value >>> 15) >>> 0;
|
|
65
|
+
};
|
|
66
|
+
const unit = (seed, channel) => hash(seed, channel) / 4294967296;
|
|
67
|
+
const normal = (seed, channel) => Math.sqrt(-2 * Math.log(Math.max(unit(seed, channel), 1e-7))) * Math.cos(6.283185 * unit(seed, channel + 1));
|
|
68
|
+
const clamp = (value, low, high) => Math.max(low, Math.min(high, value));
|
|
69
|
+
|
|
70
|
+
function normalizedSignals(values) {
|
|
71
|
+
return fields.map((field, index) => Math.log1p(values[index] || 0) / Math.log1p(model.domains[field] || 1));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function weightedSignal(normalized, category) {
|
|
75
|
+
return fields.reduce((total, field, index) => total + signalWeights[category][field] * normalized[index], .12);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function corePosition(seed) {
|
|
79
|
+
const bulge = unit(seed, 2) < .24;
|
|
80
|
+
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))));
|
|
81
|
+
const theta = unit(seed, 4) * Math.PI * 2 + radial * .04;
|
|
82
|
+
const vertical = normal(seed, 5) * (bulge ? 5.8 : 1.4 + radial * .025);
|
|
83
|
+
return [Math.cos(theta) * radial, vertical, Math.sin(theta) * radial];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function testPosition(seed) {
|
|
87
|
+
const radial = 17 + Math.min(45, -14 * Math.log(Math.max(1e-5, 1 - unit(seed, 7))));
|
|
88
|
+
const arm = Math.floor(unit(seed, 8) * 3);
|
|
89
|
+
const inArm = unit(seed, 9) < .38;
|
|
90
|
+
const theta = inArm
|
|
91
|
+
? arm * (Math.PI * 2 / 3) + radial * .105 + normal(seed, 10) * .22
|
|
92
|
+
: unit(seed, 10) * Math.PI * 2;
|
|
93
|
+
const vertical = normal(seed, 11) * (1.4 + radial * .035);
|
|
94
|
+
return [Math.cos(theta) * radial, vertical, Math.sin(theta) * radial];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const packageAnchors = model.packages.map((row, index) => {
|
|
98
|
+
const seed = row[0], radius = 70 + 72 * Math.pow(unit(seed, 14), .72);
|
|
99
|
+
const theta = unit(seed, 15) * Math.PI * 2;
|
|
100
|
+
const vertical = normal(seed, 16) * 24;
|
|
101
|
+
return [Math.cos(theta) * radius, vertical, Math.sin(theta) * radius, 1.6 + Math.min(9, Math.sqrt(row[3]) * .055), index];
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
function dependencyPosition(seed, packageIndex) {
|
|
105
|
+
const anchor = packageAnchors[packageIndex] || [0, 0, 0, 2];
|
|
106
|
+
const radius = Math.min(anchor[3], -anchor[3] * .34 * Math.log(Math.max(1e-5, 1 - unit(seed, 18))));
|
|
107
|
+
const theta = unit(seed, 19) * Math.PI * 2;
|
|
108
|
+
const lift = normal(seed, 20) * anchor[3] * .17;
|
|
109
|
+
const tilt = (unit(model.packages[packageIndex]?.[0] || seed, 21) - .5) * .75;
|
|
110
|
+
return [
|
|
111
|
+
anchor[0] + Math.cos(theta) * radius,
|
|
112
|
+
anchor[1] + lift + Math.sin(theta) * radius * tilt,
|
|
113
|
+
anchor[2] + Math.sin(theta) * radius * Math.cos(tilt),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildPoints() {
|
|
118
|
+
const points = [];
|
|
119
|
+
const interactivePoints = [];
|
|
120
|
+
const dependencyHubs = [];
|
|
121
|
+
const addPoint = (point, interactive = true) => {
|
|
122
|
+
points.push(point);
|
|
123
|
+
if (interactive && interactiveMode) interactivePoints.push(point);
|
|
124
|
+
if (point.hub) dependencyHubs.push(point);
|
|
125
|
+
};
|
|
126
|
+
model.namespaces.forEach((row, index) => {
|
|
127
|
+
const category = row[3] === 1 ? "tests" : "core";
|
|
128
|
+
const values = row.slice(4, 10);
|
|
129
|
+
const rubyCounts = row.slice(10, 14);
|
|
130
|
+
const point = { category, seed: row[0], position: category === "tests" ? testPosition(row[0]) : corePosition(row[0]), signal: weightedSignal(normalizedSignals(values), category), base: category === "core" ? .82 : .68 };
|
|
131
|
+
if (interactiveMode) Object.assign(point, { name: model.namespaceNames[index], kind: row[2] === 0 ? "Class" : "Module", rubyCounts, instanceVariableCount: row[14] || 0, values });
|
|
132
|
+
addPoint(point);
|
|
133
|
+
});
|
|
134
|
+
model.dependencyStars.forEach(row => {
|
|
135
|
+
const values = row.slice(2, 8);
|
|
136
|
+
addPoint({ category: "dependencies", packageIndex: row[1], seed: row[0], position: dependencyPosition(row[0], row[1]), signal: weightedSignal(normalizedSignals(values), "dependencies"), base: .45 }, false);
|
|
137
|
+
});
|
|
138
|
+
packageAnchors.forEach((anchor, index) => {
|
|
139
|
+
const packageRow = model.packages[index];
|
|
140
|
+
const rubyCounts = packageRow.slice(4, 8);
|
|
141
|
+
const visualValues = [0, packageRow[3], 0, 0, 0, 0];
|
|
142
|
+
const point = { category: "dependencies", packageIndex: index, seed: packageRow[0], position: anchor.slice(0, 3), signal: weightedSignal(normalizedSignals(visualValues), "dependencies"), base: 1.8, hub: true };
|
|
143
|
+
if (interactiveMode) Object.assign(point, { name: model.packageNames[index], packageRole: packageRow[1] === 0 ? "Direct dependency" : "Transitive dependency", packageLocation: packageRow[2] === 0 ? "Workspace package" : "External gem", rubyCounts });
|
|
144
|
+
addPoint(point);
|
|
145
|
+
});
|
|
146
|
+
return { points, interactivePoints, dependencyHubs };
|
|
147
|
+
}
|
|
148
|
+
const { points, interactivePoints, dependencyHubs } = buildPoints();
|
|
149
|
+
function showcasePointSample() {
|
|
150
|
+
if (!showcaseMode || points.length <= SHOWCASE_POINT_LIMIT) return points;
|
|
151
|
+
const hubs = points.filter(point => point.hub);
|
|
152
|
+
const rank = point => [hash(point.seed, 73), point.seed, point];
|
|
153
|
+
if (hubs.length >= SHOWCASE_POINT_LIMIT) {
|
|
154
|
+
return hubs.map(rank)
|
|
155
|
+
.sort((left, right) => left[0] - right[0] || left[1] - right[1])
|
|
156
|
+
.slice(0, SHOWCASE_POINT_LIMIT)
|
|
157
|
+
.map(candidate => candidate[2]);
|
|
158
|
+
}
|
|
159
|
+
const available = Math.max(0, SHOWCASE_POINT_LIMIT - hubs.length);
|
|
160
|
+
const candidates = points.filter(point => !point.hub).map(rank);
|
|
161
|
+
candidates.sort((left, right) => left[0] - right[0] || left[1] - right[1]);
|
|
162
|
+
return candidates.slice(0, available).map(candidate => candidate[2]).concat(hubs);
|
|
163
|
+
}
|
|
164
|
+
const renderPoints = showcasePointSample();
|
|
165
|
+
|
|
166
|
+
function createShowcaseRenderer() {
|
|
167
|
+
const liveCanvas = document.createElement("canvas");
|
|
168
|
+
liveCanvas.id = "showcase-cosmos";
|
|
169
|
+
liveCanvas.setAttribute("role", "img");
|
|
170
|
+
liveCanvas.setAttribute("aria-label", canvas.getAttribute("aria-label") || "Autonomous stellar artwork of a Ruby codebase.");
|
|
171
|
+
canvas.insertAdjacentElement("afterend", liveCanvas);
|
|
172
|
+
const gl = liveCanvas.getContext("webgl2", {
|
|
173
|
+
alpha: false,
|
|
174
|
+
antialias: true,
|
|
175
|
+
depth: false,
|
|
176
|
+
desynchronized: true,
|
|
177
|
+
powerPreference: "high-performance",
|
|
178
|
+
preserveDrawingBuffer: false,
|
|
179
|
+
});
|
|
180
|
+
if (!gl) {
|
|
181
|
+
liveCanvas.remove();
|
|
182
|
+
document.documentElement.dataset.showcaseRenderer = "canvas2d-fallback";
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const compileShader = (type, source) => {
|
|
187
|
+
const shader = gl.createShader(type);
|
|
188
|
+
gl.shaderSource(shader, source);
|
|
189
|
+
gl.compileShader(shader);
|
|
190
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
191
|
+
const message = gl.getShaderInfoLog(shader) || "Unknown WebGL shader error";
|
|
192
|
+
gl.deleteShader(shader);
|
|
193
|
+
throw new Error(message);
|
|
194
|
+
}
|
|
195
|
+
return shader;
|
|
196
|
+
};
|
|
197
|
+
const createProgram = (vertexSource, fragmentSource) => {
|
|
198
|
+
const program = gl.createProgram();
|
|
199
|
+
const vertex = compileShader(gl.VERTEX_SHADER, vertexSource);
|
|
200
|
+
const fragment = compileShader(gl.FRAGMENT_SHADER, fragmentSource);
|
|
201
|
+
gl.attachShader(program, vertex);
|
|
202
|
+
gl.attachShader(program, fragment);
|
|
203
|
+
gl.linkProgram(program);
|
|
204
|
+
gl.deleteShader(vertex);
|
|
205
|
+
gl.deleteShader(fragment);
|
|
206
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
207
|
+
const message = gl.getProgramInfoLog(program) || "Unknown WebGL program error";
|
|
208
|
+
gl.deleteProgram(program);
|
|
209
|
+
throw new Error(message);
|
|
210
|
+
}
|
|
211
|
+
return program;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const backgroundProgram = createProgram(`#version 300 es
|
|
215
|
+
precision highp float;
|
|
216
|
+
const vec2 POSITIONS[3] = vec2[3](vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
|
|
217
|
+
void main() { gl_Position = vec4(POSITIONS[gl_VertexID], 0.0, 1.0); }
|
|
218
|
+
`, `#version 300 es
|
|
219
|
+
precision highp float;
|
|
220
|
+
uniform vec2 u_resolution;
|
|
221
|
+
uniform vec2 u_center;
|
|
222
|
+
uniform float u_backgroundGlow;
|
|
223
|
+
out vec4 outColor;
|
|
224
|
+
void main() {
|
|
225
|
+
vec2 pixel = vec2(gl_FragCoord.x, u_resolution.y - gl_FragCoord.y);
|
|
226
|
+
float radius = max(u_resolution.x, u_resolution.y) * 0.72;
|
|
227
|
+
float distanceMix = clamp(distance(pixel, u_center) / radius, 0.0, 1.0);
|
|
228
|
+
vec3 base = vec3(3.0, 4.0, 10.0) / 255.0;
|
|
229
|
+
vec3 source = mix(vec3(30.0, 16.0, 45.0) / 255.0, vec3(0.0), distanceMix);
|
|
230
|
+
float centerAlpha = min(0.5, 0.18 * u_backgroundGlow / 100.0);
|
|
231
|
+
float alpha = mix(centerAlpha, 0.6, distanceMix);
|
|
232
|
+
outColor = vec4(mix(base, source, alpha), 1.0);
|
|
233
|
+
}
|
|
234
|
+
`);
|
|
235
|
+
|
|
236
|
+
const pointProgram = createProgram(`#version 300 es
|
|
237
|
+
precision highp float;
|
|
238
|
+
layout(location = 0) in vec3 a_position;
|
|
239
|
+
layout(location = 1) in float a_sizeFactor;
|
|
240
|
+
layout(location = 2) in float a_alpha;
|
|
241
|
+
layout(location = 3) in float a_category;
|
|
242
|
+
layout(location = 4) in float a_maxSize;
|
|
243
|
+
uniform vec2 u_resolution;
|
|
244
|
+
uniform vec2 u_center;
|
|
245
|
+
uniform float u_yaw;
|
|
246
|
+
uniform float u_pitch;
|
|
247
|
+
uniform float u_zoom;
|
|
248
|
+
uniform float u_brightness;
|
|
249
|
+
uniform float u_glow;
|
|
250
|
+
uniform float u_deepDetail;
|
|
251
|
+
uniform int u_pass;
|
|
252
|
+
out vec3 v_colour;
|
|
253
|
+
out float v_alpha;
|
|
254
|
+
out float v_radius;
|
|
255
|
+
|
|
256
|
+
void hidePoint() {
|
|
257
|
+
gl_Position = vec4(2.0, 2.0, 0.0, 1.0);
|
|
258
|
+
gl_PointSize = 1.0;
|
|
259
|
+
v_colour = vec3(0.0);
|
|
260
|
+
v_alpha = 0.0;
|
|
261
|
+
v_radius = 0.5;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
void main() {
|
|
265
|
+
float cy = cos(u_yaw);
|
|
266
|
+
float sy = sin(u_yaw);
|
|
267
|
+
float cp = cos(u_pitch);
|
|
268
|
+
float sp = sin(u_pitch);
|
|
269
|
+
float x1 = a_position.x * cy - a_position.z * sy;
|
|
270
|
+
float z1 = a_position.x * sy + a_position.z * cy;
|
|
271
|
+
float y2 = a_position.y * cp - z1 * sp;
|
|
272
|
+
float z2 = a_position.y * sp + z1 * cp;
|
|
273
|
+
float depth = 270.0 - z2;
|
|
274
|
+
if (depth <= 35.0) { hidePoint(); return; }
|
|
275
|
+
|
|
276
|
+
float perspective = 440.0 / depth * u_zoom;
|
|
277
|
+
vec2 screen = u_center + vec2(x1, y2) * perspective;
|
|
278
|
+
if (screen.x < -20.0 || screen.x > u_resolution.x + 20.0 || screen.y < -20.0 || screen.y > u_resolution.y + 20.0) {
|
|
279
|
+
hidePoint();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
float size = clamp(a_sizeFactor * perspective, 0.35, a_maxSize);
|
|
284
|
+
float visibleAlpha = clamp(a_alpha * u_brightness / 100.0, 0.0, 1.0);
|
|
285
|
+
float radius = size;
|
|
286
|
+
float alpha = visibleAlpha;
|
|
287
|
+
vec3 colour = a_category < 0.5
|
|
288
|
+
? vec3(244.0, 82.0, 132.0) / 255.0
|
|
289
|
+
: (a_category < 1.5 ? vec3(87.0, 204.0, 255.0) / 255.0 : vec3(255.0, 184.0, 77.0) / 255.0);
|
|
290
|
+
|
|
291
|
+
if (u_pass == 0) {
|
|
292
|
+
if (size <= 1.35 || u_glow <= 0.0) { hidePoint(); return; }
|
|
293
|
+
float glowScale = (3.4 - u_deepDetail * 1.3) * (0.75 + 0.25 * u_glow / 100.0);
|
|
294
|
+
radius = size * glowScale;
|
|
295
|
+
alpha = min(1.0, visibleAlpha * 0.055 * u_glow / 100.0);
|
|
296
|
+
} else if (u_pass == 1) {
|
|
297
|
+
radius = size < 0.85 ? 0.5 : size;
|
|
298
|
+
} else {
|
|
299
|
+
if (size <= 1.1) { hidePoint(); return; }
|
|
300
|
+
radius = max(0.45 + u_deepDetail * 0.25, size * (0.24 + u_deepDetail * 0.06));
|
|
301
|
+
alpha = min(0.9, visibleAlpha * 1.25);
|
|
302
|
+
colour = vec3(255.0, 248.0, 244.0) / 255.0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
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);
|
|
306
|
+
gl_PointSize = max(1.0, radius * 2.0);
|
|
307
|
+
v_colour = colour;
|
|
308
|
+
v_alpha = alpha;
|
|
309
|
+
v_radius = radius;
|
|
310
|
+
}
|
|
311
|
+
`, `#version 300 es
|
|
312
|
+
precision highp float;
|
|
313
|
+
in vec3 v_colour;
|
|
314
|
+
in float v_alpha;
|
|
315
|
+
in float v_radius;
|
|
316
|
+
out vec4 outColor;
|
|
317
|
+
void main() {
|
|
318
|
+
if (v_alpha <= 0.0) discard;
|
|
319
|
+
float radial = length(gl_PointCoord - vec2(0.5)) * 2.0;
|
|
320
|
+
float feather = min(1.0, 1.0 / max(v_radius, 1.0));
|
|
321
|
+
float coverage = 1.0 - smoothstep(1.0 - feather, 1.0, radial);
|
|
322
|
+
if (coverage <= 0.0) discard;
|
|
323
|
+
float contribution = v_alpha * coverage;
|
|
324
|
+
outColor = vec4(v_colour * contribution, contribution);
|
|
325
|
+
}
|
|
326
|
+
`);
|
|
327
|
+
|
|
328
|
+
const pointData = new Float32Array(renderPoints.length * 7);
|
|
329
|
+
const categoryIndex = { core: 0, tests: 1, dependencies: 2 };
|
|
330
|
+
renderPoints.forEach((point, index) => {
|
|
331
|
+
const offset = index * 7;
|
|
332
|
+
pointData[offset] = point.position[0];
|
|
333
|
+
pointData[offset + 1] = point.position[1];
|
|
334
|
+
pointData[offset + 2] = point.position[2];
|
|
335
|
+
pointData[offset + 3] = point.base * (.62 + point.signal * .46);
|
|
336
|
+
pointData[offset + 4] = clamp(.14 + point.signal * .105, .12, point.hub ? .86 : .7);
|
|
337
|
+
pointData[offset + 5] = categoryIndex[point.category];
|
|
338
|
+
pointData[offset + 6] = point.hub ? 5.2 : 3.2;
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const pointVao = gl.createVertexArray();
|
|
342
|
+
const pointBuffer = gl.createBuffer();
|
|
343
|
+
gl.bindVertexArray(pointVao);
|
|
344
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, pointBuffer);
|
|
345
|
+
gl.bufferData(gl.ARRAY_BUFFER, pointData, gl.STATIC_DRAW);
|
|
346
|
+
const stride = 7 * Float32Array.BYTES_PER_ELEMENT;
|
|
347
|
+
[[0, 3, 0], [1, 1, 3], [2, 1, 4], [3, 1, 5], [4, 1, 6]].forEach(([location, size, offset]) => {
|
|
348
|
+
gl.enableVertexAttribArray(location);
|
|
349
|
+
gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, offset * Float32Array.BYTES_PER_ELEMENT);
|
|
350
|
+
});
|
|
351
|
+
gl.bindVertexArray(null);
|
|
352
|
+
|
|
353
|
+
const backgroundUniforms = {
|
|
354
|
+
resolution: gl.getUniformLocation(backgroundProgram, "u_resolution"),
|
|
355
|
+
center: gl.getUniformLocation(backgroundProgram, "u_center"),
|
|
356
|
+
backgroundGlow: gl.getUniformLocation(backgroundProgram, "u_backgroundGlow"),
|
|
357
|
+
};
|
|
358
|
+
const pointUniforms = {
|
|
359
|
+
resolution: gl.getUniformLocation(pointProgram, "u_resolution"),
|
|
360
|
+
center: gl.getUniformLocation(pointProgram, "u_center"),
|
|
361
|
+
yaw: gl.getUniformLocation(pointProgram, "u_yaw"),
|
|
362
|
+
pitch: gl.getUniformLocation(pointProgram, "u_pitch"),
|
|
363
|
+
zoom: gl.getUniformLocation(pointProgram, "u_zoom"),
|
|
364
|
+
brightness: gl.getUniformLocation(pointProgram, "u_brightness"),
|
|
365
|
+
glow: gl.getUniformLocation(pointProgram, "u_glow"),
|
|
366
|
+
deepDetail: gl.getUniformLocation(pointProgram, "u_deepDetail"),
|
|
367
|
+
pass: gl.getUniformLocation(pointProgram, "u_pass"),
|
|
368
|
+
};
|
|
369
|
+
const pointSizeRange = gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE);
|
|
370
|
+
document.documentElement.dataset.showcaseRenderer = "webgl2";
|
|
371
|
+
document.documentElement.dataset.pointSizeRange = `${pointSizeRange[0]},${pointSizeRange[1]}`;
|
|
372
|
+
canvas.style.display = "none";
|
|
373
|
+
|
|
374
|
+
return Object.freeze({
|
|
375
|
+
resize(viewportWidth, viewportHeight) {
|
|
376
|
+
liveCanvas.width = Math.round(viewportWidth);
|
|
377
|
+
liveCanvas.height = Math.round(viewportHeight);
|
|
378
|
+
gl.viewport(0, 0, liveCanvas.width, liveCanvas.height);
|
|
379
|
+
},
|
|
380
|
+
render() {
|
|
381
|
+
const deepDetail = clamp(Math.log2(Math.max(1, zoom)) / 5, 0, 1);
|
|
382
|
+
gl.disable(gl.BLEND);
|
|
383
|
+
gl.useProgram(backgroundProgram);
|
|
384
|
+
gl.bindVertexArray(null);
|
|
385
|
+
gl.uniform2f(backgroundUniforms.resolution, width, height);
|
|
386
|
+
gl.uniform2f(backgroundUniforms.center, sceneCenterX, sceneCenterY);
|
|
387
|
+
gl.uniform1f(backgroundUniforms.backgroundGlow, SHOWCASE_PRESET.backgroundGlowPercent);
|
|
388
|
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
|
389
|
+
|
|
390
|
+
gl.enable(gl.BLEND);
|
|
391
|
+
gl.blendEquation(gl.FUNC_ADD);
|
|
392
|
+
gl.blendFunc(gl.ONE, gl.ONE);
|
|
393
|
+
gl.useProgram(pointProgram);
|
|
394
|
+
gl.bindVertexArray(pointVao);
|
|
395
|
+
gl.uniform2f(pointUniforms.resolution, width, height);
|
|
396
|
+
gl.uniform2f(pointUniforms.center, sceneCenterX, sceneCenterY);
|
|
397
|
+
gl.uniform1f(pointUniforms.yaw, yaw);
|
|
398
|
+
gl.uniform1f(pointUniforms.pitch, pitch);
|
|
399
|
+
gl.uniform1f(pointUniforms.zoom, zoom);
|
|
400
|
+
gl.uniform1f(pointUniforms.brightness, SHOWCASE_PRESET.starBrightnessPercent);
|
|
401
|
+
gl.uniform1f(pointUniforms.glow, SHOWCASE_PRESET.pointGlowPercent);
|
|
402
|
+
gl.uniform1f(pointUniforms.deepDetail, deepDetail);
|
|
403
|
+
for (let pass = 0; pass < 3; pass += 1) {
|
|
404
|
+
gl.uniform1i(pointUniforms.pass, pass);
|
|
405
|
+
gl.drawArrays(gl.POINTS, 0, renderPoints.length);
|
|
406
|
+
}
|
|
407
|
+
gl.bindVertexArray(null);
|
|
408
|
+
gl.disable(gl.BLEND);
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (showcaseMode) {
|
|
414
|
+
try {
|
|
415
|
+
showcaseRenderer = createShowcaseRenderer();
|
|
416
|
+
} catch (error) {
|
|
417
|
+
document.getElementById("showcase-cosmos")?.remove();
|
|
418
|
+
canvas.style.display = "";
|
|
419
|
+
document.documentElement.dataset.showcaseRenderer = "canvas2d-fallback";
|
|
420
|
+
document.documentElement.dataset.showcaseRendererError = error.message;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const totals = model.totals;
|
|
424
|
+
const renderedDependencyStars = model.totals.renderedDependencyStars;
|
|
425
|
+
const directGemCount = model.packages.filter(row => row[1] === 0).length;
|
|
426
|
+
const transitiveGemCount = totals.packages - directGemCount;
|
|
427
|
+
const allRubyMetricIndexes = [0, 1, 2, 3];
|
|
428
|
+
const testRubyMetricIndexes = [0, 2];
|
|
429
|
+
const dependencyRubyCounts = model.packages.reduce(
|
|
430
|
+
(counts, row) => counts.map((count, index) => count + Number(row[index + 4] || 0)),
|
|
431
|
+
[0, 0, 0, 0],
|
|
432
|
+
);
|
|
433
|
+
const categoryMeta = {
|
|
434
|
+
core: { title: "Core code", rubyCounts: model.categoryStats.core, metricIndexes: allRubyMetricIndexes, focusZoom: 2.8 },
|
|
435
|
+
tests: { title: "Tests", rubyCounts: model.categoryStats.tests, metricIndexes: testRubyMetricIndexes, focusZoom: 1.35 },
|
|
436
|
+
dependencies: { title: "Gems", summary: `${totals.packages.toLocaleString()} dependency gems`, rubyCounts: dependencyRubyCounts, metricIndexes: allRubyMetricIndexes, note: `${directGemCount.toLocaleString()} direct · ${transitiveGemCount.toLocaleString()} transitive`, focusZoom: .72 },
|
|
437
|
+
};
|
|
438
|
+
if (showcaseMode) {
|
|
439
|
+
model.namespaces = [];
|
|
440
|
+
model.packages = [];
|
|
441
|
+
model.dependencyStars = [];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function applyCameraTarget(target) {
|
|
445
|
+
yaw = target.yaw;
|
|
446
|
+
pitch = target.pitch;
|
|
447
|
+
zoom = clamp(target.zoom, MIN_ZOOM, MAX_ZOOM);
|
|
448
|
+
panX = target.panX;
|
|
449
|
+
panY = target.panY;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function cameraTargetForPoint(point, targetZoom = point.hub ? 4 : point.category === "dependencies" ? 5 : 7) {
|
|
453
|
+
const [x, y, z] = point.position;
|
|
454
|
+
return {
|
|
455
|
+
yaw: Math.atan2(x, z),
|
|
456
|
+
pitch: clamp(Math.atan2(y, Math.hypot(x, z)), -.95, .95),
|
|
457
|
+
zoom: targetZoom,
|
|
458
|
+
panX: 0,
|
|
459
|
+
panY: 0,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function topDownCameraTargetForPoint(point, targetZoom = point.hub ? 4 : point.category === "dependencies" ? 5 : 7) {
|
|
464
|
+
const targetYaw = yaw;
|
|
465
|
+
const [x, y, z] = point.position;
|
|
466
|
+
const cy = Math.cos(targetYaw), sy = Math.sin(targetYaw);
|
|
467
|
+
const cp = Math.cos(TOP_DOWN_PITCH), sp = Math.sin(TOP_DOWN_PITCH);
|
|
468
|
+
const x1 = x * cy - z * sy;
|
|
469
|
+
const z1 = x * sy + z * cy;
|
|
470
|
+
const y2 = y * cp - z1 * sp;
|
|
471
|
+
const z2 = y * sp + z1 * cp;
|
|
472
|
+
const perspective = 440 / (270 - z2) * targetZoom;
|
|
473
|
+
return {
|
|
474
|
+
yaw: targetYaw,
|
|
475
|
+
pitch: TOP_DOWN_PITCH,
|
|
476
|
+
zoom: targetZoom,
|
|
477
|
+
panX: -x1 * perspective,
|
|
478
|
+
panY: -y2 * perspective,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function cancelCameraFlight() {
|
|
483
|
+
if (!cameraFlight) return false;
|
|
484
|
+
cameraFlight = null;
|
|
485
|
+
canvas.removeAttribute("aria-busy");
|
|
486
|
+
requestRender();
|
|
487
|
+
return true;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function completeCameraFlight() {
|
|
491
|
+
if (!cameraFlight) return false;
|
|
492
|
+
const target = cameraFlight.target;
|
|
493
|
+
cameraFlight = null;
|
|
494
|
+
applyCameraTarget(target);
|
|
495
|
+
canvas.removeAttribute("aria-busy");
|
|
496
|
+
requestRender();
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function smootherstep(progress) {
|
|
501
|
+
return progress * progress * progress * (progress * (progress * 6 - 15) + 10);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function flyCamera(target) {
|
|
505
|
+
cancelCameraFlight();
|
|
506
|
+
cancelPendingHover();
|
|
507
|
+
const yawDelta = Math.atan2(Math.sin(target.yaw - yaw), Math.cos(target.yaw - yaw));
|
|
508
|
+
const resolvedTarget = { ...target, yaw: yaw + yawDelta };
|
|
509
|
+
tooltip.hidden = true;
|
|
510
|
+
if (reducedMotionQuery.matches) {
|
|
511
|
+
applyCameraTarget(resolvedTarget);
|
|
512
|
+
canvas.removeAttribute("aria-busy");
|
|
513
|
+
requestRender();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const angularDistance = Math.hypot(yawDelta, resolvedTarget.pitch - pitch);
|
|
518
|
+
const zoomStops = Math.abs(Math.log2(resolvedTarget.zoom / zoom));
|
|
519
|
+
const panDistance = Math.hypot(resolvedTarget.panX - panX, resolvedTarget.panY - panY);
|
|
520
|
+
if (angularDistance < .001 && zoomStops < .01 && panDistance < .5) {
|
|
521
|
+
applyCameraTarget(resolvedTarget);
|
|
522
|
+
canvas.removeAttribute("aria-busy");
|
|
523
|
+
requestRender();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const minimumZoom = Math.min(zoom, resolvedTarget.zoom);
|
|
527
|
+
const cruiseZoom = Math.min(2.5, minimumZoom);
|
|
528
|
+
cameraFlight = {
|
|
529
|
+
start: { yaw, pitch, zoom, panX, panY },
|
|
530
|
+
target: resolvedTarget,
|
|
531
|
+
startTime: null,
|
|
532
|
+
duration: clamp(440 + angularDistance * 60 + zoomStops * 20, 440, 540),
|
|
533
|
+
pullback: angularDistance > .35 && minimumZoom > cruiseZoom
|
|
534
|
+
? Math.log(minimumZoom / cruiseZoom) * .72
|
|
535
|
+
: 0,
|
|
536
|
+
};
|
|
537
|
+
canvas.setAttribute("aria-busy", "true");
|
|
538
|
+
requestRender();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function updateCameraFlight(timestamp) {
|
|
542
|
+
if (!cameraFlight) return;
|
|
543
|
+
if (cameraFlight.startTime === null) cameraFlight.startTime = timestamp;
|
|
544
|
+
const progress = clamp((timestamp - cameraFlight.startTime) / cameraFlight.duration, 0, 1);
|
|
545
|
+
const eased = smootherstep(progress);
|
|
546
|
+
const { start, target } = cameraFlight;
|
|
547
|
+
yaw = start.yaw + (target.yaw - start.yaw) * eased;
|
|
548
|
+
pitch = start.pitch + (target.pitch - start.pitch) * eased;
|
|
549
|
+
panX = start.panX + (target.panX - start.panX) * eased;
|
|
550
|
+
panY = start.panY + (target.panY - start.panY) * eased;
|
|
551
|
+
const pullback = Math.sin(Math.PI * progress) ** 2 * cameraFlight.pullback;
|
|
552
|
+
zoom = Math.exp(Math.log(start.zoom) + (Math.log(target.zoom) - Math.log(start.zoom)) * eased - pullback);
|
|
553
|
+
if (progress >= 1) {
|
|
554
|
+
cameraFlight = null;
|
|
555
|
+
applyCameraTarget(target);
|
|
556
|
+
canvas.removeAttribute("aria-busy");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function addTooltipMetric(label, value) {
|
|
561
|
+
const term = document.createElement("dt");
|
|
562
|
+
const detail = document.createElement("dd");
|
|
563
|
+
term.textContent = label;
|
|
564
|
+
detail.textContent = Number(value).toLocaleString();
|
|
565
|
+
tooltipMetrics.append(term, detail);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function addRubyMetrics(rubyCounts, metricIndexes) {
|
|
569
|
+
metricIndexes.forEach(index => addTooltipMetric(rubyMetricLabels[index], rubyCounts[index] || 0));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function addCoreTooltipMetrics(point) {
|
|
573
|
+
addTooltipMetric("Ancestors", point.values[0]);
|
|
574
|
+
addTooltipMetric("Descendants", point.values[3]);
|
|
575
|
+
if (point.kind === "Class") addTooltipMetric("Instance variables", point.instanceVariableCount);
|
|
576
|
+
addTooltipMetric("Methods", point.rubyCounts[2]);
|
|
577
|
+
addTooltipMetric("References", point.values[4]);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function updateTooltipContent(point) {
|
|
581
|
+
tooltipMetrics.textContent = "";
|
|
582
|
+
tooltipCategory.textContent = point.hub ? "Gem" : point.category === "tests" ? "Tests" : "Core code";
|
|
583
|
+
tooltipName.textContent = point.name || "Unnamed Ruby item";
|
|
584
|
+
if (point.hub) {
|
|
585
|
+
const expanded = expandedPackageIndex === point.packageIndex ? " · Expanded gem cloud · Escape to exit" : "";
|
|
586
|
+
tooltipContext.textContent = `${point.packageRole} · ${point.packageLocation}${expanded}`;
|
|
587
|
+
addRubyMetrics(point.rubyCounts, allRubyMetricIndexes);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
tooltipContext.textContent = point.kind;
|
|
591
|
+
if (point.category === "core") addCoreTooltipMetrics(point);
|
|
592
|
+
else addRubyMetrics(point.rubyCounts, testRubyMetricIndexes);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function positionTooltip(point) {
|
|
596
|
+
if (cameraFlight || !point?.screen) { tooltip.hidden = true; return; }
|
|
597
|
+
tooltip.hidden = false;
|
|
598
|
+
const bounds = tooltip.getBoundingClientRect();
|
|
599
|
+
if (bounds.width === 0 || bounds.height === 0) {
|
|
600
|
+
requestAnimationFrame(() => { if (selectedPoint === point) positionTooltip(point); });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
const preferredX = point.screen[0] + 16;
|
|
604
|
+
const leftOfPoint = point.screen[0] - bounds.width - 16;
|
|
605
|
+
const fitsRight = preferredX + bounds.width <= sceneRight - 12;
|
|
606
|
+
const fitsLeft = leftOfPoint >= 12;
|
|
607
|
+
if (fitsRight || fitsLeft) {
|
|
608
|
+
tooltip.style.left = `${fitsRight ? preferredX : leftOfPoint}px`;
|
|
609
|
+
tooltip.style.top = `${clamp(point.screen[1] - bounds.height / 2, 12, sceneBottom - bounds.height - 12)}px`;
|
|
610
|
+
} else {
|
|
611
|
+
tooltip.style.left = `${clamp((sceneRight - bounds.width) / 2, 12, sceneRight - bounds.width - 12)}px`;
|
|
612
|
+
const below = point.screen[1] + 20;
|
|
613
|
+
tooltip.style.top = `${below + bounds.height <= sceneBottom - 12 ? below : Math.max(12, point.screen[1] - bounds.height - 20)}px`;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function selectPoint(point, locked = false) {
|
|
618
|
+
if (locked && selectionLocked && selectedPoint === point) point = null;
|
|
619
|
+
selectedPoint = point;
|
|
620
|
+
selectionLocked = Boolean(point) && locked;
|
|
621
|
+
if (point) updateTooltipContent(point);
|
|
622
|
+
else tooltip.hidden = true;
|
|
623
|
+
requestRender();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function hitTest(x, y) {
|
|
627
|
+
let nearest = null;
|
|
628
|
+
let nearestDistance = Infinity;
|
|
629
|
+
for (const point of interactivePoints) {
|
|
630
|
+
if (!point.screen) continue;
|
|
631
|
+
if (focusedCategory && point.category !== focusedCategory) continue;
|
|
632
|
+
if (expandedPackageIndex !== null && (point.category !== "dependencies" || point.packageIndex !== expandedPackageIndex)) continue;
|
|
633
|
+
const dx = point.screen[0] - x;
|
|
634
|
+
const dy = point.screen[1] - y;
|
|
635
|
+
const distance = Math.hypot(dx, dy);
|
|
636
|
+
const radius = Math.max(8, point.screen[2] + 4);
|
|
637
|
+
if (distance <= radius && distance < nearestDistance) {
|
|
638
|
+
nearest = point;
|
|
639
|
+
nearestDistance = distance;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return nearest;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function dependencyPackageAt(x, y, exact = hitTest(x, y)) {
|
|
646
|
+
if (exact) return exact.hub ? exact : null;
|
|
647
|
+
|
|
648
|
+
let nearestHub = null;
|
|
649
|
+
let nearestRatio = Infinity;
|
|
650
|
+
for (const point of dependencyHubs) {
|
|
651
|
+
if (!point.screen || !point.cloudScreenRadius) continue;
|
|
652
|
+
if (expandedPackageIndex !== null && point.packageIndex !== expandedPackageIndex) continue;
|
|
653
|
+
const distance = Math.hypot(point.screen[0] - x, point.screen[1] - y);
|
|
654
|
+
const ratio = distance / point.cloudScreenRadius;
|
|
655
|
+
if (ratio <= 1 && ratio < nearestRatio) {
|
|
656
|
+
nearestHub = point;
|
|
657
|
+
nearestRatio = ratio;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return nearestHub;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function hoverTargetAt(x, y) {
|
|
664
|
+
const exact = hitTest(x, y);
|
|
665
|
+
return exact || dependencyPackageAt(x, y, exact);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function queueHover(x, y) {
|
|
669
|
+
if (cameraFlight || selectionLocked || dragging || pointers.size > 0) return;
|
|
670
|
+
pendingHover = [x, y];
|
|
671
|
+
if (hoverFrame) return;
|
|
672
|
+
hoverFrame = requestAnimationFrame(() => {
|
|
673
|
+
hoverFrame = 0;
|
|
674
|
+
if (!pendingHover) return;
|
|
675
|
+
const point = hoverTargetAt(pendingHover[0], pendingHover[1]);
|
|
676
|
+
pendingHover = null;
|
|
677
|
+
if (point !== selectedPoint) selectPoint(point);
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function cancelPendingHover() {
|
|
682
|
+
pendingHover = null;
|
|
683
|
+
if (hoverFrame) cancelAnimationFrame(hoverFrame);
|
|
684
|
+
hoverFrame = 0;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function setPanelCollapsed(collapsed) {
|
|
688
|
+
if (collapsed && panelBody.contains(document.activeElement)) panelToggle.focus();
|
|
689
|
+
panel.classList.toggle("is-collapsed", collapsed);
|
|
690
|
+
panelBody.hidden = collapsed;
|
|
691
|
+
panelToggle.setAttribute("aria-expanded", String(!collapsed));
|
|
692
|
+
panelToggle.textContent = collapsed ? "Show" : "Hide";
|
|
693
|
+
updateSceneViewport();
|
|
694
|
+
requestRender();
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function maxPoint(candidates, valueFor) {
|
|
698
|
+
let best = null;
|
|
699
|
+
let bestValue = -Infinity;
|
|
700
|
+
for (const point of candidates) {
|
|
701
|
+
const value = Number(valueFor(point)) || 0;
|
|
702
|
+
if (value > bestValue || (value === bestValue && point.name.localeCompare(best?.name || "") < 0)) {
|
|
703
|
+
best = point;
|
|
704
|
+
bestValue = value;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return best ? { point: best, value: bestValue } : null;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function factsFor(category) {
|
|
711
|
+
if (category === "dependencies") {
|
|
712
|
+
const gemMetricFact = (title, index) => {
|
|
713
|
+
const result = maxPoint(dependencyHubs, point => point.rubyCounts[index]);
|
|
714
|
+
return result && result.value > 0 && { title, ...result };
|
|
715
|
+
};
|
|
716
|
+
return [
|
|
717
|
+
gemMetricFact("Most classes", 0),
|
|
718
|
+
gemMetricFact("Most modules", 1),
|
|
719
|
+
gemMetricFact("Most methods", 2),
|
|
720
|
+
gemMetricFact("Most constants", 3),
|
|
721
|
+
].filter(Boolean);
|
|
722
|
+
}
|
|
723
|
+
const namespacePoints = interactivePoints.filter(point => point.category === category && !point.hub && !excludedTriviaNames.has(point.name));
|
|
724
|
+
const metricFact = (title, index) => {
|
|
725
|
+
const result = maxPoint(namespacePoints, point => point.rubyCounts[index]);
|
|
726
|
+
return result && result.value > 0 && { title, ...result };
|
|
727
|
+
};
|
|
728
|
+
return category === "tests"
|
|
729
|
+
? [metricFact("Most methods", 2)].filter(Boolean)
|
|
730
|
+
: [metricFact("Most methods", 2), metricFact("Most constants", 3)].filter(Boolean);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
function createRubyBreakdown(title, rubyCounts, metricIndexes) {
|
|
734
|
+
const breakdown = document.createElement("dl");
|
|
735
|
+
breakdown.className = "ruby-breakdown";
|
|
736
|
+
breakdown.setAttribute("aria-label", `${title} breakdown`);
|
|
737
|
+
for (const index of metricIndexes) {
|
|
738
|
+
const metric = document.createElement("div");
|
|
739
|
+
const term = document.createElement("dt");
|
|
740
|
+
const detail = document.createElement("dd");
|
|
741
|
+
term.textContent = rubyMetricLabels[index];
|
|
742
|
+
detail.textContent = Number(rubyCounts[index] || 0).toLocaleString();
|
|
743
|
+
metric.append(term, detail);
|
|
744
|
+
breakdown.append(metric);
|
|
745
|
+
}
|
|
746
|
+
return breakdown;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function clearActiveFact() {
|
|
750
|
+
if (activeFactButton) activeFactButton.setAttribute("aria-pressed", "false");
|
|
751
|
+
activeFactButton = null;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function clearCategoryFocus() {
|
|
755
|
+
if (focusedCategory && focusButtons[focusedCategory]) {
|
|
756
|
+
focusButtons[focusedCategory].setAttribute("aria-pressed", "false");
|
|
757
|
+
focusButtons[focusedCategory].textContent = "Focus";
|
|
758
|
+
}
|
|
759
|
+
focusedCategory = null;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function clearExpandedPackage() {
|
|
763
|
+
expandedPackageIndex = null;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function clearExplorationFocus() {
|
|
767
|
+
cancelCameraFlight();
|
|
768
|
+
clearActiveFact();
|
|
769
|
+
clearCategoryFocus();
|
|
770
|
+
clearExpandedPackage();
|
|
771
|
+
selectPoint(null);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
function setCategoryVisible(category, visible) {
|
|
775
|
+
visibleCategories[category] = visible;
|
|
776
|
+
if (visibilityInputs[category]) visibilityInputs[category].checked = visible;
|
|
777
|
+
if (!visible && (selectedPoint?.category === category || focusedCategory === category)) clearExplorationFocus();
|
|
778
|
+
requestRender();
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function focusCategory(category) {
|
|
782
|
+
if (focusedCategory === category) {
|
|
783
|
+
clearExplorationFocus();
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
setCategoryVisible(category, true);
|
|
787
|
+
clearActiveFact();
|
|
788
|
+
clearExpandedPackage();
|
|
789
|
+
selectPoint(null);
|
|
790
|
+
clearCategoryFocus();
|
|
791
|
+
focusedCategory = category;
|
|
792
|
+
focusButtons[category].setAttribute("aria-pressed", "true");
|
|
793
|
+
focusButtons[category].textContent = "Focused";
|
|
794
|
+
setDrifting(false);
|
|
795
|
+
flyCamera({ yaw: -.36, pitch: .34, zoom: categoryMeta[category].focusZoom, panX: 0, panY: 0 });
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function focusPoint(point, button) {
|
|
799
|
+
if (activeFactButton === button) {
|
|
800
|
+
clearExplorationFocus();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (point.hub) {
|
|
804
|
+
focusDependencyPackage(point.packageIndex, button);
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
setCategoryVisible(point.category, true);
|
|
808
|
+
clearActiveFact();
|
|
809
|
+
clearExpandedPackage();
|
|
810
|
+
activeFactButton = button;
|
|
811
|
+
activeFactButton.setAttribute("aria-pressed", "true");
|
|
812
|
+
clearCategoryFocus();
|
|
813
|
+
setDrifting(false);
|
|
814
|
+
selectedPoint = null;
|
|
815
|
+
selectionLocked = false;
|
|
816
|
+
selectPoint(point, true);
|
|
817
|
+
flyCamera(topDownCameraTargetForPoint(point));
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function focusDependencyPackage(packageIndex, button = null) {
|
|
821
|
+
const hub = dependencyHubs.find(point => point.packageIndex === packageIndex);
|
|
822
|
+
if (!hub) return false;
|
|
823
|
+
|
|
824
|
+
setCategoryVisible("dependencies", true);
|
|
825
|
+
clearActiveFact();
|
|
826
|
+
if (button) {
|
|
827
|
+
activeFactButton = button;
|
|
828
|
+
activeFactButton.setAttribute("aria-pressed", "true");
|
|
829
|
+
}
|
|
830
|
+
clearCategoryFocus();
|
|
831
|
+
expandedPackageIndex = packageIndex;
|
|
832
|
+
setDrifting(false);
|
|
833
|
+
selectedPoint = null;
|
|
834
|
+
selectionLocked = false;
|
|
835
|
+
selectPoint(hub, true);
|
|
836
|
+
flyCamera(button ? topDownCameraTargetForPoint(hub, 4) : cameraTargetForPoint(hub, 4));
|
|
837
|
+
return true;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function createExplorer() {
|
|
841
|
+
const container = document.getElementById("controls");
|
|
842
|
+
container.textContent = "";
|
|
843
|
+
for (const category of ["core", "tests", "dependencies"]) {
|
|
844
|
+
const meta = categoryMeta[category];
|
|
845
|
+
const details = document.createElement("details");
|
|
846
|
+
details.open = category === "core";
|
|
847
|
+
const summary = document.createElement("summary");
|
|
848
|
+
const swatch = document.createElement("span");
|
|
849
|
+
swatch.className = `swatch ${category}`;
|
|
850
|
+
const heading = document.createElement("span");
|
|
851
|
+
heading.className = "section-heading";
|
|
852
|
+
const title = document.createElement("strong");
|
|
853
|
+
title.textContent = meta.title;
|
|
854
|
+
heading.append(title);
|
|
855
|
+
if (meta.summary) {
|
|
856
|
+
const summaryText = document.createElement("small");
|
|
857
|
+
summaryText.textContent = meta.summary;
|
|
858
|
+
heading.append(summaryText);
|
|
859
|
+
}
|
|
860
|
+
summary.append(swatch, heading);
|
|
861
|
+
|
|
862
|
+
const body = document.createElement("div");
|
|
863
|
+
body.className = "section-body";
|
|
864
|
+
const actions = document.createElement("div");
|
|
865
|
+
actions.className = "section-actions";
|
|
866
|
+
const visibility = document.createElement("label");
|
|
867
|
+
visibility.className = "visibility";
|
|
868
|
+
const checkbox = document.createElement("input");
|
|
869
|
+
checkbox.type = "checkbox";
|
|
870
|
+
checkbox.checked = true;
|
|
871
|
+
checkbox.setAttribute("aria-label", `Show ${meta.title}`);
|
|
872
|
+
checkbox.addEventListener("change", () => setCategoryVisible(category, checkbox.checked));
|
|
873
|
+
visibilityInputs[category] = checkbox;
|
|
874
|
+
const visibilityText = document.createElement("span");
|
|
875
|
+
visibilityText.textContent = "Visible";
|
|
876
|
+
visibility.append(checkbox, visibilityText);
|
|
877
|
+
const focus = document.createElement("button");
|
|
878
|
+
focus.type = "button";
|
|
879
|
+
focus.textContent = "Focus";
|
|
880
|
+
focus.setAttribute("aria-label", `Focus ${meta.title}`);
|
|
881
|
+
focus.setAttribute("aria-pressed", "false");
|
|
882
|
+
focusButtons[category] = focus;
|
|
883
|
+
focus.addEventListener("click", () => focusCategory(category));
|
|
884
|
+
actions.append(visibility, focus);
|
|
885
|
+
|
|
886
|
+
const categoryFacts = factsFor(category);
|
|
887
|
+
const facts = document.createElement("div");
|
|
888
|
+
facts.className = "facts";
|
|
889
|
+
for (const fact of categoryFacts) {
|
|
890
|
+
const button = document.createElement("button");
|
|
891
|
+
button.type = "button";
|
|
892
|
+
button.className = "fact";
|
|
893
|
+
button.title = fact.point.name;
|
|
894
|
+
button.setAttribute("aria-pressed", "false");
|
|
895
|
+
const factTitle = document.createElement("span");
|
|
896
|
+
factTitle.className = "fact-title";
|
|
897
|
+
const factTitleText = document.createElement("span");
|
|
898
|
+
factTitleText.textContent = fact.title;
|
|
899
|
+
const factScope = document.createElement("span");
|
|
900
|
+
factScope.className = "fact-scope";
|
|
901
|
+
factScope.textContent = fact.point.hub ? "Gem" : fact.point.kind;
|
|
902
|
+
factTitle.append(factTitleText, factScope);
|
|
903
|
+
const factName = document.createElement("span");
|
|
904
|
+
factName.className = "fact-name";
|
|
905
|
+
factName.textContent = fact.point.name;
|
|
906
|
+
const factValue = document.createElement("span");
|
|
907
|
+
factValue.className = "fact-value";
|
|
908
|
+
factValue.textContent = fact.value.toLocaleString();
|
|
909
|
+
button.append(factTitle, factName, factValue);
|
|
910
|
+
button.addEventListener("click", () => focusPoint(fact.point, button));
|
|
911
|
+
facts.append(button);
|
|
912
|
+
}
|
|
913
|
+
body.append(actions, createRubyBreakdown(meta.title, meta.rubyCounts, meta.metricIndexes));
|
|
914
|
+
if (meta.note) {
|
|
915
|
+
const sectionNote = document.createElement("p");
|
|
916
|
+
sectionNote.className = "section-note";
|
|
917
|
+
sectionNote.textContent = meta.note;
|
|
918
|
+
body.append(sectionNote);
|
|
919
|
+
}
|
|
920
|
+
if (categoryFacts.length) {
|
|
921
|
+
const factLabel = document.createElement("p");
|
|
922
|
+
factLabel.className = "fact-label";
|
|
923
|
+
factLabel.textContent = "Ruby code highlights";
|
|
924
|
+
body.append(factLabel, facts);
|
|
925
|
+
}
|
|
926
|
+
details.append(summary, body);
|
|
927
|
+
container.append(details);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function configureShowcaseStage() {
|
|
932
|
+
if (!showcaseStage) return;
|
|
933
|
+
const stageScale = SHOWCASE_PRESET.stageWidth / SHOWCASE_PRESET.layoutReferenceWidth;
|
|
934
|
+
const textScale = SHOWCASE_PRESET.textScalePercent / 100;
|
|
935
|
+
showcaseStage.style.width = `${SHOWCASE_PRESET.stageWidth}px`;
|
|
936
|
+
showcaseStage.style.height = `${SHOWCASE_PRESET.stageHeight}px`;
|
|
937
|
+
const masthead = showcaseStage.querySelector(".masthead");
|
|
938
|
+
masthead.style.left = `${SHOWCASE_PRESET.mastheadLeft * stageScale}px`;
|
|
939
|
+
masthead.style.top = `${SHOWCASE_PRESET.mastheadTop * stageScale}px`;
|
|
940
|
+
masthead.style.width = `${SHOWCASE_PRESET.mastheadWidth / textScale}px`;
|
|
941
|
+
masthead.style.transform = `scale(${stageScale * textScale})`;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function fitShowcaseStage() {
|
|
945
|
+
if (!showcaseStage) return;
|
|
946
|
+
const scale = Math.min(window.innerWidth / SHOWCASE_PRESET.stageWidth, window.innerHeight / SHOWCASE_PRESET.stageHeight);
|
|
947
|
+
const fittedWidth = SHOWCASE_PRESET.stageWidth * scale;
|
|
948
|
+
const fittedHeight = SHOWCASE_PRESET.stageHeight * scale;
|
|
949
|
+
showcaseStage.style.left = `${(window.innerWidth - fittedWidth) / 2}px`;
|
|
950
|
+
showcaseStage.style.top = `${(window.innerHeight - fittedHeight) / 2}px`;
|
|
951
|
+
showcaseStage.style.transform = `scale(${scale})`;
|
|
952
|
+
document.documentElement.dataset.showcaseStageScale = String(scale);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function resize() {
|
|
956
|
+
if (showcaseMode) {
|
|
957
|
+
dpr = 1;
|
|
958
|
+
width = SHOWCASE_PRESET.stageWidth;
|
|
959
|
+
height = SHOWCASE_PRESET.stageHeight;
|
|
960
|
+
if (showcaseRenderer) showcaseRenderer.resize(width, height);
|
|
961
|
+
else {
|
|
962
|
+
canvas.width = width;
|
|
963
|
+
canvas.height = height;
|
|
964
|
+
context.setTransform(1, 0, 0, 1, 0, 0);
|
|
965
|
+
}
|
|
966
|
+
fitShowcaseStage();
|
|
967
|
+
updateSceneViewport();
|
|
968
|
+
if (reducedMotionQuery.matches) applyShowcaseCamera(0);
|
|
969
|
+
requestRender();
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
973
|
+
width = window.innerWidth; height = window.innerHeight;
|
|
974
|
+
canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr);
|
|
975
|
+
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
976
|
+
updateSceneViewport();
|
|
977
|
+
requestRender();
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function updateSceneViewport() {
|
|
981
|
+
if (showcaseMode) {
|
|
982
|
+
sceneRight = width;
|
|
983
|
+
sceneBottom = height;
|
|
984
|
+
sceneCenterX = width * SHOWCASE_PRESET.centerXPercent / 100;
|
|
985
|
+
sceneCenterY = height * SHOWCASE_PRESET.centerYPercent / 100;
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const panelBounds = panel.getBoundingClientRect();
|
|
989
|
+
sceneRight = width > 760 && !panel.classList.contains("is-collapsed") ? panelBounds.left - 14 : width;
|
|
990
|
+
sceneBottom = width <= 760 && !panel.classList.contains("is-collapsed") ? panelBounds.top - 12 : height;
|
|
991
|
+
sceneRight = Math.max(280, sceneRight);
|
|
992
|
+
sceneBottom = Math.max(320, sceneBottom);
|
|
993
|
+
sceneCenterX = sceneRight * .5;
|
|
994
|
+
sceneCenterY = sceneBottom * .53;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function zoomBetween(nextZoom, fromX, fromY, toX = fromX, toY = fromY) {
|
|
998
|
+
const clampedZoom = clamp(nextZoom, MIN_ZOOM, MAX_ZOOM);
|
|
999
|
+
const scale = clampedZoom / zoom;
|
|
1000
|
+
panX = toX - sceneCenterX - (fromX - sceneCenterX - panX) * scale;
|
|
1001
|
+
panY = toY - sceneCenterY - (fromY - sceneCenterY - panY) * scale;
|
|
1002
|
+
zoom = clampedZoom;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function panBy(dx, dy) {
|
|
1006
|
+
panX += dx;
|
|
1007
|
+
panY += dy;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function isEditableTarget(target) {
|
|
1011
|
+
if (!(target instanceof Element)) return false;
|
|
1012
|
+
if (target.isContentEditable || target.matches("textarea, select")) return true;
|
|
1013
|
+
return target.matches("input") && !["checkbox", "radio", "button", "submit", "reset"].includes(target.type);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function moveViewWithArrow(event) {
|
|
1017
|
+
if (!event.key.startsWith("Arrow") || pointers.size > 0) return false;
|
|
1018
|
+
if (event.metaKey || event.ctrlKey || event.altKey || isEditableTarget(event.target)) return false;
|
|
1019
|
+
cancelCameraFlight();
|
|
1020
|
+
const distance = event.shiftKey ? 96 : 32;
|
|
1021
|
+
if (event.key === "ArrowLeft") panBy(distance, 0);
|
|
1022
|
+
else if (event.key === "ArrowRight") panBy(-distance, 0);
|
|
1023
|
+
else if (event.key === "ArrowUp") panBy(0, distance);
|
|
1024
|
+
else if (event.key === "ArrowDown") panBy(0, -distance);
|
|
1025
|
+
else return false;
|
|
1026
|
+
event.preventDefault();
|
|
1027
|
+
requestRender();
|
|
1028
|
+
return true;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function resetCamera() {
|
|
1032
|
+
yaw = -.36;
|
|
1033
|
+
pitch = .34;
|
|
1034
|
+
zoom = 1;
|
|
1035
|
+
panX = 0;
|
|
1036
|
+
panY = 0;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
function setNavigationMode(mode) {
|
|
1040
|
+
navigationMode = mode;
|
|
1041
|
+
const panMode = document.getElementById("pan-mode");
|
|
1042
|
+
const panning = navigationMode === "pan";
|
|
1043
|
+
panMode.setAttribute("aria-pressed", String(panning));
|
|
1044
|
+
canvas.classList.toggle("is-pan", panning);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function project(point, matrix) {
|
|
1048
|
+
const position = point.position;
|
|
1049
|
+
const anchor = expandedPackageIndex !== null && point.packageIndex === expandedPackageIndex
|
|
1050
|
+
? packageAnchors[expandedPackageIndex]
|
|
1051
|
+
: null;
|
|
1052
|
+
const positionX = anchor ? anchor[0] + (position[0] - anchor[0]) * DEPENDENCY_EXPANSION : position[0];
|
|
1053
|
+
const positionY = anchor ? anchor[1] + (position[1] - anchor[1]) * DEPENDENCY_EXPANSION : position[1];
|
|
1054
|
+
const positionZ = anchor ? anchor[2] + (position[2] - anchor[2]) * DEPENDENCY_EXPANSION : position[2];
|
|
1055
|
+
const [cy, sy, cp, sp] = matrix;
|
|
1056
|
+
const x1 = positionX * cy - positionZ * sy;
|
|
1057
|
+
const z1 = positionX * sy + positionZ * cy;
|
|
1058
|
+
const y2 = positionY * cp - z1 * sp;
|
|
1059
|
+
const z2 = positionY * sp + z1 * cp;
|
|
1060
|
+
const depth = 270 - z2;
|
|
1061
|
+
if (depth <= 35) return null;
|
|
1062
|
+
const perspective = 440 / depth * zoom;
|
|
1063
|
+
return [sceneCenterX + panX + x1 * perspective, sceneCenterY + panY + y2 * perspective, perspective];
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function renderShowcaseFallback() {
|
|
1067
|
+
context.globalCompositeOperation = "source-over";
|
|
1068
|
+
context.fillStyle = "#03040a";
|
|
1069
|
+
context.fillRect(0, 0, width, height);
|
|
1070
|
+
const vignette = context.createRadialGradient(sceneCenterX, sceneCenterY, 0, sceneCenterX, sceneCenterY, Math.max(width, height) * .72);
|
|
1071
|
+
vignette.addColorStop(0, `rgba(30,16,45,${Math.min(.5, .18 * SHOWCASE_PRESET.backgroundGlowPercent / 100)})`);
|
|
1072
|
+
vignette.addColorStop(1, "rgba(0,0,0,.6)");
|
|
1073
|
+
context.fillStyle = vignette;
|
|
1074
|
+
context.fillRect(0, 0, width, height);
|
|
1075
|
+
context.globalCompositeOperation = "lighter";
|
|
1076
|
+
const matrix = [Math.cos(yaw), Math.sin(yaw), Math.cos(pitch), Math.sin(pitch)];
|
|
1077
|
+
const deepDetail = clamp(Math.log2(Math.max(1, zoom)) / 5, 0, 1);
|
|
1078
|
+
for (const point of renderPoints) {
|
|
1079
|
+
const projected = project(point, matrix);
|
|
1080
|
+
if (!projected) continue;
|
|
1081
|
+
const [x, y, perspective] = projected;
|
|
1082
|
+
if (x < -20 || x > sceneRight + 20 || y < -20 || y > sceneBottom + 20) continue;
|
|
1083
|
+
const size = clamp(point.base * (.62 + point.signal * .46) * perspective, .35, point.hub ? 5.2 : 3.2);
|
|
1084
|
+
const alpha = clamp(.14 + point.signal * .105, .12, point.hub ? .86 : .7) * SHOWCASE_PRESET.starBrightnessPercent / 100;
|
|
1085
|
+
const colour = colours[point.category];
|
|
1086
|
+
if (size > 1.35) {
|
|
1087
|
+
const glowScale = (3.4 - deepDetail * 1.3) * (.75 + .25 * SHOWCASE_PRESET.pointGlowPercent / 100);
|
|
1088
|
+
context.beginPath();
|
|
1089
|
+
context.arc(x, y, size * glowScale, 0, Math.PI * 2);
|
|
1090
|
+
context.fillStyle = `rgba(${colour[0]},${colour[1]},${colour[2]},${alpha * .055 * SHOWCASE_PRESET.pointGlowPercent / 100})`;
|
|
1091
|
+
context.fill();
|
|
1092
|
+
}
|
|
1093
|
+
context.fillStyle = `rgba(${colour[0]},${colour[1]},${colour[2]},${alpha})`;
|
|
1094
|
+
if (size < .85) context.fillRect(x, y, 1, 1);
|
|
1095
|
+
else {
|
|
1096
|
+
context.beginPath();
|
|
1097
|
+
context.arc(x, y, size, 0, Math.PI * 2);
|
|
1098
|
+
context.fill();
|
|
1099
|
+
}
|
|
1100
|
+
if (size > 1.1) {
|
|
1101
|
+
context.beginPath();
|
|
1102
|
+
context.arc(x, y, Math.max(.45 + deepDetail * .25, size * (.24 + deepDetail * .06)), 0, Math.PI * 2);
|
|
1103
|
+
context.fillStyle = `rgba(255,248,244,${Math.min(.9, alpha * 1.25)})`;
|
|
1104
|
+
context.fill();
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
context.globalCompositeOperation = "source-over";
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function render(timestamp) {
|
|
1111
|
+
animationFrame = 0;
|
|
1112
|
+
updateCameraFlight(timestamp);
|
|
1113
|
+
if (showcaseMode) {
|
|
1114
|
+
if (showcaseRenderer) showcaseRenderer.render();
|
|
1115
|
+
else renderShowcaseFallback();
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
if (interactiveMode) document.getElementById("zoom-level").value = `${Math.round(zoom * 100)}%`;
|
|
1119
|
+
context.globalCompositeOperation = "source-over";
|
|
1120
|
+
context.fillStyle = "#03040a";
|
|
1121
|
+
context.fillRect(0, 0, width, height);
|
|
1122
|
+
const vignette = context.createRadialGradient(sceneCenterX + panX, sceneCenterY + panY, 0, sceneCenterX + panX, sceneCenterY + panY, Math.max(width, height) * .72);
|
|
1123
|
+
vignette.addColorStop(0, "rgba(30,16,45,.18)"); vignette.addColorStop(1, "rgba(0,0,0,.6)");
|
|
1124
|
+
context.fillStyle = vignette; context.fillRect(0, 0, width, height);
|
|
1125
|
+
context.globalCompositeOperation = "lighter";
|
|
1126
|
+
const matrix = [Math.cos(yaw), Math.sin(yaw), Math.cos(pitch), Math.sin(pitch)];
|
|
1127
|
+
const deepDetail = clamp(Math.log2(Math.max(1, zoom)) / 5, 0, 1);
|
|
1128
|
+
for (const point of renderPoints) {
|
|
1129
|
+
point.screen = null;
|
|
1130
|
+
if (point.hub) point.cloudScreenRadius = null;
|
|
1131
|
+
if (!visibleCategories[point.category]) continue;
|
|
1132
|
+
const projected = project(point, matrix);
|
|
1133
|
+
if (!projected) continue;
|
|
1134
|
+
const [x, y, perspective] = projected;
|
|
1135
|
+
const cullMargin = point === selectedPoint ? 0 : 20;
|
|
1136
|
+
if (x < -cullMargin || x > sceneRight + cullMargin || y < -cullMargin || y > sceneBottom + cullMargin) continue;
|
|
1137
|
+
const signal = point.signal;
|
|
1138
|
+
const size = clamp(point.base * (.62 + signal * .46) * perspective, .35, point.hub ? 5.2 : 3.2);
|
|
1139
|
+
const alpha = clamp(.14 + signal * .105, .12, point.hub ? .86 : .7);
|
|
1140
|
+
const focusedPackagePoint = expandedPackageIndex !== null && point.category === "dependencies" && point.packageIndex === expandedPackageIndex;
|
|
1141
|
+
const emphasis = expandedPackageIndex !== null
|
|
1142
|
+
? (focusedPackagePoint ? 1 : contextVisibility.package)
|
|
1143
|
+
: selectionLocked && selectedPoint ? (point === selectedPoint ? 1 : contextVisibility.selection) : focusedCategory && point.category !== focusedCategory ? contextVisibility.category : 1;
|
|
1144
|
+
const visibleAlpha = focusedPackagePoint ? Math.max(.34, alpha) : alpha * emphasis;
|
|
1145
|
+
const colour = colours[point.category];
|
|
1146
|
+
point.screen = [x, y, size];
|
|
1147
|
+
if (point.hub) {
|
|
1148
|
+
const expansion = expandedPackageIndex === point.packageIndex ? DEPENDENCY_EXPANSION : 1;
|
|
1149
|
+
point.cloudScreenRadius = Math.max(12, packageAnchors[point.packageIndex][3] * perspective * expansion * 1.2);
|
|
1150
|
+
}
|
|
1151
|
+
const detailedPoint = expandedPackageIndex !== null ? focusedPackagePoint : emphasis >= .1;
|
|
1152
|
+
if (size > 1.35 && detailedPoint) {
|
|
1153
|
+
const glowScale = focusedPackagePoint ? 2.2 - deepDetail * .8 : 3.4 - deepDetail * 1.3;
|
|
1154
|
+
context.beginPath(); context.arc(x, y, size * glowScale, 0, Math.PI * 2);
|
|
1155
|
+
context.fillStyle = `rgba(${colour[0]},${colour[1]},${colour[2]},${visibleAlpha * (focusedPackagePoint ? .045 : .055)})`; context.fill();
|
|
1156
|
+
}
|
|
1157
|
+
context.fillStyle = `rgba(${colour[0]},${colour[1]},${colour[2]},${visibleAlpha})`;
|
|
1158
|
+
if (!detailedPoint || size < .85) context.fillRect(x, y, 1, 1);
|
|
1159
|
+
else { context.beginPath(); context.arc(x, y, size, 0, Math.PI * 2); context.fill(); }
|
|
1160
|
+
if (size > 1.1 && detailedPoint) {
|
|
1161
|
+
context.beginPath(); context.arc(x, y, Math.max(.45 + deepDetail * .25, size * (.24 + deepDetail * .06)), 0, Math.PI * 2);
|
|
1162
|
+
context.fillStyle = `rgba(255,248,244,${Math.min(.9, visibleAlpha * 1.25)})`; context.fill();
|
|
1163
|
+
}
|
|
1164
|
+
if (point === selectedPoint) {
|
|
1165
|
+
context.beginPath(); context.arc(x, y, Math.max(7, size * 2.5), 0, Math.PI * 2);
|
|
1166
|
+
context.strokeStyle = "rgba(255,255,255,.95)"; context.lineWidth = 1.2; context.stroke();
|
|
1167
|
+
context.beginPath(); context.arc(x, y, Math.max(12, size * 4), 0, Math.PI * 2);
|
|
1168
|
+
context.strokeStyle = `rgba(${colour[0]},${colour[1]},${colour[2]},.5)`; context.lineWidth = 1; context.stroke();
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
context.globalCompositeOperation = "source-over";
|
|
1172
|
+
if (selectedPoint) {
|
|
1173
|
+
if (cameraFlight) tooltip.hidden = true;
|
|
1174
|
+
else positionTooltip(selectedPoint);
|
|
1175
|
+
}
|
|
1176
|
+
if (cameraFlight) requestRender();
|
|
1177
|
+
else if (interactiveMode && drifting && !dragging && !selectedPoint) { yaw += .00055; requestRender(); }
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function requestRender() {
|
|
1181
|
+
if (!animationFrame) animationFrame = requestAnimationFrame(render);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function applyShowcaseCamera(progress) {
|
|
1185
|
+
const wrapped = ((Number(progress) % 1) + 1) % 1;
|
|
1186
|
+
const direction = SHOWCASE_PRESET.direction === "clockwise" ? 1 : -1;
|
|
1187
|
+
const phase = wrapped * Math.PI * 2 * SHOWCASE_PRESET.turns * direction;
|
|
1188
|
+
const viewportScale = Math.min(width / SHOWCASE_PRESET.layoutReferenceWidth, height / SHOWCASE_PRESET.layoutReferenceHeight);
|
|
1189
|
+
yaw = SHOWCASE_PRESET.startAngleDegrees * Math.PI / 180 + phase;
|
|
1190
|
+
pitch = (SHOWCASE_PRESET.elevationDegrees + Math.sin(phase) * SHOWCASE_PRESET.elevationSwayDegrees) * Math.PI / 180;
|
|
1191
|
+
zoom = SHOWCASE_PRESET.zoom * (1 + ((1 - Math.cos(phase)) / 2) * SHOWCASE_PRESET.zoomBreathPercent / 100) * viewportScale;
|
|
1192
|
+
panX = 0;
|
|
1193
|
+
panY = 0;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function renderShowcase(timestamp) {
|
|
1197
|
+
showcaseStartedAt ??= timestamp;
|
|
1198
|
+
const frameCount = SHOWCASE_PRESET.targetFps * SHOWCASE_PRESET.durationMs / 1000;
|
|
1199
|
+
const rawProgress = ((timestamp - showcaseStartedAt) % SHOWCASE_PRESET.durationMs) / SHOWCASE_PRESET.durationMs;
|
|
1200
|
+
const progress = Math.floor(rawProgress * frameCount) / frameCount;
|
|
1201
|
+
applyShowcaseCamera(progress);
|
|
1202
|
+
render(timestamp);
|
|
1203
|
+
document.documentElement.dataset.showcaseReady = "true";
|
|
1204
|
+
if (!reducedMotionQuery.matches) animationFrame = requestAnimationFrame(renderShowcase);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function startShowcase() {
|
|
1208
|
+
if (animationFrame) cancelAnimationFrame(animationFrame);
|
|
1209
|
+
animationFrame = 0;
|
|
1210
|
+
showcaseStartedAt = null;
|
|
1211
|
+
if (reducedMotionQuery.matches) {
|
|
1212
|
+
applyShowcaseCamera(0);
|
|
1213
|
+
render(performance.now());
|
|
1214
|
+
document.documentElement.dataset.showcaseReady = "true";
|
|
1215
|
+
document.documentElement.dataset.showcaseMotion = "reduced";
|
|
1216
|
+
} else {
|
|
1217
|
+
document.documentElement.dataset.showcaseMotion = "active";
|
|
1218
|
+
animationFrame = requestAnimationFrame(renderShowcase);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function populateShowcaseStats() {
|
|
1223
|
+
const core = model.categoryStats?.core || [0, 0, 0, 0];
|
|
1224
|
+
const tests = model.categoryStats?.tests || [0, 0, 0, 0];
|
|
1225
|
+
const format = value => Number(value || 0).toLocaleString("en-US");
|
|
1226
|
+
const counted = (value, singular, plural) => `${format(value)} ${Number(value || 0) === 1 ? singular : plural}`;
|
|
1227
|
+
["classes", "modules", "methods", "constants"].forEach((metric, index) => {
|
|
1228
|
+
document.getElementById(`cinema-${metric}`).textContent = format(core[index]);
|
|
1229
|
+
});
|
|
1230
|
+
document.getElementById("cinema-secondary").textContent = `Tests · ${counted(tests[0], "class", "classes")} · ${counted(tests[2], "method", "methods")} · ${counted(totals.packages, "dependency gem", "dependency gems")} in orbit`;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
function setDrifting(next) {
|
|
1234
|
+
if (next) cancelCameraFlight();
|
|
1235
|
+
drifting = next;
|
|
1236
|
+
const motion = document.getElementById("motion");
|
|
1237
|
+
motion.textContent = drifting ? "Pause drift" : "Resume drift";
|
|
1238
|
+
motion.setAttribute("aria-pressed", String(!drifting));
|
|
1239
|
+
requestRender();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const pointerMetrics = () => {
|
|
1243
|
+
const active = [...pointers.values()];
|
|
1244
|
+
if (active.length < 2) return null;
|
|
1245
|
+
return {
|
|
1246
|
+
distance: Math.hypot(active[0][0] - active[1][0], active[0][1] - active[1][1]),
|
|
1247
|
+
x: (active[0][0] + active[1][0]) / 2,
|
|
1248
|
+
y: (active[0][1] + active[1][1]) / 2,
|
|
1249
|
+
};
|
|
1250
|
+
};
|
|
1251
|
+
function clearGestureState() {
|
|
1252
|
+
pointers.clear();
|
|
1253
|
+
gesture = null;
|
|
1254
|
+
pinchState = null;
|
|
1255
|
+
dragging = false;
|
|
1256
|
+
canvas.classList.remove("is-dragging-pan");
|
|
1257
|
+
requestRender();
|
|
1258
|
+
};
|
|
1259
|
+
if (interactiveMode) {
|
|
1260
|
+
canvas.addEventListener("pointerdown", event => {
|
|
1261
|
+
cancelCameraFlight();
|
|
1262
|
+
canvas.focus({ preventScroll: true });
|
|
1263
|
+
const firstPointer = pointers.size === 0;
|
|
1264
|
+
pointers.set(event.pointerId, [event.clientX, event.clientY]);
|
|
1265
|
+
canvas.setPointerCapture(event.pointerId);
|
|
1266
|
+
dragging = true;
|
|
1267
|
+
if (firstPointer) {
|
|
1268
|
+
const mode = navigationMode === "pan" || event.shiftKey || event.button !== 0 ? "pan" : "orbit";
|
|
1269
|
+
gesture = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, lastX: event.clientX, lastY: event.clientY, mode, moved: false, tappable: event.button === 0 };
|
|
1270
|
+
canvas.classList.toggle("is-dragging-pan", mode === "pan");
|
|
1271
|
+
if (!selectionLocked) selectPoint(null);
|
|
1272
|
+
} else {
|
|
1273
|
+
gesture = null;
|
|
1274
|
+
pinchState = pointerMetrics();
|
|
1275
|
+
canvas.classList.remove("is-dragging-pan");
|
|
1276
|
+
}
|
|
1277
|
+
});
|
|
1278
|
+
canvas.addEventListener("pointermove", event => {
|
|
1279
|
+
if (!pointers.has(event.pointerId)) {
|
|
1280
|
+
if (event.pointerType === "mouse") queueHover(event.clientX, event.clientY);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
pointers.set(event.pointerId, [event.clientX, event.clientY]);
|
|
1284
|
+
if (pointers.size >= 2) {
|
|
1285
|
+
const nextPinch = pointerMetrics();
|
|
1286
|
+
if (pinchState && nextPinch && pinchState.distance > 0) {
|
|
1287
|
+
zoomBetween(zoom * nextPinch.distance / pinchState.distance, pinchState.x, pinchState.y, nextPinch.x, nextPinch.y);
|
|
1288
|
+
}
|
|
1289
|
+
pinchState = nextPinch;
|
|
1290
|
+
gesture = null;
|
|
1291
|
+
requestRender();
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (!gesture || gesture.pointerId !== event.pointerId) return;
|
|
1295
|
+
const dx = event.clientX - gesture.lastX;
|
|
1296
|
+
const dy = event.clientY - gesture.lastY;
|
|
1297
|
+
if (Math.hypot(event.clientX - gesture.startX, event.clientY - gesture.startY) > 3) gesture.moved = true;
|
|
1298
|
+
if (gesture.mode === "pan") panBy(dx, dy);
|
|
1299
|
+
else {
|
|
1300
|
+
yaw += dx * .006;
|
|
1301
|
+
pitch = clamp(pitch + dy * .004, -TOP_DOWN_PITCH, TOP_DOWN_PITCH);
|
|
1302
|
+
}
|
|
1303
|
+
gesture.lastX = event.clientX;
|
|
1304
|
+
gesture.lastY = event.clientY;
|
|
1305
|
+
requestRender();
|
|
1306
|
+
});
|
|
1307
|
+
function finishPointer(event, cancelled = false) {
|
|
1308
|
+
const wasTap = !cancelled && pointers.size === 1 && gesture?.pointerId === event.pointerId && gesture.tappable && !gesture.moved;
|
|
1309
|
+
pointers.delete(event.pointerId);
|
|
1310
|
+
gesture = null;
|
|
1311
|
+
pinchState = pointers.size >= 2 ? pointerMetrics() : null;
|
|
1312
|
+
dragging = pointers.size > 0;
|
|
1313
|
+
canvas.classList.remove("is-dragging-pan");
|
|
1314
|
+
if (wasTap) {
|
|
1315
|
+
const point = hoverTargetAt(event.clientX, event.clientY);
|
|
1316
|
+
clearActiveFact();
|
|
1317
|
+
clearCategoryFocus();
|
|
1318
|
+
if (point?.category === "dependencies" && selectionLocked && selectedPoint === point) focusDependencyPackage(point.packageIndex);
|
|
1319
|
+
else if (point) selectPoint(point, true);
|
|
1320
|
+
else clearExplorationFocus();
|
|
1321
|
+
}
|
|
1322
|
+
requestRender();
|
|
1323
|
+
}
|
|
1324
|
+
canvas.addEventListener("pointerup", event => finishPointer(event));
|
|
1325
|
+
canvas.addEventListener("pointercancel", event => finishPointer(event, true));
|
|
1326
|
+
canvas.addEventListener("lostpointercapture", event => { if (pointers.has(event.pointerId)) finishPointer(event, true); });
|
|
1327
|
+
canvas.addEventListener("pointerleave", () => { if (!selectionLocked && pointers.size === 0) selectPoint(null); });
|
|
1328
|
+
canvas.addEventListener("contextmenu", event => event.preventDefault());
|
|
1329
|
+
window.addEventListener("blur", clearGestureState);
|
|
1330
|
+
canvas.addEventListener("wheel", event => {
|
|
1331
|
+
event.preventDefault();
|
|
1332
|
+
if (pointers.size > 0) return;
|
|
1333
|
+
cancelCameraFlight();
|
|
1334
|
+
const delta = event.deltaY * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? height : 1);
|
|
1335
|
+
zoomBetween(zoom * Math.exp(-delta * .0012), event.clientX, event.clientY);
|
|
1336
|
+
requestRender();
|
|
1337
|
+
}, { passive: false });
|
|
1338
|
+
canvas.addEventListener("dblclick", event => {
|
|
1339
|
+
if (pointers.size > 0) return;
|
|
1340
|
+
const dependency = dependencyPackageAt(event.clientX, event.clientY);
|
|
1341
|
+
if (dependency) {
|
|
1342
|
+
focusDependencyPackage(dependency.packageIndex);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
cancelCameraFlight();
|
|
1346
|
+
zoomBetween(zoom * 2, event.clientX, event.clientY);
|
|
1347
|
+
requestRender();
|
|
1348
|
+
});
|
|
1349
|
+
canvas.addEventListener("keydown", event => {
|
|
1350
|
+
if (pointers.size > 0) return;
|
|
1351
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
1352
|
+
if (event.key === "+" || event.key === "=") { cancelCameraFlight(); zoomBetween(zoom * ZOOM_STEP, sceneCenterX, sceneCenterY); }
|
|
1353
|
+
else if (event.key === "-") { cancelCameraFlight(); zoomBetween(zoom / ZOOM_STEP, sceneCenterX, sceneCenterY); }
|
|
1354
|
+
else if (event.key === "0") { cancelCameraFlight(); resetCamera(); }
|
|
1355
|
+
else if (event.key.toLowerCase() === "p") { cancelCameraFlight(); setNavigationMode(navigationMode === "pan" ? "orbit" : "pan"); }
|
|
1356
|
+
else if ((event.key === "Enter" || event.key.toLowerCase() === "f") && selectedPoint?.category === "dependencies") focusDependencyPackage(selectedPoint.packageIndex);
|
|
1357
|
+
else return;
|
|
1358
|
+
event.preventDefault();
|
|
1359
|
+
requestRender();
|
|
1360
|
+
});
|
|
1361
|
+
window.addEventListener("keydown", event => {
|
|
1362
|
+
if (event.key === "Escape") clearExplorationFocus();
|
|
1363
|
+
else moveViewWithArrow(event);
|
|
1364
|
+
});
|
|
1365
|
+
document.getElementById("motion").addEventListener("click", () => setDrifting(!drifting));
|
|
1366
|
+
document.getElementById("pan-mode").addEventListener("click", () => { cancelCameraFlight(); setNavigationMode(navigationMode === "pan" ? "orbit" : "pan"); });
|
|
1367
|
+
document.getElementById("zoom-in").addEventListener("click", () => { if (pointers.size === 0) { cancelCameraFlight(); zoomBetween(zoom * ZOOM_STEP, sceneCenterX, sceneCenterY); } requestRender(); });
|
|
1368
|
+
document.getElementById("zoom-out").addEventListener("click", () => { if (pointers.size === 0) { cancelCameraFlight(); zoomBetween(zoom / ZOOM_STEP, sceneCenterX, sceneCenterY); } requestRender(); });
|
|
1369
|
+
document.getElementById("view").addEventListener("click", () => {
|
|
1370
|
+
cancelCameraFlight();
|
|
1371
|
+
resetCamera();
|
|
1372
|
+
setNavigationMode("orbit");
|
|
1373
|
+
for (const category of Object.keys(visibleCategories)) setCategoryVisible(category, true);
|
|
1374
|
+
clearExplorationFocus();
|
|
1375
|
+
requestRender();
|
|
1376
|
+
});
|
|
1377
|
+
panelToggle.addEventListener("click", () => setPanelCollapsed(panelToggle.getAttribute("aria-expanded") === "true"));
|
|
1378
|
+
panel.addEventListener("transitionend", event => { if (event.propertyName === "width") { updateSceneViewport(); requestRender(); } });
|
|
1379
|
+
reducedMotionQuery.addEventListener("change", event => {
|
|
1380
|
+
if (!event.matches) return;
|
|
1381
|
+
completeCameraFlight();
|
|
1382
|
+
setDrifting(false);
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
window.addEventListener("resize", resize);
|
|
1387
|
+
document.querySelector("h1").textContent = model.projectName;
|
|
1388
|
+
if (showcaseMode) {
|
|
1389
|
+
document.title = `${model.projectName} · RubyLens showcase`;
|
|
1390
|
+
const showcaseLabel = `Autonomous stellar artwork of ${model.projectName}, completing one slow rotation each minute.`;
|
|
1391
|
+
canvas.setAttribute("aria-label", showcaseLabel);
|
|
1392
|
+
document.getElementById("showcase-cosmos")?.setAttribute("aria-label", showcaseLabel);
|
|
1393
|
+
populateShowcaseStats();
|
|
1394
|
+
reducedMotionQuery.addEventListener("change", startShowcase);
|
|
1395
|
+
configureShowcaseStage();
|
|
1396
|
+
resize();
|
|
1397
|
+
startShowcase();
|
|
1398
|
+
} else {
|
|
1399
|
+
document.title = `RubyLens · ${model.projectName}`;
|
|
1400
|
+
canvas.setAttribute("aria-label", `Interactive three-dimensional stellar artwork of ${model.projectName}. Hover class and module stars for Ruby code details or gem clouds for package summaries. Sidebar highlights open a top-down view. Double-click a gem cloud, press Enter or F on a selected gem marker, or tap that marker again to expand its stars. Drag to orbit, Shift-drag or Pan mode to move, scroll or pinch to zoom at a point, and use arrow keys to move the view. Escape exits a focused gem system.`);
|
|
1401
|
+
document.getElementById("coverage").textContent = `${renderedDependencyStars.toLocaleString()} dependency stars shown`;
|
|
1402
|
+
const warningTotal = Object.values(model.warningCounts).reduce((sum, count) => sum + count, 0);
|
|
1403
|
+
if (warningTotal > 0) { const status = document.getElementById("status"); status.hidden = false; status.textContent = `${warningTotal.toLocaleString()} partial-index warning${warningTotal === 1 ? "" : "s"}`; }
|
|
1404
|
+
setDrifting(drifting);
|
|
1405
|
+
setNavigationMode(navigationMode);
|
|
1406
|
+
createExplorer();
|
|
1407
|
+
setPanelCollapsed(window.matchMedia("(max-width: 760px)").matches);
|
|
1408
|
+
resize();
|
|
1409
|
+
}
|