@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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -2
  3. package/bin/spectra.js +215 -0
  4. package/dist/assets/index-BvBsPok5.js +46 -0
  5. package/dist/assets/index-Da3Le5Px.css +1 -0
  6. package/dist/index.html +14 -0
  7. package/index.html +13 -0
  8. package/package.json +76 -4
  9. package/server/analysis/cpu.js +369 -0
  10. package/server/analysis/findings.js +551 -0
  11. package/server/analysis/locate.js +99 -0
  12. package/server/analysis/report.js +750 -0
  13. package/server/analysis/sources.js +249 -0
  14. package/server/analysis/trace.js +314 -0
  15. package/server/api.js +292 -0
  16. package/server/chrome.js +81 -0
  17. package/server/cli.js +94 -0
  18. package/server/config.js +59 -0
  19. package/server/dashboard.js +154 -0
  20. package/server/explore/boot.js +150 -0
  21. package/server/explore/classify.js +169 -0
  22. package/server/explore/discover.js +162 -0
  23. package/server/explore/evaluate-cli.js +101 -0
  24. package/server/explore/evaluate.js +165 -0
  25. package/server/explore/paths.js +105 -0
  26. package/server/explore/runner.js +94 -0
  27. package/server/explore/test-explore.js +139 -0
  28. package/server/explore/test-paths.js +75 -0
  29. package/server/framework.js +67 -0
  30. package/server/git.js +59 -0
  31. package/server/pdf/render.js +60 -0
  32. package/server/pdf/template.js +419 -0
  33. package/server/plugin.js +19 -0
  34. package/server/probe.browser.js +689 -0
  35. package/server/recorder.js +610 -0
  36. package/server/signin.js +27 -0
  37. package/server/test-analysis.js +424 -0
  38. package/server/test-compare.js +473 -0
  39. package/server/test-sources.js +136 -0
  40. package/shared/compare.js +977 -0
  41. package/shared/snapshot.js +77 -0
  42. package/src/App.jsx +361 -0
  43. package/src/api.js +51 -0
  44. package/src/components/AppearanceMenu.jsx +76 -0
  45. package/src/components/CodeRef.jsx +48 -0
  46. package/src/components/ComparePanel.jsx +143 -0
  47. package/src/components/CompareResults.jsx +473 -0
  48. package/src/components/ControlBar.jsx +336 -0
  49. package/src/components/CulpritSphere.jsx +218 -0
  50. package/src/components/Culprits.jsx +118 -0
  51. package/src/components/CulpritsHero.jsx +109 -0
  52. package/src/components/EvaluationPanel.jsx +160 -0
  53. package/src/components/FilePanel.jsx +225 -0
  54. package/src/components/FindingsList.jsx +381 -0
  55. package/src/components/HeatTrees.jsx +222 -0
  56. package/src/components/Icons.jsx +58 -0
  57. package/src/components/LongTasks.jsx +199 -0
  58. package/src/components/NetworkPanel.jsx +98 -0
  59. package/src/components/PageMap.jsx +329 -0
  60. package/src/components/Popover.jsx +51 -0
  61. package/src/components/ReactPanel.jsx +200 -0
  62. package/src/components/SidePicker.jsx +207 -0
  63. package/src/components/SnapshotButton.jsx +140 -0
  64. package/src/components/TimeBreakdown.jsx +45 -0
  65. package/src/components/TraceButton.jsx +76 -0
  66. package/src/components/TreeView.jsx +173 -0
  67. package/src/components/Vitals.jsx +163 -0
  68. package/src/culprits.js +127 -0
  69. package/src/download.js +18 -0
  70. package/src/format.js +26 -0
  71. package/src/heat.js +177 -0
  72. package/src/highlight.js +45 -0
  73. package/src/hooks/useCompare.js +187 -0
  74. package/src/hooks/useSession.js +73 -0
  75. package/src/layout.js +55 -0
  76. package/src/main.jsx +15 -0
  77. package/src/styles.css +2478 -0
  78. package/src/theme.js +75 -0
  79. package/src/themes.css +286 -0
  80. package/vite.config.js +8 -0
@@ -0,0 +1,173 @@
1
+ import { select, zoom, zoomIdentity } from 'd3';
2
+ import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { ROW_HEIGHT } from '../layout.js';
4
+
5
+ const CULL_MARGIN = 200;
6
+
7
+ function linkPath(link) {
8
+ const sx = link.source.px + link.source.labelWidth - 10;
9
+ const sy = link.source.py;
10
+ const tx = link.target.px;
11
+ const ty = link.target.py;
12
+ const mid = (sx + tx) / 2;
13
+ return `M${sx},${sy}C${mid},${sy} ${mid},${ty} ${tx},${ty}`;
14
+ }
15
+
16
+ function TreeNode({ node, isSelected, onActivate, onToggle }) {
17
+ const view = node.view;
18
+ const hasChildren = view.expandable;
19
+ const heat = Math.max(0, Math.min(1, view.heat || 0));
20
+ const cls = [
21
+ 'node',
22
+ view.isDir ? 'dir' : 'file',
23
+ view.vendor ? 'vendor' : '',
24
+ isSelected ? 'selected' : '',
25
+ ]
26
+ .filter(Boolean)
27
+ .join(' ');
28
+ const r = view.r || 5;
29
+ return (
30
+ <g className={cls} transform={`translate(${node.px},${node.py})`}>
31
+ <title>{view.tooltip || node.label}</title>
32
+ <rect
33
+ className="node-hit"
34
+ x={-r}
35
+ y={-ROW_HEIGHT / 2 + 1}
36
+ width={node.labelWidth}
37
+ height={ROW_HEIGHT - 2}
38
+ rx={4}
39
+ />
40
+ <circle
41
+ className="node-dot"
42
+ r={r}
43
+ style={{ '--heat-pct': `${Math.round(12 + heat * 88)}%` }}
44
+ onClick={() => (hasChildren ? onToggle(node) : onActivate(node))}
45
+ />
46
+ {hasChildren && (
47
+ <text className="node-glyph" y={3.5} textAnchor="middle">
48
+ {node.children ? '−' : '+'}
49
+ </text>
50
+ )}
51
+ <text
52
+ className="node-label"
53
+ x={r + 6}
54
+ y={4}
55
+ role="button"
56
+ tabIndex={0}
57
+ onClick={() => onActivate(node)}
58
+ onKeyDown={e => {
59
+ if (e.key === 'Enter' || e.key === ' ') {
60
+ e.preventDefault();
61
+ onActivate(node);
62
+ }
63
+ }}
64
+ >
65
+ {node.label}
66
+ </text>
67
+ </g>
68
+ );
69
+ }
70
+
71
+ /** Pan/zoomable node-link tree. Wheel scrolls, ctrl/cmd + wheel zooms. */
72
+ export default function TreeView({
73
+ layout,
74
+ selectedKey,
75
+ centerKey,
76
+ centerTick,
77
+ anchor = 0.12,
78
+ onActivate,
79
+ onToggle,
80
+ }) {
81
+ const svgRef = useRef(null);
82
+ const zoomRef = useRef(null);
83
+ const sizeRef = useRef({ width: 800, height: 600 });
84
+ const [size, setSize] = useState(sizeRef.current);
85
+ const [transform, setTransform] = useState(zoomIdentity);
86
+
87
+ useEffect(() => {
88
+ const svg = svgRef.current;
89
+ const behavior = zoom()
90
+ .scaleExtent([0.25, 2.5])
91
+ .filter(e => (e.type === 'wheel' ? e.ctrlKey || e.metaKey : !e.button))
92
+ .on('zoom', e => setTransform(e.transform));
93
+ zoomRef.current = behavior;
94
+ select(svg).call(behavior).on('dblclick.zoom', null);
95
+ const onWheel = e => {
96
+ if (e.ctrlKey || e.metaKey) return;
97
+ e.preventDefault();
98
+ select(svg).call(behavior.translateBy, -e.deltaX, -e.deltaY);
99
+ };
100
+ svg.addEventListener('wheel', onWheel, { passive: false });
101
+ const observer = new ResizeObserver(([entry]) => {
102
+ sizeRef.current = {
103
+ width: entry.contentRect.width,
104
+ height: entry.contentRect.height,
105
+ };
106
+ setSize(sizeRef.current);
107
+ });
108
+ observer.observe(svg);
109
+ return () => {
110
+ svg.removeEventListener('wheel', onWheel);
111
+ observer.disconnect();
112
+ select(svg).on('.zoom', null);
113
+ };
114
+ }, []);
115
+
116
+ // Re-centre on an explicit request (new report, search, list click) only.
117
+ useEffect(() => {
118
+ const node = layout.byKey.get(centerKey) || layout.root;
119
+ const { width, height } = sizeRef.current;
120
+ const target = zoomIdentity.translate(
121
+ Math.max(60, width * anchor) - node.px,
122
+ height / 2 - node.py,
123
+ );
124
+ select(svgRef.current)
125
+ .transition()
126
+ .duration(centerTick > 0 ? 300 : 0)
127
+ .call(zoomRef.current.transform, target);
128
+ // eslint-disable-next-line react-hooks/exhaustive-deps
129
+ }, [centerTick]);
130
+
131
+ const visible = useMemo(() => {
132
+ const top = (-transform.y - CULL_MARGIN) / transform.k;
133
+ const bottom = (size.height - transform.y + CULL_MARGIN) / transform.k;
134
+ const inView = n => n.py >= top && n.py <= bottom;
135
+ return {
136
+ nodes: layout.nodes.filter(inView),
137
+ links: layout.links.filter(l => inView(l.source) || inView(l.target)),
138
+ };
139
+ }, [layout, transform, size.height]);
140
+
141
+ return (
142
+ <svg
143
+ ref={svgRef}
144
+ className="tree-svg"
145
+ role="tree"
146
+ aria-label="Performance heat tree"
147
+ >
148
+ <g
149
+ transform={`translate(${transform.x},${transform.y}) scale(${transform.k})`}
150
+ >
151
+ <g className="tree-links">
152
+ {visible.links.map(l => (
153
+ <path
154
+ key={`${l.source.data.id}>${l.target.data.id}`}
155
+ d={linkPath(l)}
156
+ />
157
+ ))}
158
+ </g>
159
+ <g>
160
+ {visible.nodes.map(n => (
161
+ <TreeNode
162
+ key={n.data.id}
163
+ node={n}
164
+ isSelected={n.data.id === selectedKey}
165
+ onActivate={onActivate}
166
+ onToggle={onToggle}
167
+ />
168
+ ))}
169
+ </g>
170
+ </g>
171
+ </svg>
172
+ );
173
+ }
@@ -0,0 +1,163 @@
1
+ import { fmtMs, fmtNumber } from '../format.js';
2
+
3
+ // Web-vitals style thresholds: [good, poor]. `hero` ones get a full card.
4
+ const RATINGS = [
5
+ {
6
+ key: 'tbt',
7
+ label: 'Total blocking time',
8
+ get: r => r.vitals.tbtMs,
9
+ limits: [200, 600],
10
+ fmt: fmtMs,
11
+ hero: true,
12
+ },
13
+ {
14
+ key: 'long',
15
+ label: 'Longest task',
16
+ get: r => r.vitals.maxLongTaskMs,
17
+ limits: [50, 200],
18
+ fmt: fmtMs,
19
+ hero: true,
20
+ },
21
+ {
22
+ key: 'inp',
23
+ label: 'Slowest interaction',
24
+ get: r => r.vitals.inpMs,
25
+ limits: [200, 500],
26
+ fmt: fmtMs,
27
+ hero: true,
28
+ },
29
+ {
30
+ key: 'wasted',
31
+ label: 'Wasted React renders',
32
+ get: r =>
33
+ r.react.detected
34
+ ? (r.react && r.react.components ? r.react.components.reduce((s, c) => s + c.wasted, 0) : 0)
35
+ : null,
36
+ limits: [20, 200],
37
+ fmt: fmtNumber,
38
+ hero: true,
39
+ },
40
+ {
41
+ key: 'lcp',
42
+ label: 'Largest paint',
43
+ get: r => r.vitals.lcpMs,
44
+ limits: [2500, 4000],
45
+ fmt: fmtMs,
46
+ },
47
+ {
48
+ key: 'fcp',
49
+ label: 'First paint',
50
+ get: r => r.vitals.fcpMs,
51
+ limits: [1800, 3000],
52
+ fmt: fmtMs,
53
+ },
54
+ {
55
+ key: 'cls',
56
+ label: 'Layout shift',
57
+ get: r => r.vitals.cls,
58
+ limits: [0.1, 0.25],
59
+ fmt: v => v.toFixed(3),
60
+ },
61
+ {
62
+ key: 'dom',
63
+ label: 'DOM nodes',
64
+ get: r => r.memory.domNodes,
65
+ limits: [1500, 3000],
66
+ fmt: fmtNumber,
67
+ },
68
+ {
69
+ key: 'heap',
70
+ label: 'Heap growth',
71
+ get: r => r.memory.heapGrowthMb,
72
+ limits: [30, 100],
73
+ fmt: v => `${v.toFixed(1)} MB`,
74
+ },
75
+ {
76
+ key: 'forced',
77
+ label: 'Forced reflows',
78
+ get: r => r.totals.forcedLayoutCount + r.totals.forcedStyleCount,
79
+ limits: [10, 100],
80
+ fmt: fmtNumber,
81
+ },
82
+ ];
83
+
84
+ const LABELS = { good: 'Good', warn: 'Needs work', poor: 'Poor', none: 'n/a' };
85
+ const ICONS = { good: '✓', warn: '!', poor: '✕', none: '–' };
86
+ const GAUGE_HEADROOM = 1.5;
87
+
88
+ function rate(value, [good, poor]) {
89
+ if (value == null) return 'none';
90
+ if (value <= good) return 'good';
91
+ return value >= poor ? 'poor' : 'warn';
92
+ }
93
+
94
+ function Gauge({ value, limits }) {
95
+ const max = limits[1] * GAUGE_HEADROOM;
96
+ const pct = n => `${Math.min(100, (n / max) * 100)}%`;
97
+ return (
98
+ <div
99
+ className="gauge"
100
+ aria-hidden="true"
101
+ style={{ '--g': pct(limits[0]), '--p': pct(limits[1]) }}
102
+ >
103
+ <span className="gauge-needle" style={{ left: pct(value) }} />
104
+ </div>
105
+ );
106
+ }
107
+
108
+ function HeroCard({ vital, value, rating }) {
109
+ const [good, poor] = vital.limits;
110
+ return (
111
+ <div className={`vital-hero rating-${rating}`}>
112
+ <div className="vital-label">{vital.label}</div>
113
+ <div className="vital-value">
114
+ {value == null ? '–' : vital.fmt(value)}
115
+ </div>
116
+ <div className="vital-rating">
117
+ <span aria-hidden="true">{ICONS[rating]}</span> {LABELS[rating]}
118
+ </div>
119
+ {value != null && <Gauge value={value} limits={vital.limits} />}
120
+ <div className="vital-target muted">
121
+ Good ≤ {vital.fmt(good)} · Poor ≥ {vital.fmt(poor)}
122
+ </div>
123
+ </div>
124
+ );
125
+ }
126
+
127
+ function Chip({ vital, value, rating }) {
128
+ return (
129
+ <div className={`vital-chip rating-${rating}`}>
130
+ <span className="vital-chip-icon" aria-hidden="true">
131
+ {ICONS[rating]}
132
+ </span>
133
+ <span className="vital-chip-label">{vital.label}</span>
134
+ <b>{value == null ? '–' : vital.fmt(value)}</b>
135
+ <span className="visually-hidden">{LABELS[rating]}</span>
136
+ </div>
137
+ );
138
+ }
139
+
140
+ export default function Vitals({ report }) {
141
+ const rows = RATINGS.map(v => {
142
+ const value = v.get(report);
143
+ return { vital: v, value, rating: rate(value, v.limits) };
144
+ });
145
+ return (
146
+ <div className="vitals-block">
147
+ <div className="vitals-hero">
148
+ {rows
149
+ .filter(r => r.vital.hero)
150
+ .map(r => (
151
+ <HeroCard key={r.vital.key} {...r} />
152
+ ))}
153
+ </div>
154
+ <div className="vitals-secondary">
155
+ {rows
156
+ .filter(r => !r.vital.hero)
157
+ .map(r => (
158
+ <Chip key={r.vital.key} {...r} />
159
+ ))}
160
+ </div>
161
+ </div>
162
+ );
163
+ }
@@ -0,0 +1,127 @@
1
+ export const SEVERITY_RANK = { critical: 3, warning: 2, info: 1 };
2
+
3
+ export const SEVERITY = {
4
+ critical: { label: 'Critical', icon: '✕' },
5
+ warning: { label: 'Warning', icon: '!' },
6
+ info: { label: 'Note', icon: 'i' },
7
+ };
8
+
9
+ const isVendorFile = file => file.includes('node_modules/');
10
+ const higher = (a, b) => (SEVERITY_RANK[b] > SEVERITY_RANK[a] ? b : a);
11
+
12
+ export const culpritKey = c => `${c.fn || c.name}|${c.file}`;
13
+
14
+ /** The finding to quote a fix from: most severe, then biggest impact. */
15
+ function leadFinding(findings) {
16
+ return [...findings].sort(
17
+ (a, b) =>
18
+ SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
19
+ b.impactMs - a.impactMs,
20
+ )[0];
21
+ }
22
+
23
+ /**
24
+ * The functions behind the report, merged across findings: one entry per
25
+ * function with its worst severity, the time it cost and the findings it
26
+ * appears in. Time is the largest figure seen (not a sum) because the same
27
+ * work is counted again by the total-blocking-time finding.
28
+ */
29
+ export function topCulprits(report, hideVendor, limit = 12) {
30
+ const map = new Map();
31
+ const entryFor = (key, base) => {
32
+ let e = map.get(key);
33
+ if (!e) {
34
+ e = {
35
+ ...base,
36
+ key,
37
+ ms: 0,
38
+ severity: 'info',
39
+ findings: [],
40
+ categories: new Set(),
41
+ };
42
+ map.set(key, e);
43
+ }
44
+ return e;
45
+ };
46
+ for (const f of report.findings) {
47
+ for (const c of f.culprits || []) {
48
+ if (!c.file || (hideVendor && isVendorFile(c.file))) continue;
49
+ const e = entryFor(culpritKey(c), {
50
+ name: c.fn || '(unknown)',
51
+ file: c.file,
52
+ line: c.line,
53
+ lineKind: c.lineKind,
54
+ abs: c.abs,
55
+ });
56
+ e.ms = Math.max(e.ms, c.ms || 0);
57
+ e.severity = higher(e.severity, f.severity);
58
+ e.findings.push(f);
59
+ e.categories.add(f.category);
60
+ }
61
+ }
62
+ for (const h of report.hotFunctions || []) {
63
+ if (!h.file || (hideVendor && h.vendor)) continue;
64
+ const e = entryFor(culpritKey({ fn: h.name, file: h.file }), {
65
+ name: h.name,
66
+ file: h.file,
67
+ line: h.line,
68
+ lineKind: h.lineKind,
69
+ abs: h.abs,
70
+ });
71
+ e.ms = Math.max(e.ms, h.selfMs || 0);
72
+ if (!e.findings.length && h.share >= 10) e.severity = 'warning';
73
+ }
74
+ return [...map.values()]
75
+ .sort((a, b) => b.ms - a.ms)
76
+ .slice(0, limit)
77
+ .map(e => ({
78
+ ...e,
79
+ categories: [...e.categories],
80
+ lead: e.findings.length ? leadFinding(e.findings) : null,
81
+ }));
82
+ }
83
+
84
+ /**
85
+ * Collapses findings that blame the same function (typically a dozen long
86
+ * tasks all caused by one library call) into one group, so the list shows
87
+ * root causes instead of repeating them.
88
+ */
89
+ export function groupFindings(findings) {
90
+ const buckets = new Map();
91
+ const order = [];
92
+ for (const f of findings) {
93
+ const first = f.culprits?.[0];
94
+ const key = first?.file ? culpritKey(first) : `solo:${f.id}`;
95
+ if (!buckets.has(key)) {
96
+ buckets.set(key, []);
97
+ order.push(key);
98
+ }
99
+ buckets.get(key).push(f);
100
+ }
101
+ const items = order.map(key => {
102
+ const members = buckets.get(key);
103
+ if (members.length === 1)
104
+ return {
105
+ kind: 'single',
106
+ id: members[0].id,
107
+ severity: members[0].severity,
108
+ impactMs: members[0].impactMs,
109
+ finding: members[0],
110
+ };
111
+ const lead = leadFinding(members);
112
+ return {
113
+ kind: 'group',
114
+ id: `group:${key}`,
115
+ severity: members.reduce((s, m) => higher(s, m.severity), 'info'),
116
+ impactMs: Math.max(...members.map(m => m.impactMs)),
117
+ culprit: lead.culprits[0],
118
+ lead,
119
+ members: [...members].sort((a, b) => b.impactMs - a.impactMs),
120
+ };
121
+ });
122
+ return items.sort(
123
+ (a, b) =>
124
+ SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
125
+ b.impactMs - a.impactMs,
126
+ );
127
+ }
@@ -0,0 +1,18 @@
1
+ /** Saves a Blob through the browser's download mechanism. */
2
+ export function downloadBlob(blob, filename) {
3
+ const url = URL.createObjectURL(blob);
4
+ const link = document.createElement('a');
5
+ link.href = url;
6
+ link.download = filename;
7
+ document.body.appendChild(link);
8
+ link.click();
9
+ link.remove();
10
+ // The download starts asynchronously; releasing the URL immediately can cancel it.
11
+ setTimeout(() => URL.revokeObjectURL(url), 30_000);
12
+ }
13
+
14
+ export const downloadJson = (value, filename) =>
15
+ downloadBlob(
16
+ new Blob([JSON.stringify(value)], { type: 'application/json' }),
17
+ filename,
18
+ );
package/src/format.js ADDED
@@ -0,0 +1,26 @@
1
+ export function fmtMs(ms) {
2
+ if (ms == null) return '–';
3
+ if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
4
+ if (ms >= 100) return `${Math.round(ms)} ms`;
5
+ if (ms >= 1) return `${ms.toFixed(1)} ms`;
6
+ return `${ms.toFixed(2)} ms`;
7
+ }
8
+
9
+ export function fmtBytes(n) {
10
+ if (n == null) return '–';
11
+ if (n < 1024) return `${n} B`;
12
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
13
+ return `${(n / 1024 / 1024).toFixed(2)} MB`;
14
+ }
15
+
16
+ export function fmtNumber(n) {
17
+ return n == null ? '–' : Number(n).toLocaleString();
18
+ }
19
+
20
+ export function splitPath(path) {
21
+ const slash = path.lastIndexOf('/');
22
+ return {
23
+ dir: slash < 0 ? '' : path.slice(0, slash + 1),
24
+ name: path.slice(slash + 1),
25
+ };
26
+ }
package/src/heat.js ADDED
@@ -0,0 +1,177 @@
1
+ import { fmtMs, fmtNumber } from './format.js';
2
+
3
+ export const FILE_METRICS = [
4
+ {
5
+ id: 'selfMs',
6
+ label: 'CPU self time',
7
+ unit: 'ms',
8
+ hint: 'JavaScript executed by functions in this file (excluding callees).',
9
+ },
10
+ {
11
+ id: 'blockMs',
12
+ label: 'Main-thread blocking',
13
+ unit: 'ms',
14
+ hint: 'Self time inside long tasks (>50 ms): what made the page unresponsive.',
15
+ },
16
+ {
17
+ id: 'reactMs',
18
+ label: 'React render time',
19
+ unit: 'ms',
20
+ hint: 'Time React spent rendering components defined in this file (self time).',
21
+ },
22
+ {
23
+ id: 'wasted',
24
+ label: 'Wasted re-renders',
25
+ unit: '',
26
+ hint: 'Renders where props, state and context were identical.',
27
+ },
28
+ {
29
+ id: 'renders',
30
+ label: 'React renders',
31
+ unit: '',
32
+ hint: 'How many times components in this file rendered.',
33
+ },
34
+ {
35
+ id: 'forcedCount',
36
+ label: 'Forced reflows',
37
+ unit: '',
38
+ hint: 'Synchronous layouts/style recalcs triggered by code in this file.',
39
+ },
40
+ ];
41
+
42
+ export const formatMetric = (metric, value) =>
43
+ metric.unit === 'ms' ? fmtMs(value) : fmtNumber(Math.round(value));
44
+
45
+ /**
46
+ * Directory tree of the profiled files, aggregated by `metric` and sorted
47
+ * hottest first. Single-child directory chains are merged.
48
+ */
49
+ export function buildFileTree(files, metric, { hideVendor, rootName = 'target-frontend' } = {}) {
50
+ const root = {
51
+ id: '',
52
+ name: rootName,
53
+ isDir: true,
54
+ children: [],
55
+ parent: null,
56
+ value: 0,
57
+ };
58
+ const dirs = new Map([['', root]]);
59
+ const dirFor = dirPath => {
60
+ let node = dirs.get(dirPath);
61
+ if (node) return node;
62
+ const slash = dirPath.lastIndexOf('/');
63
+ const parent = dirFor(slash < 0 ? '' : dirPath.slice(0, slash));
64
+ node = {
65
+ id: dirPath,
66
+ name: dirPath.slice(slash + 1),
67
+ isDir: true,
68
+ children: [],
69
+ parent,
70
+ value: 0,
71
+ };
72
+ parent.children.push(node);
73
+ dirs.set(dirPath, node);
74
+ return node;
75
+ };
76
+ for (const file of files) {
77
+ if (hideVendor && file.vendor) continue;
78
+ const value = file[metric.id] || 0;
79
+ if (value <= 0) continue;
80
+ const slash = file.path.lastIndexOf('/');
81
+ const parent = dirFor(slash < 0 ? '' : file.path.slice(0, slash));
82
+ parent.children.push({
83
+ id: file.path,
84
+ name: file.path.slice(slash + 1),
85
+ isDir: false,
86
+ file,
87
+ vendor: file.vendor,
88
+ value,
89
+ parent,
90
+ children: [],
91
+ });
92
+ }
93
+ const finish = node => {
94
+ if (!node.isDir) return node.value;
95
+ node.value = node.children.reduce((sum, c) => sum + finish(c), 0);
96
+ node.children.sort((a, b) => b.value - a.value);
97
+ while (
98
+ node.parent &&
99
+ node.children.length === 1 &&
100
+ node.children[0].isDir
101
+ ) {
102
+ const only = node.children[0];
103
+ node.id = only.id;
104
+ node.name = `${node.name}/${only.name}`;
105
+ node.children = only.children;
106
+ node.children.forEach(c => {
107
+ c.parent = node;
108
+ });
109
+ }
110
+ return node.value;
111
+ };
112
+ finish(root);
113
+ const byId = new Map();
114
+ const index = node => {
115
+ byId.set(node.id, node);
116
+ node.children.forEach(index);
117
+ };
118
+ index(root);
119
+ return { root, byId, total: root.value };
120
+ }
121
+
122
+ /** Expand the root and the paths leading to the hottest leaves. */
123
+ export function defaultExpanded(tree, topLeaves = 6) {
124
+ const leaves = [...tree.byId.values()]
125
+ .filter(n => !n.isDir)
126
+ .sort((a, b) => b.value - a.value)
127
+ .slice(0, topLeaves);
128
+ const set = new Set([tree.root.id]);
129
+ for (const leaf of leaves)
130
+ for (let n = leaf.parent; n; n = n.parent) set.add(n.id);
131
+ return set;
132
+ }
133
+
134
+ export function ancestorIds(tree, path) {
135
+ const ids = [];
136
+ for (let n = tree.byId.get(path)?.parent; n; n = n.parent) ids.push(n.id);
137
+ return ids;
138
+ }
139
+
140
+ /** Turn an aggregated node into the shape TreeView renders. */
141
+ export function decorateFileNode(node, tree, metric) {
142
+ const heat = tree.total ? Math.sqrt(node.value / tree.total) : 0;
143
+ return {
144
+ isDir: node.isDir,
145
+ vendor: node.vendor,
146
+ expandable: node.isDir && node.children.length > 0,
147
+ heat,
148
+ r: 4 + heat * 7,
149
+ tooltip: `${node.id || tree.root.name}\n${metric.label}: ${formatMetric(metric, node.value)} (${tree.total ? Math.round((node.value / tree.total) * 100) : 0}% of total)`,
150
+ };
151
+ }
152
+
153
+ /** Heat + label data for the top-down call tree. */
154
+ export function decorateCallNode(node, total) {
155
+ const share = total ? node.totalMs / total : 0;
156
+ const selfShare = total ? node.selfMs / total : 0;
157
+ return {
158
+ isDir: false,
159
+ vendor: node.vendor,
160
+ expandable: node.children.length > 0,
161
+ heat: Math.min(1, Math.sqrt(selfShare) * 1.6),
162
+ r: 4 + Math.sqrt(share) * 6,
163
+ tooltip: `${node.name}\n${node.file}${node.line ? `:${node.line}` : ''}\nself ${fmtMs(node.selfMs)} · total ${fmtMs(node.totalMs)}`,
164
+ };
165
+ }
166
+
167
+ export function defaultCallExpanded(root) {
168
+ const set = new Set();
169
+ const limit = root.totalMs * 0.08;
170
+ const walk = node => {
171
+ if (node.totalMs < limit || !node.children.length) return;
172
+ set.add(node.id);
173
+ node.children.forEach(walk);
174
+ };
175
+ walk(root);
176
+ return set;
177
+ }