@parca/profile 0.19.113 → 0.19.115

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 (86) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/ProfileExplorer/ProfileExplorerSingle.d.ts.map +1 -1
  3. package/dist/ProfileExplorer/ProfileExplorerSingle.js +9 -3
  4. package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.d.ts +31 -0
  5. package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.d.ts.map +1 -0
  6. package/dist/{MetricsGraphStrips/AreaGraph → ProfileFlameChart/SamplesStrips/SamplesGraph}/index.js +32 -60
  7. package/dist/{MetricsGraphStrips/MetricsGraphStrips.stories.d.ts → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.d.ts} +4 -3
  8. package/dist/ProfileFlameChart/SamplesStrips/SamplesStrips.stories.d.ts.map +1 -0
  9. package/dist/{MetricsGraphStrips/MetricsGraphStrips.stories.js → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.js} +5 -4
  10. package/dist/{MetricsGraphStrips → ProfileFlameChart/SamplesStrips}/index.d.ts +5 -4
  11. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -0
  12. package/dist/ProfileFlameChart/SamplesStrips/index.js +145 -0
  13. package/dist/ProfileFlameChart/index.d.ts +20 -0
  14. package/dist/ProfileFlameChart/index.d.ts.map +1 -0
  15. package/dist/ProfileFlameChart/index.js +155 -0
  16. package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
  17. package/dist/ProfileFlameGraph/index.js +0 -1
  18. package/dist/ProfileMetricsGraph/hooks/useQueryRange.d.ts +2 -1
  19. package/dist/ProfileMetricsGraph/hooks/useQueryRange.d.ts.map +1 -1
  20. package/dist/ProfileMetricsGraph/hooks/useQueryRange.js +11 -21
  21. package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
  22. package/dist/ProfileMetricsGraph/index.js +13 -3
  23. package/dist/ProfileSelector/index.d.ts.map +1 -1
  24. package/dist/ProfileSelector/index.js +4 -0
  25. package/dist/ProfileView/components/ActionButtons/GroupByDropdown.d.ts +1 -0
  26. package/dist/ProfileView/components/ActionButtons/GroupByDropdown.d.ts.map +1 -1
  27. package/dist/ProfileView/components/ActionButtons/GroupByDropdown.js +2 -2
  28. package/dist/ProfileView/components/DashboardItems/index.d.ts +5 -4
  29. package/dist/ProfileView/components/DashboardItems/index.d.ts.map +1 -1
  30. package/dist/ProfileView/components/DashboardItems/index.js +4 -3
  31. package/dist/ProfileView/components/GroupByLabelsDropdown/index.d.ts +2 -1
  32. package/dist/ProfileView/components/GroupByLabelsDropdown/index.d.ts.map +1 -1
  33. package/dist/ProfileView/components/GroupByLabelsDropdown/index.js +2 -2
  34. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +1 -1
  35. package/dist/ProfileView/components/Toolbars/index.d.ts +2 -0
  36. package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
  37. package/dist/ProfileView/components/Toolbars/index.js +4 -2
  38. package/dist/ProfileView/hooks/useAutoSelectDimension.d.ts +16 -0
  39. package/dist/ProfileView/hooks/useAutoSelectDimension.d.ts.map +1 -0
  40. package/dist/ProfileView/hooks/useAutoSelectDimension.js +75 -0
  41. package/dist/ProfileView/hooks/useVisualizationState.d.ts +2 -0
  42. package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
  43. package/dist/ProfileView/hooks/useVisualizationState.js +8 -0
  44. package/dist/ProfileView/index.d.ts +1 -1
  45. package/dist/ProfileView/index.d.ts.map +1 -1
  46. package/dist/ProfileView/index.js +7 -4
  47. package/dist/ProfileView/types/visualization.d.ts +15 -3
  48. package/dist/ProfileView/types/visualization.d.ts.map +1 -1
  49. package/dist/ProfileViewWithData.d.ts +2 -1
  50. package/dist/ProfileViewWithData.d.ts.map +1 -1
  51. package/dist/ProfileViewWithData.js +41 -29
  52. package/dist/index.d.ts +1 -0
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +4 -0
  55. package/dist/styles.css +1 -1
  56. package/package.json +8 -7
  57. package/src/ProfileExplorer/ProfileExplorerSingle.tsx +14 -3
  58. package/src/{MetricsGraphStrips/AreaGraph → ProfileFlameChart/SamplesStrips/SamplesGraph}/index.tsx +77 -81
  59. package/src/{MetricsGraphStrips/MetricsGraphStrips.stories.tsx → ProfileFlameChart/SamplesStrips/SamplesStrips.stories.tsx} +7 -6
  60. package/src/ProfileFlameChart/SamplesStrips/index.tsx +317 -0
  61. package/src/ProfileFlameChart/index.tsx +305 -0
  62. package/src/ProfileFlameGraph/index.tsx +0 -1
  63. package/src/ProfileMetricsGraph/hooks/useQueryRange.ts +18 -26
  64. package/src/ProfileMetricsGraph/index.tsx +24 -2
  65. package/src/ProfileSelector/index.tsx +11 -0
  66. package/src/ProfileView/components/ActionButtons/GroupByDropdown.tsx +3 -0
  67. package/src/ProfileView/components/DashboardItems/index.tsx +19 -17
  68. package/src/ProfileView/components/GroupByLabelsDropdown/index.tsx +4 -2
  69. package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +1 -1
  70. package/src/ProfileView/components/Toolbars/index.tsx +18 -1
  71. package/src/ProfileView/hooks/useAutoSelectDimension.ts +90 -0
  72. package/src/ProfileView/hooks/useVisualizationState.ts +17 -0
  73. package/src/ProfileView/index.tsx +16 -2
  74. package/src/ProfileView/types/visualization.ts +17 -3
  75. package/src/ProfileViewWithData.tsx +80 -37
  76. package/src/index.tsx +4 -0
  77. package/dist/MetricsGraphStrips/AreaGraph/Tooltip.d.ts +0 -10
  78. package/dist/MetricsGraphStrips/AreaGraph/Tooltip.d.ts.map +0 -1
  79. package/dist/MetricsGraphStrips/AreaGraph/Tooltip.js +0 -44
  80. package/dist/MetricsGraphStrips/AreaGraph/index.d.ts +0 -21
  81. package/dist/MetricsGraphStrips/AreaGraph/index.d.ts.map +0 -1
  82. package/dist/MetricsGraphStrips/MetricsGraphStrips.stories.d.ts.map +0 -1
  83. package/dist/MetricsGraphStrips/index.d.ts.map +0 -1
  84. package/dist/MetricsGraphStrips/index.js +0 -70
  85. package/src/MetricsGraphStrips/AreaGraph/Tooltip.tsx +0 -83
  86. package/src/MetricsGraphStrips/index.tsx +0 -142
@@ -17,12 +17,18 @@ import {Icon} from '@iconify/react';
17
17
  import cx from 'classnames';
18
18
  import * as d3 from 'd3';
19
19
 
20
- import {NumberDuo} from '../../utils';
21
- import {Tooltip} from './Tooltip';
20
+ import {NumberDuo} from '../../../utils';
22
21
 
23
22
  export interface DataPoint {
24
23
  timestamp: number;
25
24
  value: number;
25
+ sampleCount?: number;
26
+ }
27
+
28
+ interface DragState {
29
+ stripIndex: number;
30
+ startX: number;
31
+ currentX: number;
26
32
  }
27
33
 
28
34
  interface Props {
@@ -36,7 +42,11 @@ interface Props {
36
42
  data: DataPoint[];
37
43
  selectionBounds?: NumberDuo | undefined;
38
44
  setSelectionBounds: (newBounds: NumberDuo | undefined) => void;
39
- valueBounds: NumberDuo;
45
+ stepMs: number;
46
+ onDragStart?: (startX: number) => void;
47
+ dragState?: DragState;
48
+ isAnyDragActive?: boolean;
49
+ timeBounds?: NumberDuo;
40
50
  }
41
51
 
42
52
  const DraggingWindow = ({
@@ -189,7 +199,7 @@ const ZoomWindow = ({
189
199
  );
190
200
  };
191
201
 
192
- export const AreaGraph = ({
202
+ export const SamplesGraph = ({
193
203
  data,
194
204
  height,
195
205
  width,
@@ -200,29 +210,30 @@ export const AreaGraph = ({
200
210
  fill = 'gray',
201
211
  selectionBounds,
202
212
  setSelectionBounds,
203
- valueBounds,
213
+ stepMs,
214
+ onDragStart,
215
+ dragState,
216
+ isAnyDragActive = false,
217
+ timeBounds,
204
218
  }: Props): JSX.Element => {
205
219
  const [mousePosition, setMousePosition] = useState<NumberDuo | undefined>(undefined);
206
- const [dragStart, setDragStart] = useState<number | undefined>(undefined);
207
220
  const [isHoveringDragHandle, setIsHoveringDragHandle] = useState(false);
208
- const [hoverData, setHoverData] = useState<{timestamp: number; value: number} | null>(null);
209
- const [isMouseOverGraph, setIsMouseOverGraph] = useState(false);
210
- const isDragging = dragStart !== undefined;
211
-
212
- // Declare the x (horizontal position) scale.
213
- const x = d3.scaleUtc(d3.extent(data, d => d.timestamp) as NumberDuo, [
214
- marginLeft,
215
- width - marginRight,
216
- ]);
217
-
218
- // Declare the y (vertical position) scale.
219
- const y = d3.scaleLinear([valueBounds[0], valueBounds[1]], [height - marginBottom, marginTop]);
220
- const area = d3
221
- .area<DataPoint>()
222
- .curve(d3.curveMonotoneX)
223
- .x(d => x(d.timestamp))
224
- .y0(y(0))
225
- .y1(d => y(d.value));
221
+
222
+ // use the bounds from props if provided, else compute from data
223
+ const xDomain = timeBounds ?? (d3.extent(data, d => d.timestamp) as NumberDuo);
224
+ const x = d3.scaleUtc(xDomain, [marginLeft, width - marginRight]);
225
+
226
+ // Calculate sample count range for opacity scaling
227
+ const sampleCounts = data.map(d => Number(d.sampleCount ?? 1));
228
+ const maxSampleCount = Math.max(...sampleCounts);
229
+ const minSampleCount = Math.min(...sampleCounts);
230
+
231
+ // Create opacity scale: more samples = higher opacity
232
+ const opacityScale = d3
233
+ .scaleLinear()
234
+ .domain([minSampleCount, maxSampleCount])
235
+ .range([0.5, 1.0])
236
+ .clamp(true);
226
237
 
227
238
  const zoomWindow: NumberDuo | undefined = useMemo(() => {
228
239
  if (selectionBounds === undefined) {
@@ -240,7 +251,11 @@ export const AreaGraph = ({
240
251
  return (
241
252
  <div
242
253
  style={{height, width}}
254
+ className="relative"
243
255
  onMouseMove={e => {
256
+ // Only track hover position when no drag is active anywhere
257
+ if (isAnyDragActive) return;
258
+
244
259
  const [xPos, yPos] = d3.pointer(e);
245
260
 
246
261
  if (
@@ -250,30 +265,13 @@ export const AreaGraph = ({
250
265
  yPos <= height - marginBottom
251
266
  ) {
252
267
  setMousePosition([xPos, yPos]);
253
-
254
- // Find the closest data point
255
- if (!isHoveringDragHandle && !isDragging) {
256
- const xDate = x.invert(xPos);
257
- const bisect = d3.bisector((d: DataPoint) => d.timestamp).left;
258
- const index = bisect(data, xDate.getTime());
259
- const dataPoint = data[index];
260
- if (dataPoint !== undefined) {
261
- setHoverData(dataPoint);
262
- }
263
- }
264
268
  } else {
265
269
  setMousePosition(undefined);
266
- setHoverData(null);
267
270
  }
268
271
  }}
269
- onMouseEnter={() => {
270
- setIsMouseOverGraph(true);
271
- }}
272
272
  onMouseLeave={() => {
273
- setIsMouseOverGraph(false);
273
+ // Only clear hover position, drag is managed by parent
274
274
  setMousePosition(undefined);
275
- setDragStart(undefined);
276
- setHoverData(null);
277
275
  }}
278
276
  onMouseDown={e => {
279
277
  // only left mouse button
@@ -281,45 +279,28 @@ export const AreaGraph = ({
281
279
  return;
282
280
  }
283
281
 
284
- // X/Y coordinate array relative to svg
282
+ // X/Y coordinate array relative to element
285
283
  const rel = d3.pointer(e);
286
-
287
284
  const xCoordinate = rel[0];
288
- const xCoordinateWithoutMargin = xCoordinate - marginLeft;
289
- if (xCoordinateWithoutMargin >= 0) {
290
- setDragStart(xCoordinateWithoutMargin);
285
+
286
+ if (xCoordinate >= 0 && onDragStart !== undefined) {
287
+ onDragStart(xCoordinate);
291
288
  }
292
289
 
293
290
  e.stopPropagation();
294
291
  e.preventDefault();
295
292
  }}
296
- onMouseUp={e => {
297
- if (dragStart === undefined) {
298
- return;
299
- }
300
-
301
- const rel = d3.pointer(e);
302
- const xCoordinate = rel[0];
303
- const xCoordinateWithoutMargin = xCoordinate - marginLeft;
304
- if (xCoordinateWithoutMargin >= 0 && dragStart !== xCoordinateWithoutMargin) {
305
- const start = Math.min(dragStart, xCoordinateWithoutMargin);
306
- const end = Math.max(dragStart, xCoordinateWithoutMargin);
307
- setSelectionBoundsWithScaling([start, end]);
308
- }
309
- setDragStart(undefined);
310
- }}
311
- className="relative"
312
293
  >
313
294
  {/* onHover guide, only visible when hovering and not dragging and not having an active zoom window */}
314
295
  <div
315
296
  style={{height, width: 2, left: mousePosition?.[0] ?? -1}}
316
297
  className={cx('bg-gray-700/75 dark:bg-gray-200/75 absolute top-0', {
317
- hidden: mousePosition === undefined || isDragging || isHoveringDragHandle,
298
+ hidden: mousePosition === undefined || isAnyDragActive || isHoveringDragHandle,
318
299
  })}
319
300
  ></div>
320
301
 
321
302
  {/* drag guide, only visible when dragging */}
322
- <DraggingWindow dragStart={dragStart} currentX={mousePosition?.[0]} />
303
+ <DraggingWindow dragStart={dragState?.startX} currentX={dragState?.currentX} />
323
304
 
324
305
  {/* zoom window */}
325
306
  <ZoomWindow
@@ -329,23 +310,38 @@ export const AreaGraph = ({
329
310
  setIsHoveringDragHandle={setIsHoveringDragHandle}
330
311
  />
331
312
 
332
- {/* Update Tooltip conditional render */}
333
- {mousePosition !== undefined &&
334
- hoverData !== null &&
335
- !isDragging &&
336
- !isHoveringDragHandle &&
337
- isMouseOverGraph && (
338
- <Tooltip
339
- x={mousePosition[0]}
340
- y={mousePosition[1]}
341
- timestamp={hoverData.timestamp}
342
- value={hoverData.value}
343
- containerWidth={width}
344
- />
345
- )}
346
-
347
313
  <svg style={{width: '100%', height: '100%'}}>
348
- <path fill={fill} d={area(data) as string} className="opacity-80" />
314
+ {/* Background for the full strip area */}
315
+ <rect
316
+ x={marginLeft}
317
+ y={0}
318
+ width={width - marginLeft - marginRight}
319
+ height={height}
320
+ fill={fill}
321
+ fillOpacity={0.1}
322
+ />
323
+ <g>
324
+ {data.map((d, i) => {
325
+ const xPosition = x(d.timestamp);
326
+ // Use stepMs for bucket width
327
+ const rectWidth = x(d.timestamp + stepMs) - xPosition;
328
+
329
+ // Calculate opacity based on sample count
330
+ const opacity = opacityScale(Number(d.sampleCount ?? 1));
331
+
332
+ return (
333
+ <rect
334
+ key={i}
335
+ x={xPosition}
336
+ y={0}
337
+ width={rectWidth}
338
+ height={height}
339
+ fill={fill}
340
+ fillOpacity={opacity}
341
+ />
342
+ );
343
+ })}
344
+ </g>
349
345
  </svg>
350
346
  </div>
351
347
  );
@@ -16,9 +16,9 @@ import {useArgs} from '@storybook/preview-api';
16
16
  // eslint-disable-next-line import/named
17
17
  import {Meta} from '@storybook/react';
18
18
 
19
- import {NumberDuo} from '../utils';
20
- import {DataPoint} from './AreaGraph';
21
- import {MetricsGraphStrips} from './index';
19
+ import {NumberDuo} from '../../utils';
20
+ import {DataPoint} from './SamplesGraph';
21
+ import {SamplesStrip} from './index';
22
22
 
23
23
  function seededRandom(seed: number): () => number {
24
24
  return () => {
@@ -39,8 +39,8 @@ for (let i = 0; i < 200; i++) {
39
39
  }
40
40
  }
41
41
  const meta: Meta = {
42
- title: 'components/MetricsGraphStrips',
43
- component: MetricsGraphStrips,
42
+ title: 'components/SamplesStrip',
43
+ component: SamplesStrip,
44
44
  };
45
45
  export default meta;
46
46
 
@@ -53,6 +53,7 @@ export const ThreeCPUStrips = {
53
53
  console.log('onSelectedTimeframe', index, bounds);
54
54
  },
55
55
  bounds: [mockData[0][0].timestamp, mockData[0][mockData[0].length - 1].timestamp],
56
+ stepMs: 100,
56
57
  },
57
58
  render: function Component(args: any): JSX.Element {
58
59
  const [, setArgs] = useArgs();
@@ -62,6 +63,6 @@ export const ThreeCPUStrips = {
62
63
  setArgs({...args, selectedTimeframe: {index, bounds}});
63
64
  };
64
65
 
65
- return <MetricsGraphStrips {...args} onSelectedTimeframe={onSelectedTimeframe} />;
66
+ return <SamplesStrip {...args} onSelectedTimeframe={onSelectedTimeframe} />;
66
67
  },
67
68
  };
@@ -0,0 +1,317 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ import {useMemo, useRef, useState} from 'react';
15
+
16
+ import {Icon} from '@iconify/react';
17
+ import cx from 'classnames';
18
+ import * as d3 from 'd3';
19
+ import isEqual from 'fast-deep-equal';
20
+ import {useIntersectionObserver} from 'usehooks-ts';
21
+
22
+ import {LabelSet} from '@parca/client';
23
+ import {Button} from '@parca/components';
24
+
25
+ import {TimelineGuide} from '../../TimelineGuide';
26
+ import {NumberDuo} from '../../utils';
27
+ import {DataPoint, SamplesGraph} from './SamplesGraph';
28
+
29
+ export type {DataPoint} from './SamplesGraph';
30
+
31
+ interface DragState {
32
+ stripIndex: number;
33
+ startX: number;
34
+ currentX: number;
35
+ }
36
+
37
+ interface Props {
38
+ cpus: LabelSet[];
39
+ data: DataPoint[][];
40
+ selectedTimeframe?: {
41
+ labels: LabelSet;
42
+ bounds: NumberDuo;
43
+ };
44
+ onSelectedTimeframe: (labels: LabelSet, bounds: NumberDuo | undefined) => void;
45
+ width?: number;
46
+ bounds: NumberDuo;
47
+ stepMs: number;
48
+ }
49
+
50
+ export const labelSetToString = (labelSet?: LabelSet): string => {
51
+ if (labelSet === undefined) {
52
+ return '{}';
53
+ }
54
+
55
+ let str = '{';
56
+
57
+ let isFirst = true;
58
+ for (const label of labelSet.labels) {
59
+ if (!isFirst) {
60
+ str += ', ';
61
+ } else {
62
+ isFirst = false;
63
+ }
64
+ str += `${label.name}: ${label.value}`;
65
+ }
66
+
67
+ str += '}';
68
+
69
+ return str;
70
+ };
71
+
72
+ const STRIP_HEIGHT = 24;
73
+ const MAX_VISIBLE_STRIPS = 20;
74
+
75
+ const getTimelineGuideHeight = (cpusCount: number, collapsedCount: number): number => {
76
+ return (STRIP_HEIGHT + 4) * (cpusCount - collapsedCount) + 20 * collapsedCount + 24 - 6;
77
+ };
78
+
79
+ const stickyPx = 0;
80
+
81
+ const SamplesGraphContainer = ({
82
+ isSelected,
83
+ isCollapsed,
84
+ cpu,
85
+ width,
86
+ onToggleCollapse,
87
+ data,
88
+ selectionBounds,
89
+ setSelectionBounds,
90
+ color,
91
+ stepMs,
92
+ onDragStart,
93
+ dragState,
94
+ stripIndex,
95
+ isAnyDragActive,
96
+ timeBounds,
97
+ }: {
98
+ isSelected: boolean;
99
+ isCollapsed: boolean;
100
+ cpu: LabelSet;
101
+ width: number | undefined;
102
+ onToggleCollapse: () => void;
103
+ data: DataPoint[];
104
+ selectionBounds: NumberDuo | undefined;
105
+ setSelectionBounds: (bounds: NumberDuo | undefined) => void;
106
+ color: (label: string) => string;
107
+ stepMs: number;
108
+ onDragStart: (stripIndex: number, startX: number) => void;
109
+ dragState: DragState | undefined;
110
+ stripIndex: number;
111
+ isAnyDragActive: boolean;
112
+ timeBounds: NumberDuo;
113
+ }): JSX.Element => {
114
+ const labelStr = labelSetToString(cpu);
115
+
116
+ const {isIntersecting, ref} = useIntersectionObserver({
117
+ rootMargin: `${stickyPx}px 0px 0px 0px`,
118
+ });
119
+
120
+ const isSticky = useMemo(() => {
121
+ return isSelected && isIntersecting;
122
+ }, [isSelected, isIntersecting]);
123
+
124
+ return (
125
+ <div
126
+ className={cx('min-h-5', {
127
+ relative: !isSelected,
128
+ 'sticky z-30 bg-white dark:bg-black bg-opacity-75': isSelected,
129
+ '!bg-opacity-100': isSticky,
130
+ })}
131
+ style={{width: width ?? 1468, top: isSelected ? stickyPx : undefined}}
132
+ key={labelStr}
133
+ ref={ref}
134
+ >
135
+ <div
136
+ className="text-xs absolute top-0 left-0 flex gap-[2px] items-center bg-white/50 dark:bg-black/50 px-1 rounded-sm cursor-pointer"
137
+ style={{
138
+ zIndex: 15,
139
+ }}
140
+ onClick={onToggleCollapse}
141
+ >
142
+ <Icon icon={isCollapsed ? 'bxs:right-arrow' : 'bxs:down-arrow'} />
143
+ {labelStr}
144
+ </div>
145
+ {!isCollapsed ? (
146
+ <SamplesGraph
147
+ data={data}
148
+ height={STRIP_HEIGHT}
149
+ width={width ?? 1468}
150
+ fill={color(labelStr)}
151
+ selectionBounds={selectionBounds}
152
+ setSelectionBounds={setSelectionBounds}
153
+ stepMs={stepMs}
154
+ onDragStart={(startX: number) => onDragStart(stripIndex, startX)}
155
+ dragState={dragState?.stripIndex === stripIndex ? dragState : undefined}
156
+ isAnyDragActive={isAnyDragActive}
157
+ timeBounds={timeBounds}
158
+ />
159
+ ) : null}
160
+ </div>
161
+ );
162
+ };
163
+
164
+ export const SamplesStrip = ({
165
+ cpus,
166
+ data,
167
+ selectedTimeframe,
168
+ onSelectedTimeframe,
169
+ width,
170
+ bounds,
171
+ stepMs,
172
+ }: Props): JSX.Element => {
173
+ const [collapsedLabels, setCollapsedLabels] = useState<Set<string>>(new Set());
174
+ const [showAll, setShowAll] = useState(false);
175
+ const [dragState, setDragState] = useState<DragState | undefined>(undefined);
176
+ const containerRef = useRef<HTMLDivElement>(null);
177
+
178
+ const isDragging = dragState !== undefined;
179
+
180
+ // Sort cpus and data by label string for consistent ordering across reloads
181
+ const sortedItems = useMemo(() => {
182
+ const items = cpus.map((cpu, i) => ({
183
+ cpu,
184
+ data: data[i],
185
+ label: labelSetToString(cpu),
186
+ }));
187
+ return items.sort((a, b) => a.label.localeCompare(b.label));
188
+ }, [cpus, data]);
189
+
190
+ const hasMore = useMemo(() => sortedItems.length > MAX_VISIBLE_STRIPS, [sortedItems]);
191
+ const visibleItems = useMemo(
192
+ () => (showAll || !hasMore ? sortedItems : sortedItems.slice(0, MAX_VISIBLE_STRIPS)),
193
+ [sortedItems, showAll, hasMore]
194
+ );
195
+
196
+ // Deterministic color: hash the label string so the same label always gets the same color
197
+ // regardless of render order.
198
+ const color = useMemo(() => {
199
+ const palette = d3.schemeObservable10;
200
+ const hashStr = (s: string): number => {
201
+ let h = 0;
202
+ for (let i = 0; i < s.length; i++) {
203
+ h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
204
+ }
205
+ return Math.abs(h);
206
+ };
207
+ return (label: string): string => palette[hashStr(label) % palette.length];
208
+ }, []);
209
+
210
+ const handleDragStart = (stripIndex: number, startX: number): void => {
211
+ setDragState({stripIndex, startX, currentX: startX});
212
+ };
213
+
214
+ const handleMouseMove = (e: React.MouseEvent): void => {
215
+ if (dragState === undefined || containerRef.current === null) return;
216
+
217
+ const rect = containerRef.current.getBoundingClientRect();
218
+ const x = e.clientX - rect.left;
219
+ // Clamp to container bounds
220
+ const clampedX = Math.max(0, Math.min(x, width ?? rect.width));
221
+ setDragState({...dragState, currentX: clampedX});
222
+ };
223
+
224
+ const handleMouseUp = (e: React.MouseEvent): void => {
225
+ if (dragState === undefined || containerRef.current === null) return;
226
+
227
+ const rect = containerRef.current.getBoundingClientRect();
228
+ const x = e.clientX - rect.left;
229
+ const clampedX = Math.max(0, Math.min(x, width ?? rect.width));
230
+
231
+ const {stripIndex, startX} = dragState;
232
+ if (startX !== clampedX) {
233
+ const start = Math.min(startX, clampedX);
234
+ const end = Math.max(startX, clampedX);
235
+ // Convert pixel positions to timestamps
236
+ const innerWidth = width ?? rect.width;
237
+ const startTs = bounds[0] + (start / innerWidth) * (bounds[1] - bounds[0]);
238
+ const endTs = bounds[0] + (end / innerWidth) * (bounds[1] - bounds[0]);
239
+ onSelectedTimeframe(visibleItems[stripIndex].cpu, [startTs, endTs]);
240
+ }
241
+
242
+ setDragState(undefined);
243
+ };
244
+
245
+ const handleMouseLeave = (): void => {
246
+ setDragState(undefined);
247
+ };
248
+
249
+ if (data.length === 0) {
250
+ return (
251
+ <span className="flex justify-center my-10">
252
+ There is no data matching your filter criteria, please try changing the filter.
253
+ </span>
254
+ );
255
+ }
256
+
257
+ return (
258
+ <div
259
+ ref={containerRef}
260
+ className={cx('flex flex-col gap-1 relative my-0', {'cursor-ew-resize': isDragging})}
261
+ style={{width: width ?? '100%'}}
262
+ onMouseMove={handleMouseMove}
263
+ onMouseUp={handleMouseUp}
264
+ onMouseLeave={handleMouseLeave}
265
+ >
266
+ <TimelineGuide
267
+ bounds={[BigInt(0), BigInt(bounds[1] - bounds[0])]}
268
+ width={width ?? 1468}
269
+ height={getTimelineGuideHeight(
270
+ visibleItems.length,
271
+ [...collapsedLabels].filter(l => visibleItems.some(item => item.label === l)).length
272
+ )}
273
+ margin={1}
274
+ />
275
+ {visibleItems.map((item, i) => {
276
+ const isCollapsed = collapsedLabels.has(item.label);
277
+ const isSelected = isEqual(item.cpu, selectedTimeframe?.labels);
278
+
279
+ return (
280
+ <SamplesGraphContainer
281
+ isSelected={isSelected}
282
+ isCollapsed={isCollapsed}
283
+ cpu={item.cpu}
284
+ width={width}
285
+ data={item.data}
286
+ onToggleCollapse={() => {
287
+ const newCollapsedLabels = new Set(collapsedLabels);
288
+ if (collapsedLabels.has(item.label)) {
289
+ newCollapsedLabels.delete(item.label);
290
+ } else {
291
+ newCollapsedLabels.add(item.label);
292
+ }
293
+ setCollapsedLabels(newCollapsedLabels);
294
+ }}
295
+ selectionBounds={isSelected ? selectedTimeframe?.bounds : undefined}
296
+ setSelectionBounds={newBounds => {
297
+ onSelectedTimeframe(item.cpu, newBounds);
298
+ }}
299
+ color={color}
300
+ stepMs={stepMs}
301
+ onDragStart={handleDragStart}
302
+ dragState={dragState}
303
+ stripIndex={i}
304
+ isAnyDragActive={isDragging}
305
+ timeBounds={bounds}
306
+ key={item.label}
307
+ />
308
+ );
309
+ })}
310
+ {hasMore && !showAll && (
311
+ <Button variant="secondary" onClick={() => setShowAll(true)} className="w-fit mx-auto mt-2">
312
+ Show all {sortedItems.length} rows
313
+ </Button>
314
+ )}
315
+ </div>
316
+ );
317
+ };