@parca/profile 0.16.136 → 0.16.138

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.
@@ -15,355 +15,159 @@ import {useEffect, useRef, useState} from 'react';
15
15
 
16
16
  import cx from 'classnames';
17
17
  import * as d3 from 'd3';
18
- import graphviz from 'graphviz-wasm';
19
- import type {KonvaEventObject} from 'konva/lib/Node';
20
- import {Arrow, Label, Layer, Rect, Stage, Text} from 'react-konva';
18
+ import SVG from 'react-inlinesvg';
19
+ import {MapInteractionCSS} from 'react-map-interaction';
21
20
 
22
- import {CallgraphEdge, CallgraphNode, Callgraph as CallgraphType} from '@parca/client';
23
- import {Button, useURLState} from '@parca/components';
24
- import {isSearchMatch, selectQueryParam} from '@parca/functions';
21
+ import {CallgraphEdge, Callgraph as CallgraphType} from '@parca/client';
22
+ import {Button, useKeyDown, useURLState} from '@parca/components';
23
+ import {getNewSpanColor} from '@parca/functions';
24
+ import {selectDarkMode, setHoveringNode, useAppDispatch, useAppSelector} from '@parca/store';
25
25
 
26
- import Tooltip, {type HoveringNode} from '../GraphTooltip';
27
- import {DEFAULT_NODE_HEIGHT, GRAPH_MARGIN} from './constants';
28
- import {getCurvePoints, jsonToDot} from './utils';
26
+ import GraphTooltip from '../GraphTooltip';
29
27
 
30
- interface NodeProps {
31
- node: INode;
32
- hoveredNode: INode | null;
33
- setHoveredNode: (node: INode | null) => void;
34
- isCurrentSearchMatch: boolean;
35
- }
36
- interface EdgeProps {
37
- edge: GraphvizEdge;
38
- sourceNode: {x: number; y: number};
39
- targetNode: {x: number; y: number};
40
- xScale: (x: number) => number;
41
- yScale: (y: number) => number;
42
- isCurrentSearchMatch: boolean;
43
- }
44
28
  export interface Props {
45
- graph: CallgraphType;
29
+ data: CallgraphType;
30
+ svgString: string;
46
31
  sampleUnit: string;
47
32
  width: number;
48
- colorRange: [string, string];
49
- }
50
-
51
- interface GraphvizNode extends CallgraphNode {
52
- _gvid: number;
53
- name: string;
54
- pos: string;
55
- functionName: string;
56
- color: string;
57
- width: string | number;
58
- height: string | number;
59
- }
60
-
61
- interface INode extends GraphvizNode {
62
- x: number;
63
- y: number;
64
- data: {id: string};
65
- mouseX?: number;
66
- mouseY?: number;
67
33
  }
68
34
 
69
- interface GraphvizEdge extends CallgraphEdge {
70
- _gvid: number;
71
- tail: number;
72
- head: number;
73
- pos: string;
74
- color: string;
75
- opacity: string;
76
- boxHeight: number;
35
+ interface View {
36
+ scale: number;
37
+ translation: {x: number; y: number};
77
38
  }
78
39
 
79
- interface GraphvizType {
80
- edges: GraphvizEdge[];
81
- objects: GraphvizNode[];
82
- bb: string;
83
- }
84
-
85
- const Node = ({
86
- node,
87
- hoveredNode,
88
- setHoveredNode,
89
- isCurrentSearchMatch,
90
- }: NodeProps): JSX.Element => {
91
- const {
92
- data: {id},
93
- x,
94
- y,
95
- color,
96
- functionName,
97
- width: widthString,
98
- height: heightString,
99
- } = node;
100
- const isHovered = Boolean(hoveredNode) && hoveredNode?.data.id === id;
101
- const width = Number(widthString);
102
- const height = Number(heightString);
103
- const textPadding = 6;
104
- const opacity = isCurrentSearchMatch ? 1 : 0.1;
105
-
106
- return (
107
- <Label x={x - width / 2} y={y - height / 2}>
108
- <Rect
109
- width={width}
110
- height={height}
111
- fill={color}
112
- opacity={opacity}
113
- cornerRadius={3}
114
- stroke={isHovered ? 'black' : color}
115
- strokeWidth={2}
116
- onMouseOver={e => {
117
- setHoveredNode({...node, mouseX: e.evt.clientX, mouseY: e.evt.clientY});
118
- }}
119
- onMouseOut={() => {
120
- setHoveredNode(null);
121
- }}
122
- />
123
- {width > DEFAULT_NODE_HEIGHT + 10 && (
124
- <Text
125
- text={functionName}
126
- fontSize={10}
127
- fill="white"
128
- width={width - textPadding}
129
- height={height - textPadding}
130
- x={textPadding / 2}
131
- y={textPadding / 2}
132
- align="center"
133
- verticalAlign="middle"
134
- listening={false}
135
- />
136
- )}
137
- </Label>
138
- );
139
- };
140
-
141
- const Edge = ({
142
- edge,
143
- sourceNode,
144
- targetNode,
145
- xScale,
146
- yScale,
147
- isCurrentSearchMatch,
148
- }: EdgeProps): JSX.Element => {
149
- const {pos, color, head, tail, opacity, boxHeight} = edge;
150
-
151
- const points = getCurvePoints({
152
- pos,
153
- xScale,
154
- yScale,
155
- source: [sourceNode.x, sourceNode.y],
156
- target: [targetNode.x, targetNode.y],
157
- offset: boxHeight / 2,
158
- isSelfLoop: head === tail,
159
- });
160
-
161
- return (
162
- <Arrow
163
- points={points}
164
- bezier={true}
165
- stroke={color}
166
- strokeWidth={3}
167
- pointerLength={10}
168
- pointerWidth={10}
169
- fill={color}
170
- opacity={isCurrentSearchMatch ? Number(opacity) : 0}
171
- />
172
- );
173
- };
174
-
175
- const Callgraph = ({graph, sampleUnit, width, colorRange}: Props): JSX.Element => {
176
- const containerRef = useRef<HTMLDivElement>(null);
177
- const [graphData, setGraphData] = useState<any>(null);
178
- const [hoveredNode, setHoveredNode] = useState<INode | null>(null);
179
- const [stage, setStage] = useState<{scale: {x: number; y: number}; x: number; y: number}>({
180
- scale: {x: 1, y: 1},
181
- x: 0,
182
- y: 0,
183
- });
184
- const {nodes: rawNodes, cumulative: total} = graph;
185
- const currentSearchString = (selectQueryParam('search_string') as string) ?? '';
186
- const isSearchEmpty = currentSearchString === undefined || currentSearchString === '';
40
+ const Callgraph = ({data, svgString, sampleUnit, width}: Props): JSX.Element => {
41
+ const originalView = {
42
+ scale: 1,
43
+ translation: {x: 0, y: 0},
44
+ };
45
+ const [view, setView] = useState<View>(originalView);
46
+ const containerRef = useRef(null);
47
+ const svgRef = useRef(null);
48
+ const svgWrapper = useRef(null);
49
+ const [svgWrapperLoaded, setSvgWrapperLoaded] = useState(false);
50
+ const dispatch = useAppDispatch();
51
+ const {isShiftDown} = useKeyDown();
52
+ // TODO: implement highlighting nodes on user search
53
+ // const currentSearchString = (selectQueryParam('search_string') as string) ?? '';
54
+ // const isSearchEmpty = currentSearchString === undefined || currentSearchString === '';
55
+ // const isCurrentSearchMatch = isSearchEmpty
56
+ // ? true
57
+ // : isSearchMatch(currentSearchString, sourceNode.functionName) &&
58
+ // isSearchMatch(currentSearchString, targetNode.functionName);
187
59
  const [rawDashboardItems] = useURLState({param: 'dashboard_items'});
188
-
189
60
  const dashboardItems =
190
61
  rawDashboardItems !== undefined ? (rawDashboardItems as string[]) : ['icicle'];
191
62
 
192
- useEffect(() => {
193
- const getDataWithPositions = async (): Promise<void> => {
194
- // 1. Translate JSON to 'dot' graph string
195
- const dataAsDot = jsonToDot({
196
- graph,
197
- width,
198
- colorRange,
199
- });
200
-
201
- // 2. Use Graphviz-WASM to translate the 'dot' graph to a 'JSON' graph
202
- await graphviz.loadWASM(); // need to load the WASM instance and wait for it
63
+ const isDarkMode = useAppSelector(selectDarkMode);
64
+ const maxColor: string = getNewSpanColor(isDarkMode);
65
+ const minColor: string = d3.scaleLinear([isDarkMode ? 'black' : 'white', maxColor])(0.3);
66
+ const colorRange: [string, string] = [minColor, maxColor];
67
+ const cumulatives = data.edges.map((edge: CallgraphEdge) => parseInt(edge.cumulative));
68
+ const cumulativesRange = d3.extent(cumulatives);
69
+ const colorScale = d3
70
+ .scaleSequentialLog(d3.interpolateBlues)
71
+ .domain([Number(cumulativesRange[0]), Number(cumulativesRange[1])])
72
+ .range(colorRange);
203
73
 
204
- const jsonGraph = graphviz.layout(dataAsDot, 'json', 'dot');
205
-
206
- setGraphData(jsonGraph);
207
- };
208
-
209
- if (width !== null) {
210
- void getDataWithPositions();
211
- }
212
- }, [graph, width, colorRange]);
213
-
214
- // 3. Render the graph with calculated layout in Canvas container
215
- if (width == null || graphData == null) return <></>;
216
- const {objects: gvizNodes, edges, bb: boundingBox} = JSON.parse(graphData) as GraphvizType;
217
-
218
- if (gvizNodes.length < 1) return <>Profile has no samples</>;
219
-
220
- const graphBB = boundingBox.split(',');
221
- const bbWidth = Number(graphBB[2]);
222
- const bbHeight = Number(graphBB[3]);
223
- const height = (width * bbHeight) / bbWidth;
224
- const xScale = d3
225
- .scaleLinear()
226
- .domain([0, bbWidth])
227
- .range([0, width - 2 * GRAPH_MARGIN]);
228
- const yScale = d3
229
- .scaleLinear()
230
- .domain([0, bbHeight])
231
- .range([0, height - 2 * GRAPH_MARGIN]);
232
-
233
- const nodes: INode[] = gvizNodes.map((node: GraphvizNode) => {
234
- const [x, y] = node.pos.split(',');
235
- return {
236
- ...node,
237
- x: xScale(Number(x)),
238
- y: yScale(Number(y)),
239
- data: rawNodes.find(n => n.id === node.name) ?? {id: 'n0'},
240
- };
241
- });
242
-
243
- // 4. Add zooming
244
- const handleWheel: (e: KonvaEventObject<WheelEvent>) => void = e => {
245
- // stop default scrolling
246
- e.evt.preventDefault();
247
-
248
- const scaleBy = 1.01;
249
- const stage = e.target.getStage();
74
+ useEffect(() => {
75
+ setSvgWrapperLoaded(true);
76
+ }, []);
250
77
 
251
- if (stage !== null) {
252
- const oldScale = stage.scaleX();
253
- const pointer = stage.getPointerPosition() ?? {x: 0, y: 0};
254
- const mousePointTo = {
255
- x: pointer.x / oldScale - stage.x() / oldScale,
256
- y: pointer.y / oldScale - stage.y() / oldScale,
78
+ useEffect(() => {
79
+ if (svgWrapperLoaded && svgRef.current !== null) {
80
+ const addInteraction = (): void => {
81
+ const svg = d3.select(svgRef.current);
82
+ const nodes = svg.selectAll('.node');
83
+
84
+ nodes.each(function () {
85
+ const nodeData = data.nodes.find((n): boolean => {
86
+ // @ts-expect-error
87
+ return n.id === this.id;
88
+ });
89
+ const defaultColor = colorScale(Number(nodeData?.cumulative));
90
+ const node = d3.select(this);
91
+ const path = node.select('path');
92
+
93
+ node
94
+ .style('cursor', 'pointer')
95
+ .on('mouseenter', function () {
96
+ if (isShiftDown) return;
97
+ d3.select(this).select('path').style('fill', 'white');
98
+ const hoveringNode = {
99
+ ...nodeData,
100
+ meta: {...nodeData?.meta, lineIndex: 0, locationIndex: 0},
101
+ };
102
+ // @ts-expect-error
103
+ dispatch(setHoveringNode(hoveringNode));
104
+ })
105
+ .on('mouseleave', function () {
106
+ if (isShiftDown) return;
107
+ d3.select(this).select('path').style('fill', defaultColor);
108
+ dispatch(setHoveringNode(undefined));
109
+ });
110
+ path.style('fill', defaultColor);
111
+ });
257
112
  };
258
113
 
259
- // whether to zoom in or out
260
- let direction = e.evt.deltaY > 0 ? 1 : -1;
261
-
262
- // for trackpad, e.evt.ctrlKey is true => in that case, revert direction
263
- if (e.evt.ctrlKey) {
264
- direction = -direction;
265
- }
114
+ setTimeout(addInteraction, 1000);
115
+ }
116
+ // eslint-disable-next-line react-hooks/exhaustive-deps
117
+ }, [svgWrapper.current, svgWrapperLoaded]);
266
118
 
267
- const newScale = direction > 0 ? oldScale * scaleBy : oldScale / scaleBy;
268
- stage.scale({x: newScale, y: newScale});
119
+ if (data.nodes.length < 1) return <>Profile has no samples</>;
269
120
 
270
- setStage({
271
- scale: {x: newScale, y: newScale},
272
- x: -(mousePointTo.x - pointer.x / newScale) * newScale,
273
- y: -(mousePointTo.y - pointer.y / newScale) * newScale,
274
- });
275
- }
276
- };
121
+ const resetView = (): void => setView(originalView);
277
122
 
278
- // 5. Reset zoom
279
- const resetZoom = (): void => {
280
- setStage({
281
- scale: {x: 1, y: 1},
282
- x: 0,
283
- y: 0,
284
- });
285
- };
123
+ const isResetViewButtonEnabled =
124
+ view.scale !== originalView.scale ||
125
+ view.translation.x !== originalView.translation.x ||
126
+ view.translation.y !== originalView.translation.y;
286
127
 
287
128
  return (
288
- <div className="relative">
289
- <div className={`w-[${width}px] h-[${height}px]`} ref={containerRef}>
290
- <Stage
291
- width={width}
292
- height={height}
293
- onWheel={handleWheel}
294
- scaleX={stage.scale.x}
295
- scaleY={stage.scale.y}
296
- x={stage.x}
297
- y={stage.y}
298
- draggable
129
+ <div className="w-full relative">
130
+ <div ref={containerRef} className="w-full overflow-hidden">
131
+ <MapInteractionCSS
132
+ showControls
133
+ minScale={1}
134
+ maxScale={5}
135
+ value={view}
136
+ onChange={(value: View) => setView(value)}
299
137
  >
300
- <Layer offsetX={-GRAPH_MARGIN} offsetY={-GRAPH_MARGIN}>
301
- {edges.map((edge: GraphvizEdge) => {
302
- // 'tail' in graphviz-wasm means 'source' and 'head' means 'target'
303
- const sourceNode = nodes.find(n => n._gvid === edge.tail) ?? {
304
- x: 0,
305
- y: 0,
306
- functionName: '',
307
- };
308
- const targetNode = nodes.find(n => n._gvid === edge.head) ?? {
309
- x: 0,
310
- y: 0,
311
- functionName: '',
312
- };
313
- const isCurrentSearchMatch = isSearchEmpty
314
- ? true
315
- : isSearchMatch(currentSearchString, sourceNode.functionName) &&
316
- isSearchMatch(currentSearchString, targetNode.functionName);
317
- return (
318
- <Edge
319
- key={`edge-${edge.tail}-${edge.head}`}
320
- edge={edge}
321
- xScale={xScale}
322
- yScale={yScale}
323
- sourceNode={sourceNode}
324
- targetNode={targetNode}
325
- isCurrentSearchMatch={isCurrentSearchMatch}
326
- />
327
- );
328
- })}
329
- {nodes.map(node => {
330
- const isCurrentSearchMatch = isSearchEmpty
331
- ? true
332
- : isSearchMatch(currentSearchString, node.functionName);
333
- return (
334
- <Node
335
- key={`node-${node._gvid}`}
336
- node={node}
337
- hoveredNode={hoveredNode}
338
- setHoveredNode={setHoveredNode}
339
- isCurrentSearchMatch={isCurrentSearchMatch}
340
- />
341
- );
342
- })}
343
- </Layer>
344
- </Stage>
345
- <Tooltip
346
- hoveringNode={rawNodes.find(n => n.id === hoveredNode?.data.id) as HoveringNode}
347
- unit={sampleUnit}
348
- total={+total}
349
- isFixed={false}
350
- x={hoveredNode?.mouseX ?? 0}
351
- y={hoveredNode?.mouseY ?? 0}
352
- contextElement={containerRef.current}
353
- />
354
- {stage.scale.x !== 1 && (
355
- <div
356
- className={cx(
357
- dashboardItems.length > 1 ? 'left-[25px]' : 'left-0',
358
- 'w-auto absolute top-[-46px]'
359
- )}
360
- >
361
- <Button variant="neutral" onClick={resetZoom}>
362
- Reset Zoom
363
- </Button>
364
- </div>
138
+ <SVG
139
+ ref={svgWrapper}
140
+ src={svgString}
141
+ width={width}
142
+ height="auto"
143
+ title="Callgraph"
144
+ innerRef={svgRef}
145
+ />
146
+ </MapInteractionCSS>
147
+ {svgRef.current !== null && (
148
+ <GraphTooltip
149
+ type="callgraph"
150
+ unit={sampleUnit}
151
+ total={parseInt(data.cumulative)}
152
+ contextElement={containerRef.current}
153
+ />
365
154
  )}
366
155
  </div>
156
+ <div
157
+ className={cx(
158
+ dashboardItems.length > 1 ? 'left-[25px]' : 'left-0',
159
+ 'w-auto absolute top-[-46px]'
160
+ )}
161
+ >
162
+ <Button
163
+ variant="neutral"
164
+ onClick={resetView}
165
+ className="z-50"
166
+ disabled={!isResetViewButtonEnabled}
167
+ >
168
+ Reset View
169
+ </Button>
170
+ </div>
367
171
  </div>
368
172
  );
369
173
  };
@@ -12,11 +12,10 @@
12
12
  // limitations under the License.
13
13
 
14
14
  import * as d3 from 'd3';
15
+ import {withAlphaHex} from 'with-alpha-hex';
15
16
 
16
17
  import {CallgraphEdge, CallgraphNode} from '@parca/client';
17
18
 
18
- import {DEFAULT_NODE_HEIGHT} from './constants';
19
-
20
19
  export const pixelsToInches = (pixels: number): number => pixels / 96;
21
20
 
22
21
  export const getCurvePoints = ({
@@ -94,28 +93,23 @@ export const jsonToDot = ({
94
93
  colorRange: [string, string];
95
94
  }): string => {
96
95
  const {nodes, edges} = graph;
97
- const cumulatives = nodes.map((node: CallgraphNode) => node.cumulative);
98
- const cumulativesRange = d3.extent(cumulatives).map(value => Number(value));
99
-
96
+ const cumulatives = edges.map((edge: CallgraphEdge) => Number(edge.cumulative));
97
+ const cumulativesRange = d3.extent(cumulatives) as [number, number];
100
98
  const colorScale = d3
101
99
  .scaleSequentialLog(d3.interpolateBlues)
102
100
  .domain(cumulativesRange)
103
101
  .range(colorRange);
104
- const colorOpacityScale = d3.scaleSequentialLog().domain(cumulativesRange).range([0.2, 1]);
105
- const boxWidthScale = d3
106
- .scaleLog()
107
- .domain(cumulativesRange)
108
- .range([DEFAULT_NODE_HEIGHT, DEFAULT_NODE_HEIGHT + 40]);
102
+ const colorOpacityScale = d3.scaleLinear().domain(cumulativesRange).range([0.5, 1]);
109
103
 
110
104
  const nodesAsStrings = nodes.map((node: CallgraphNode) => {
105
+ const rgbColor = colorScale(Number(node.cumulative));
106
+ const hexColor = d3.color(rgbColor)?.formatHex() ?? 'red';
111
107
  const dataAttributes = {
112
- address: node.meta?.location?.address ?? '',
113
- functionName: node.meta?.function?.name ?? '',
114
- cumulative: node.cumulative ?? '',
108
+ label: node.meta?.function?.name.substring(0, 12) ?? '',
115
109
  root: (node.id === 'root').toString(),
116
- // TODO: set box width scale to be based on flat value once we have that value available
117
- width: boxWidthScale(Number(node.cumulative)),
118
- color: colorScale(Number(node.cumulative)),
110
+ fillcolor: hexColor,
111
+ className: 'node',
112
+ id: node.id,
119
113
  };
120
114
 
121
115
  return `"${node.id}" [${objectAsDotAttributes(dataAttributes)}]`;
@@ -124,21 +118,21 @@ export const jsonToDot = ({
124
118
  const edgesAsStrings = edges.map((edge: CallgraphEdge) => {
125
119
  const dataAttributes = {
126
120
  cumulative: edge.cumulative,
127
- color: colorRange[1],
128
- opacity: colorOpacityScale(Number(edge.cumulative)),
129
- boxHeight: DEFAULT_NODE_HEIGHT,
121
+ color: withAlphaHex(colorRange[1], colorOpacityScale(Number(edge.cumulative))),
122
+ className: 'edge',
123
+ // boxHeight: DEFAULT_NODE_HEIGHT,
130
124
  };
131
125
 
132
126
  return `"${edge.source}" -> "${edge.target}" [${objectAsDotAttributes(dataAttributes)}]`;
133
127
  });
134
128
 
135
129
  const graphAsDot = `digraph "callgraph" {
136
- rankdir="BT"
130
+ rankdir="TB"
137
131
  overlap="prism"
138
132
  ratio="1,3"
139
133
  margin=15
140
134
  edge [margin=0]
141
- node [shape=box style=rounded height=${DEFAULT_NODE_HEIGHT}]
135
+ node [shape=box style="rounded,filled"]
142
136
  ${nodesAsStrings.join(' ')}
143
137
  ${edgesAsStrings.join(' ')}
144
138
  }`;