@progen-labs/spectra 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/bin/spectra.js +215 -0
- package/dist/assets/index-BvBsPok5.js +46 -0
- package/dist/assets/index-Da3Le5Px.css +1 -0
- package/dist/index.html +14 -0
- package/index.html +13 -0
- package/package.json +76 -4
- package/server/analysis/cpu.js +369 -0
- package/server/analysis/findings.js +551 -0
- package/server/analysis/locate.js +99 -0
- package/server/analysis/report.js +750 -0
- package/server/analysis/sources.js +249 -0
- package/server/analysis/trace.js +314 -0
- package/server/api.js +292 -0
- package/server/chrome.js +81 -0
- package/server/cli.js +94 -0
- package/server/config.js +59 -0
- package/server/dashboard.js +154 -0
- package/server/explore/boot.js +150 -0
- package/server/explore/classify.js +169 -0
- package/server/explore/discover.js +162 -0
- package/server/explore/evaluate-cli.js +101 -0
- package/server/explore/evaluate.js +165 -0
- package/server/explore/paths.js +105 -0
- package/server/explore/runner.js +94 -0
- package/server/explore/test-explore.js +139 -0
- package/server/explore/test-paths.js +75 -0
- package/server/framework.js +67 -0
- package/server/git.js +59 -0
- package/server/pdf/render.js +60 -0
- package/server/pdf/template.js +419 -0
- package/server/plugin.js +19 -0
- package/server/probe.browser.js +689 -0
- package/server/recorder.js +610 -0
- package/server/signin.js +27 -0
- package/server/test-analysis.js +424 -0
- package/server/test-compare.js +473 -0
- package/server/test-sources.js +136 -0
- package/shared/compare.js +977 -0
- package/shared/snapshot.js +77 -0
- package/src/App.jsx +361 -0
- package/src/api.js +51 -0
- package/src/components/AppearanceMenu.jsx +76 -0
- package/src/components/CodeRef.jsx +48 -0
- package/src/components/ComparePanel.jsx +143 -0
- package/src/components/CompareResults.jsx +473 -0
- package/src/components/ControlBar.jsx +336 -0
- package/src/components/CulpritSphere.jsx +218 -0
- package/src/components/Culprits.jsx +118 -0
- package/src/components/CulpritsHero.jsx +109 -0
- package/src/components/EvaluationPanel.jsx +160 -0
- package/src/components/FilePanel.jsx +225 -0
- package/src/components/FindingsList.jsx +381 -0
- package/src/components/HeatTrees.jsx +222 -0
- package/src/components/Icons.jsx +58 -0
- package/src/components/LongTasks.jsx +199 -0
- package/src/components/NetworkPanel.jsx +98 -0
- package/src/components/PageMap.jsx +329 -0
- package/src/components/Popover.jsx +51 -0
- package/src/components/ReactPanel.jsx +200 -0
- package/src/components/SidePicker.jsx +207 -0
- package/src/components/SnapshotButton.jsx +140 -0
- package/src/components/TimeBreakdown.jsx +45 -0
- package/src/components/TraceButton.jsx +76 -0
- package/src/components/TreeView.jsx +173 -0
- package/src/components/Vitals.jsx +163 -0
- package/src/culprits.js +127 -0
- package/src/download.js +18 -0
- package/src/format.js +26 -0
- package/src/heat.js +177 -0
- package/src/highlight.js +45 -0
- package/src/hooks/useCompare.js +187 -0
- package/src/hooks/useSession.js +73 -0
- package/src/layout.js +55 -0
- package/src/main.jsx +15 -0
- package/src/styles.css +2478 -0
- package/src/theme.js +75 -0
- package/src/themes.css +286 -0
- package/vite.config.js +8 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api.js';
|
|
3
|
+
import { ChevronIcon, PageLoadIcon, RecordIcon, StopIcon } from './Icons.jsx';
|
|
4
|
+
import Popover from './Popover.jsx';
|
|
5
|
+
|
|
6
|
+
const URL_KEY = 'perf-profiler:url';
|
|
7
|
+
const SIGNIN_KEY = 'perf-profiler:sign-in';
|
|
8
|
+
const readSignIn = () => localStorage.getItem(SIGNIN_KEY) || '';
|
|
9
|
+
|
|
10
|
+
function Elapsed({ startedAt }) {
|
|
11
|
+
const [now, setNow] = useState(Date.now());
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
// Ticks the on-screen recording timer; cleared on unmount.
|
|
14
|
+
const id = setInterval(() => setNow(Date.now()), 250);
|
|
15
|
+
return () => clearInterval(id);
|
|
16
|
+
}, []);
|
|
17
|
+
return (
|
|
18
|
+
<span className="elapsed">{((now - startedAt) / 1000).toFixed(1)} s</span>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ProbePill({ probe }) {
|
|
23
|
+
if (!probe) return null;
|
|
24
|
+
if (probe.react) {
|
|
25
|
+
return (
|
|
26
|
+
<span
|
|
27
|
+
className="pill ok"
|
|
28
|
+
title="React was detected; per-component render data will be recorded."
|
|
29
|
+
>
|
|
30
|
+
✓ React {probe.version}
|
|
31
|
+
{probe.timings ? '' : ' · no timings (production build)'}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
// If a non-React framework was detected, show that instead of the reload hint
|
|
36
|
+
if (probe.framework && probe.framework.name) {
|
|
37
|
+
return (
|
|
38
|
+
<span className="pill ok" title={`Detected ${probe.framework.name}`}>
|
|
39
|
+
✓ {probe.framework.name} {probe.framework.version || ''}
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<span
|
|
45
|
+
className="pill warn"
|
|
46
|
+
title="The probe must load before React. Use Reload, or “Record page load”."
|
|
47
|
+
>
|
|
48
|
+
! No React seen — reload
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Connect({ status, run, isCompact }) {
|
|
54
|
+
const [url, setUrl] = useState(
|
|
55
|
+
() => localStorage.getItem(URL_KEY) || status.defaultUrl || '',
|
|
56
|
+
);
|
|
57
|
+
const [headless, setHeadless] = useState(false);
|
|
58
|
+
const [browserURL, setBrowserURL] = useState('http://127.0.0.1:9222');
|
|
59
|
+
const [busy, setBusy] = useState(false);
|
|
60
|
+
const [signIn, setSignIn] = useState(readSignIn);
|
|
61
|
+
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
|
|
62
|
+
const go = async options => {
|
|
63
|
+
if (options.url) localStorage.setItem(URL_KEY, options.url);
|
|
64
|
+
localStorage.setItem(SIGNIN_KEY, signIn);
|
|
65
|
+
setBusy(true);
|
|
66
|
+
await run(() => api.connect(options));
|
|
67
|
+
setBusy(false);
|
|
68
|
+
};
|
|
69
|
+
return (
|
|
70
|
+
<div className="connect">
|
|
71
|
+
{!isCompact && (
|
|
72
|
+
<div className="connect-head">
|
|
73
|
+
<h2>Connect to a browser</h2>
|
|
74
|
+
<p className="muted">
|
|
75
|
+
Launch a fresh Chrome window, sign in, and open the page you want to
|
|
76
|
+
profile.
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
<div className="connect-main">
|
|
81
|
+
<label className="field">
|
|
82
|
+
Page to profile
|
|
83
|
+
<input
|
|
84
|
+
type="url"
|
|
85
|
+
value={url}
|
|
86
|
+
onChange={e => setUrl(e.target.value)}
|
|
87
|
+
placeholder="http://localhost:3000/"
|
|
88
|
+
/>
|
|
89
|
+
</label>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className="primary"
|
|
93
|
+
disabled={busy}
|
|
94
|
+
onClick={() => go({ mode: 'launch', url, headless, signIn })}
|
|
95
|
+
>
|
|
96
|
+
{busy ? 'Starting Chrome…' : 'Launch Chrome'}
|
|
97
|
+
</button>
|
|
98
|
+
<button
|
|
99
|
+
type="button"
|
|
100
|
+
className="ghost"
|
|
101
|
+
aria-expanded={isAdvancedOpen}
|
|
102
|
+
onClick={() => setIsAdvancedOpen(open => !open)}
|
|
103
|
+
>
|
|
104
|
+
Advanced <ChevronIcon isUp={isAdvancedOpen} />
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
{isAdvancedOpen && (
|
|
108
|
+
<div className="advanced">
|
|
109
|
+
<div className="advanced-section">
|
|
110
|
+
<div className="advanced-row">
|
|
111
|
+
<label
|
|
112
|
+
className="field"
|
|
113
|
+
title="Used automatically when the page fails to load or lands on a login screen. {url} is replaced with the page above. Do not put tokens in it."
|
|
114
|
+
>
|
|
115
|
+
Sign-in URL <span className="muted">(optional)</span>
|
|
116
|
+
<input
|
|
117
|
+
value={signIn}
|
|
118
|
+
onChange={e => setSignIn(e.target.value)}
|
|
119
|
+
placeholder="http://localhost:3000/login/?redirect={url}"
|
|
120
|
+
/>
|
|
121
|
+
</label>
|
|
122
|
+
<label className="switch">
|
|
123
|
+
<input
|
|
124
|
+
type="checkbox"
|
|
125
|
+
role="switch"
|
|
126
|
+
checked={headless}
|
|
127
|
+
onChange={e => setHeadless(e.target.checked)}
|
|
128
|
+
/>
|
|
129
|
+
Headless
|
|
130
|
+
</label>
|
|
131
|
+
</div>
|
|
132
|
+
<span className="muted">
|
|
133
|
+
The Chrome window starts signed out every time, so give it your
|
|
134
|
+
app’s login URL once; {'{url}'} is where the page to profile goes.
|
|
135
|
+
</span>
|
|
136
|
+
</div>
|
|
137
|
+
<div className="advanced-section">
|
|
138
|
+
<div className="advanced-row">
|
|
139
|
+
<label className="field">
|
|
140
|
+
Attach to a running Chrome
|
|
141
|
+
<input
|
|
142
|
+
value={browserURL}
|
|
143
|
+
onChange={e => setBrowserURL(e.target.value)}
|
|
144
|
+
/>
|
|
145
|
+
</label>
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
disabled={busy}
|
|
149
|
+
onClick={() => go({ mode: 'attach', browserURL })}
|
|
150
|
+
>
|
|
151
|
+
Attach
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
<span className="muted">
|
|
155
|
+
Start it with <code>--remote-debugging-port=9222</code>
|
|
156
|
+
</span>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function RecordingOptions({ throttle, setThrottle, saveTrace, setSaveTrace }) {
|
|
165
|
+
return (
|
|
166
|
+
<Popover label="Options" align="left">
|
|
167
|
+
<div className="options-list">
|
|
168
|
+
<label className="field">
|
|
169
|
+
<span className="field-label">CPU throttling</span>
|
|
170
|
+
<select
|
|
171
|
+
value={throttle}
|
|
172
|
+
onChange={e => setThrottle(Number(e.target.value))}
|
|
173
|
+
>
|
|
174
|
+
<option value={1}>No throttling</option>
|
|
175
|
+
<option value={4}>4× slowdown</option>
|
|
176
|
+
<option value={6}>6× slowdown</option>
|
|
177
|
+
</select>
|
|
178
|
+
</label>
|
|
179
|
+
<label className="switch">
|
|
180
|
+
<input
|
|
181
|
+
type="checkbox"
|
|
182
|
+
role="switch"
|
|
183
|
+
checked={saveTrace}
|
|
184
|
+
onChange={e => setSaveTrace(e.target.checked)}
|
|
185
|
+
/>
|
|
186
|
+
Save raw trace for DevTools
|
|
187
|
+
</label>
|
|
188
|
+
</div>
|
|
189
|
+
</Popover>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function RecordRow({ status, run }) {
|
|
194
|
+
const [throttle, setThrottle] = useState(1);
|
|
195
|
+
const [saveTrace, setSaveTrace] = useState(false);
|
|
196
|
+
const { state } = status;
|
|
197
|
+
const options = mode => ({ mode, cpuThrottle: throttle, saveTrace });
|
|
198
|
+
if (state === 'recording')
|
|
199
|
+
return (
|
|
200
|
+
<div className="rec-strip recording">
|
|
201
|
+
<span className="rec">
|
|
202
|
+
<i /> Recording
|
|
203
|
+
{status.recording?.mode === 'load' ? ' page load' : ''}{' '}
|
|
204
|
+
<Elapsed startedAt={status.recording.startedAt} />
|
|
205
|
+
</span>
|
|
206
|
+
<span className="muted">
|
|
207
|
+
{status.recording?.mode === 'load'
|
|
208
|
+
? 'Stops on its own a few seconds after the page finishes loading.'
|
|
209
|
+
: 'Use the page now — click, scroll, type — then stop.'}
|
|
210
|
+
</span>
|
|
211
|
+
<span className="spacer" />
|
|
212
|
+
<button type="button" className="danger" onClick={() => run(api.stop)}>
|
|
213
|
+
<StopIcon /> Stop & analyse
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
);
|
|
217
|
+
if (state === 'analyzing')
|
|
218
|
+
return (
|
|
219
|
+
<div className="rec-strip">
|
|
220
|
+
<span className="rec">
|
|
221
|
+
<i className="spin" /> Analysing trace, CPU samples and source maps…
|
|
222
|
+
</span>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
return (
|
|
226
|
+
<div className="rec-strip">
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className="primary"
|
|
230
|
+
onClick={() => run(() => api.start(options('live')))}
|
|
231
|
+
>
|
|
232
|
+
<RecordIcon /> Record interaction
|
|
233
|
+
</button>
|
|
234
|
+
<button
|
|
235
|
+
type="button"
|
|
236
|
+
onClick={() => run(() => api.start(options('load')))}
|
|
237
|
+
>
|
|
238
|
+
<PageLoadIcon /> Record page load
|
|
239
|
+
</button>
|
|
240
|
+
<RecordingOptions
|
|
241
|
+
throttle={throttle}
|
|
242
|
+
setThrottle={setThrottle}
|
|
243
|
+
saveTrace={saveTrace}
|
|
244
|
+
setSaveTrace={setSaveTrace}
|
|
245
|
+
/>
|
|
246
|
+
{throttle > 1 && (
|
|
247
|
+
<span className="pill warn">{throttle}× CPU slowdown</span>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export default function ControlBar({ status, run, isCompact }) {
|
|
254
|
+
const [address, setAddress] = useState('');
|
|
255
|
+
useEffect(() => {
|
|
256
|
+
if (status?.pageUrl) setAddress(status.pageUrl);
|
|
257
|
+
}, [status?.pageUrl]);
|
|
258
|
+
|
|
259
|
+
if (!status)
|
|
260
|
+
return (
|
|
261
|
+
<section className="session">
|
|
262
|
+
<span className="muted">Connecting to the profiler server…</span>
|
|
263
|
+
</section>
|
|
264
|
+
);
|
|
265
|
+
if (!status.connected)
|
|
266
|
+
return (
|
|
267
|
+
<section className="session">
|
|
268
|
+
<Connect status={status} run={run} isCompact={isCompact} />
|
|
269
|
+
</section>
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const failed =
|
|
273
|
+
status.navigation && status.navigation.status >= 400
|
|
274
|
+
? status.navigation
|
|
275
|
+
: null;
|
|
276
|
+
const idle = status.state === 'idle';
|
|
277
|
+
return (
|
|
278
|
+
<section className="session" aria-label="Browser session">
|
|
279
|
+
{failed && (
|
|
280
|
+
<div className="error" role="alert">
|
|
281
|
+
<span>
|
|
282
|
+
The page answered <b>HTTP {failed.status}</b> ({failed.url}). You
|
|
283
|
+
are probably not signed in. Press Go to retry (the saved Sign-in URL
|
|
284
|
+
is applied automatically), or sign in by hand in the Chrome window.
|
|
285
|
+
If no Sign-in URL was saved, close Chrome and launch again with one.
|
|
286
|
+
</span>
|
|
287
|
+
</div>
|
|
288
|
+
)}
|
|
289
|
+
<div className="session-row">
|
|
290
|
+
<select
|
|
291
|
+
aria-label="Browser tab"
|
|
292
|
+
value={status.pages.find(p => p.selected)?.id ?? ''}
|
|
293
|
+
disabled={!idle}
|
|
294
|
+
onChange={e => run(() => api.selectPage(Number(e.target.value)))}
|
|
295
|
+
>
|
|
296
|
+
{status.pages.map(p => (
|
|
297
|
+
<option key={p.id} value={p.id}>
|
|
298
|
+
{p.title || p.url}
|
|
299
|
+
</option>
|
|
300
|
+
))}
|
|
301
|
+
</select>
|
|
302
|
+
<input
|
|
303
|
+
className="address"
|
|
304
|
+
aria-label="Page URL"
|
|
305
|
+
value={address}
|
|
306
|
+
disabled={!idle}
|
|
307
|
+
onChange={e => setAddress(e.target.value)}
|
|
308
|
+
onKeyDown={e =>
|
|
309
|
+
e.key === 'Enter' && run(() => api.navigate(address, readSignIn()))
|
|
310
|
+
}
|
|
311
|
+
/>
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
disabled={!idle}
|
|
315
|
+
onClick={() => run(() => api.navigate(address, readSignIn()))}
|
|
316
|
+
>
|
|
317
|
+
Go
|
|
318
|
+
</button>
|
|
319
|
+
<button type="button" disabled={!idle} onClick={() => run(api.reload)}>
|
|
320
|
+
Reload
|
|
321
|
+
</button>
|
|
322
|
+
<ProbePill probe={status.probe} />
|
|
323
|
+
<button
|
|
324
|
+
type="button"
|
|
325
|
+
className="ghost destructive"
|
|
326
|
+
onClick={() => run(api.disconnect)}
|
|
327
|
+
>
|
|
328
|
+
{status.launched ? 'Close Chrome' : 'Detach'}
|
|
329
|
+
</button>
|
|
330
|
+
</div>
|
|
331
|
+
<div className="session-row">
|
|
332
|
+
<RecordRow status={status} run={run} />
|
|
333
|
+
</div>
|
|
334
|
+
</section>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import { fmtMs } from '../format.js';
|
|
3
|
+
|
|
4
|
+
const FOV = 3;
|
|
5
|
+
const RADIUS = 0.8;
|
|
6
|
+
const AUTO_SPIN = 0.00035;
|
|
7
|
+
const DRAG_SPEED = 0.008;
|
|
8
|
+
const MAX_TILT = 1.2;
|
|
9
|
+
const LAT_DEG = [-60, -30, 0, 30, 60];
|
|
10
|
+
const MERIDIANS = 6;
|
|
11
|
+
const RING_POINTS = 48;
|
|
12
|
+
const MAX_NAME = 20;
|
|
13
|
+
|
|
14
|
+
// Evenly spread points on a unit sphere (Fibonacci lattice).
|
|
15
|
+
function latticePoints(n) {
|
|
16
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
17
|
+
return Array.from({ length: n }, (_, i) => {
|
|
18
|
+
const y = n === 1 ? 0 : 1 - (2 * (i + 0.5)) / n;
|
|
19
|
+
const r = Math.sqrt(Math.max(0, 1 - y * y));
|
|
20
|
+
return [Math.cos(i * golden) * r, y, Math.sin(i * golden) * r];
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function rotate([x, y, z], ax, ay) {
|
|
25
|
+
const cy = Math.cos(ay);
|
|
26
|
+
const sy = Math.sin(ay);
|
|
27
|
+
const x1 = x * cy + z * sy;
|
|
28
|
+
const z1 = -x * sy + z * cy;
|
|
29
|
+
const cx = Math.cos(ax);
|
|
30
|
+
const sx = Math.sin(ax);
|
|
31
|
+
return [x1, y * cx - z1 * sx, y * sx + z1 * cx];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Perspective: nearer points are bigger and further from the centre.
|
|
35
|
+
function project(p, ax, ay) {
|
|
36
|
+
const [x, y, z] = rotate(p, ax, ay);
|
|
37
|
+
const k = FOV / (FOV - z);
|
|
38
|
+
return { x: x * k * RADIUS, y: y * k * RADIUS, z, k };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function ringPoints(fn) {
|
|
42
|
+
return Array.from({ length: RING_POINTS + 1 }, (_, i) =>
|
|
43
|
+
fn((i / RING_POINTS) * Math.PI * 2),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const WIREFRAME = [
|
|
48
|
+
...LAT_DEG.map(deg => {
|
|
49
|
+
const lat = (deg * Math.PI) / 180;
|
|
50
|
+
return ringPoints(t => [
|
|
51
|
+
Math.cos(lat) * Math.cos(t),
|
|
52
|
+
Math.sin(lat),
|
|
53
|
+
Math.cos(lat) * Math.sin(t),
|
|
54
|
+
]);
|
|
55
|
+
}),
|
|
56
|
+
...Array.from({ length: MERIDIANS }, (_, m) => {
|
|
57
|
+
const lon = (m / MERIDIANS) * Math.PI;
|
|
58
|
+
return ringPoints(t => [
|
|
59
|
+
Math.cos(t) * Math.cos(lon),
|
|
60
|
+
Math.sin(t),
|
|
61
|
+
Math.cos(t) * Math.sin(lon),
|
|
62
|
+
]);
|
|
63
|
+
}),
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const shortName = name =>
|
|
67
|
+
name.length > MAX_NAME ? `${name.slice(0, MAX_NAME - 1)}…` : name;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Culprits as labels on a slowly rotating sphere. Drag to spin it; hovering a
|
|
71
|
+
* label pauses it. The motion is driven straight onto the DOM nodes (no React
|
|
72
|
+
* render per frame) and is off entirely for people who ask for reduced motion.
|
|
73
|
+
*/
|
|
74
|
+
export default function CulpritSphere({
|
|
75
|
+
items,
|
|
76
|
+
activeKey,
|
|
77
|
+
highlightKey,
|
|
78
|
+
onActivate,
|
|
79
|
+
onSelect,
|
|
80
|
+
}) {
|
|
81
|
+
const points = useMemo(() => latticePoints(items.length), [items.length]);
|
|
82
|
+
const maxMs = Math.max(1, ...items.map(i => i.ms));
|
|
83
|
+
const labelRefs = useRef([]);
|
|
84
|
+
const pathRefs = useRef([]);
|
|
85
|
+
const motion = useRef({
|
|
86
|
+
ax: -0.35,
|
|
87
|
+
ay: 0,
|
|
88
|
+
vy: 0,
|
|
89
|
+
vx: 0,
|
|
90
|
+
isDragging: false,
|
|
91
|
+
isPaused: false,
|
|
92
|
+
});
|
|
93
|
+
const drag = useRef({ x: 0, y: 0 });
|
|
94
|
+
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
motion.current.isPaused = Boolean(activeKey);
|
|
97
|
+
}, [activeKey]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const reduced = window.matchMedia(
|
|
101
|
+
'(prefers-reduced-motion: reduce)',
|
|
102
|
+
).matches;
|
|
103
|
+
const paint = () => {
|
|
104
|
+
const { ax, ay } = motion.current;
|
|
105
|
+
points.forEach((p, i) => {
|
|
106
|
+
const el = labelRefs.current[i];
|
|
107
|
+
if (!el) return;
|
|
108
|
+
const q = project(p, ax, ay);
|
|
109
|
+
const depth = (q.z + 1) / 2;
|
|
110
|
+
el.style.left = `${50 + q.x * 50}%`;
|
|
111
|
+
el.style.top = `${50 + q.y * 50}%`;
|
|
112
|
+
el.style.transform = `translate(-50%, -50%) scale(${0.7 + depth * 0.45})`;
|
|
113
|
+
el.style.opacity = String(0.16 + depth * 0.84);
|
|
114
|
+
el.style.zIndex = String(Math.round(depth * 100));
|
|
115
|
+
el.style.pointerEvents = q.z > -0.35 ? 'auto' : 'none';
|
|
116
|
+
});
|
|
117
|
+
WIREFRAME.forEach((ring, i) => {
|
|
118
|
+
const path = pathRefs.current[i];
|
|
119
|
+
if (!path) return;
|
|
120
|
+
path.setAttribute(
|
|
121
|
+
'd',
|
|
122
|
+
ring
|
|
123
|
+
.map((p, j) => {
|
|
124
|
+
const q = project(p, ax, ay);
|
|
125
|
+
return `${j ? 'L' : 'M'}${q.x.toFixed(3)} ${q.y.toFixed(3)}`;
|
|
126
|
+
})
|
|
127
|
+
.join(''),
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
paint();
|
|
132
|
+
// requestAnimationFrame: this is a continuous animation with no event to hook into.
|
|
133
|
+
let frame = 0;
|
|
134
|
+
let last = performance.now();
|
|
135
|
+
const tick = now => {
|
|
136
|
+
const m = motion.current;
|
|
137
|
+
const dt = Math.min(64, now - last);
|
|
138
|
+
last = now;
|
|
139
|
+
const isSpinning = !reduced && !m.isPaused && !m.isDragging;
|
|
140
|
+
const isCoasting = Math.abs(m.vy) > 1e-4 || Math.abs(m.vx) > 1e-4;
|
|
141
|
+
if (isSpinning || isCoasting) {
|
|
142
|
+
m.ay += (isSpinning ? AUTO_SPIN * dt : 0) + m.vy;
|
|
143
|
+
m.ax = Math.max(-MAX_TILT, Math.min(MAX_TILT, m.ax + m.vx));
|
|
144
|
+
if (!m.isDragging) {
|
|
145
|
+
m.vy *= 0.94;
|
|
146
|
+
m.vx *= 0.94;
|
|
147
|
+
}
|
|
148
|
+
paint();
|
|
149
|
+
}
|
|
150
|
+
frame = requestAnimationFrame(tick);
|
|
151
|
+
};
|
|
152
|
+
frame = requestAnimationFrame(tick);
|
|
153
|
+
return () => cancelAnimationFrame(frame);
|
|
154
|
+
}, [points]);
|
|
155
|
+
|
|
156
|
+
const handlePointerDown = e => {
|
|
157
|
+
if (e.target.closest('button')) return;
|
|
158
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
159
|
+
motion.current.isDragging = true;
|
|
160
|
+
drag.current = { x: e.clientX, y: e.clientY };
|
|
161
|
+
};
|
|
162
|
+
const handlePointerMove = e => {
|
|
163
|
+
const m = motion.current;
|
|
164
|
+
if (!m.isDragging) return;
|
|
165
|
+
m.vy = (e.clientX - drag.current.x) * DRAG_SPEED;
|
|
166
|
+
m.vx = -(e.clientY - drag.current.y) * DRAG_SPEED;
|
|
167
|
+
drag.current = { x: e.clientX, y: e.clientY };
|
|
168
|
+
m.ay += m.vy;
|
|
169
|
+
m.ax = Math.max(-MAX_TILT, Math.min(MAX_TILT, m.ax + m.vx));
|
|
170
|
+
};
|
|
171
|
+
const handlePointerUp = () => {
|
|
172
|
+
motion.current.isDragging = false;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div
|
|
177
|
+
className="sphere"
|
|
178
|
+
role="group"
|
|
179
|
+
aria-label="Top culprits on a rotating sphere. Drag to rotate."
|
|
180
|
+
onPointerDown={handlePointerDown}
|
|
181
|
+
onPointerMove={handlePointerMove}
|
|
182
|
+
onPointerUp={handlePointerUp}
|
|
183
|
+
onPointerCancel={handlePointerUp}
|
|
184
|
+
>
|
|
185
|
+
<div className="sphere-glow" aria-hidden="true" />
|
|
186
|
+
<svg className="sphere-wire" viewBox="-1 -1 2 2" aria-hidden="true">
|
|
187
|
+
{WIREFRAME.map((_, i) => (
|
|
188
|
+
<path
|
|
189
|
+
key={i}
|
|
190
|
+
ref={el => {
|
|
191
|
+
pathRefs.current[i] = el;
|
|
192
|
+
}}
|
|
193
|
+
/>
|
|
194
|
+
))}
|
|
195
|
+
</svg>
|
|
196
|
+
{items.map((item, i) => (
|
|
197
|
+
<button
|
|
198
|
+
key={item.key}
|
|
199
|
+
ref={el => {
|
|
200
|
+
labelRefs.current[i] = el;
|
|
201
|
+
}}
|
|
202
|
+
type="button"
|
|
203
|
+
className={`orb sev-${item.severity} ${highlightKey === item.key ? 'active' : ''}`}
|
|
204
|
+
style={{ fontSize: `${0.7 + 0.28 * Math.sqrt(item.ms / maxMs)}rem` }}
|
|
205
|
+
title={item.name}
|
|
206
|
+
onMouseEnter={() => onActivate(item.key)}
|
|
207
|
+
onMouseLeave={() => onActivate(null)}
|
|
208
|
+
onFocus={() => onActivate(item.key)}
|
|
209
|
+
onBlur={() => onActivate(null)}
|
|
210
|
+
onClick={() => onSelect(item.key)}
|
|
211
|
+
>
|
|
212
|
+
<span className="orb-name">{shortName(item.name)}</span>
|
|
213
|
+
<span className="orb-ms">{fmtMs(item.ms)}</span>
|
|
214
|
+
</button>
|
|
215
|
+
))}
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { fmtMs } from '../format.js';
|
|
3
|
+
import CodeRef from './CodeRef.jsx';
|
|
4
|
+
import CulpritsHero from './CulpritsHero.jsx';
|
|
5
|
+
import FindingsList from './FindingsList.jsx';
|
|
6
|
+
import TimeBreakdown from './TimeBreakdown.jsx';
|
|
7
|
+
import Vitals from './Vitals.jsx';
|
|
8
|
+
|
|
9
|
+
function HotFunctions({ report, hideVendor, onOpenFile }) {
|
|
10
|
+
const rows = (report.hotFunctions || [])
|
|
11
|
+
.filter(f => !(hideVendor && f.vendor))
|
|
12
|
+
.slice(0, 15);
|
|
13
|
+
if (!rows.length) return null;
|
|
14
|
+
return (
|
|
15
|
+
<section>
|
|
16
|
+
<h2>
|
|
17
|
+
Slowest functions{' '}
|
|
18
|
+
<span className="muted">(by time spent in the function itself)</span>
|
|
19
|
+
</h2>
|
|
20
|
+
<table className="data hot-functions">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr>
|
|
23
|
+
<th>#</th>
|
|
24
|
+
<th>Function</th>
|
|
25
|
+
<th>File</th>
|
|
26
|
+
<th>Self</th>
|
|
27
|
+
<th>With callees</th>
|
|
28
|
+
<th>% of CPU</th>
|
|
29
|
+
</tr>
|
|
30
|
+
</thead>
|
|
31
|
+
<tbody>
|
|
32
|
+
{rows.map((f, i) => (
|
|
33
|
+
<tr
|
|
34
|
+
key={`${f.file}|${f.name}|${f.bundleLine}`}
|
|
35
|
+
className={f.vendor ? 'vendor-row' : ''}
|
|
36
|
+
>
|
|
37
|
+
<td className="num">{i + 1}</td>
|
|
38
|
+
<td>
|
|
39
|
+
<code className="fn">{f.name}()</code>
|
|
40
|
+
</td>
|
|
41
|
+
<td>
|
|
42
|
+
<CodeRef
|
|
43
|
+
file={f.file}
|
|
44
|
+
line={f.line}
|
|
45
|
+
lineKind={f.lineKind}
|
|
46
|
+
abs={f.abs}
|
|
47
|
+
onOpenFile={onOpenFile}
|
|
48
|
+
/>
|
|
49
|
+
</td>
|
|
50
|
+
<td className="num">{fmtMs(f.selfMs)}</td>
|
|
51
|
+
<td className="num">{fmtMs(f.totalMs)}</td>
|
|
52
|
+
<td className="num">{f.share}%</td>
|
|
53
|
+
</tr>
|
|
54
|
+
))}
|
|
55
|
+
</tbody>
|
|
56
|
+
</table>
|
|
57
|
+
</section>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export default function Culprits({ report, hideVendor, onOpenFile }) {
|
|
62
|
+
const [focus, setFocus] = useState(null);
|
|
63
|
+
const counts = report.findings.reduce(
|
|
64
|
+
(acc, f) => ({ ...acc, [f.severity]: (acc[f.severity] || 0) + 1 }),
|
|
65
|
+
{},
|
|
66
|
+
);
|
|
67
|
+
return (
|
|
68
|
+
<div className="culprits">
|
|
69
|
+
<CulpritsHero
|
|
70
|
+
report={report}
|
|
71
|
+
hideVendor={hideVendor}
|
|
72
|
+
onOpenFile={onOpenFile}
|
|
73
|
+
onJump={id => setFocus(prev => ({ id, tick: (prev?.tick || 0) + 1 }))}
|
|
74
|
+
/>
|
|
75
|
+
<Vitals report={report} />
|
|
76
|
+
<section>
|
|
77
|
+
<h2>Where main-thread time went</h2>
|
|
78
|
+
<TimeBreakdown totals={report.totals} />
|
|
79
|
+
</section>
|
|
80
|
+
<HotFunctions
|
|
81
|
+
report={report}
|
|
82
|
+
hideVendor={hideVendor}
|
|
83
|
+
onOpenFile={onOpenFile}
|
|
84
|
+
/>
|
|
85
|
+
<section>
|
|
86
|
+
<h2>
|
|
87
|
+
Findings{' '}
|
|
88
|
+
<span className="muted">
|
|
89
|
+
{counts.critical || 0} critical · {counts.warning || 0} warnings ·{' '}
|
|
90
|
+
{counts.info || 0} notes
|
|
91
|
+
</span>
|
|
92
|
+
</h2>
|
|
93
|
+
{report.findings.length === 0 ? (
|
|
94
|
+
<p className="muted">
|
|
95
|
+
Nothing stood out in this recording. Try a longer or more demanding
|
|
96
|
+
interaction.
|
|
97
|
+
</p>
|
|
98
|
+
) : (
|
|
99
|
+
<FindingsList
|
|
100
|
+
findings={report.findings}
|
|
101
|
+
onOpenFile={onOpenFile}
|
|
102
|
+
focus={focus}
|
|
103
|
+
/>
|
|
104
|
+
)}
|
|
105
|
+
</section>
|
|
106
|
+
{report.notes.length > 0 && (
|
|
107
|
+
<section>
|
|
108
|
+
<h2>Notes</h2>
|
|
109
|
+
<ul>
|
|
110
|
+
{report.notes.map(n => (
|
|
111
|
+
<li key={n}>{n}</li>
|
|
112
|
+
))}
|
|
113
|
+
</ul>
|
|
114
|
+
</section>
|
|
115
|
+
)}
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|