@kylincloud/flamegraph 0.35.22 → 0.35.24

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 (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/FlameGraph/FlameGraphComponent/Flamegraph.d.ts +6 -1
  3. package/dist/FlameGraph/FlameGraphComponent/Flamegraph.d.ts.map +1 -1
  4. package/dist/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.d.ts +16 -0
  5. package/dist/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.d.ts.map +1 -0
  6. package/dist/FlameGraph/FlameGraphComponent/Flamegraph_render.d.ts +8 -0
  7. package/dist/FlameGraph/FlameGraphComponent/Flamegraph_render.d.ts.map +1 -1
  8. package/dist/FlameGraph/FlameGraphComponent/index.d.ts +13 -0
  9. package/dist/FlameGraph/FlameGraphComponent/index.d.ts.map +1 -1
  10. package/dist/FlameGraph/FlameGraphRenderer.d.ts +13 -1
  11. package/dist/FlameGraph/FlameGraphRenderer.d.ts.map +1 -1
  12. package/dist/ProfilerTable.d.ts.map +1 -1
  13. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  14. package/dist/format/format.d.ts +14 -0
  15. package/dist/format/format.d.ts.map +1 -1
  16. package/dist/i18n.d.ts +9 -0
  17. package/dist/i18n.d.ts.map +1 -1
  18. package/dist/index.cjs.js +3 -3
  19. package/dist/index.cjs.js.map +1 -1
  20. package/dist/index.esm.js +4 -4
  21. package/dist/index.esm.js.map +1 -1
  22. package/dist/index.node.cjs.js +4 -4
  23. package/dist/index.node.cjs.js.map +1 -1
  24. package/dist/index.node.esm.js +4 -4
  25. package/dist/index.node.esm.js.map +1 -1
  26. package/dist/shims/Table.d.ts.map +1 -1
  27. package/package.json +1 -1
  28. package/src/FlameGraph/FlameGraphComponent/Flamegraph.ts +7 -2
  29. package/src/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.module.scss +106 -0
  30. package/src/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.tsx +98 -0
  31. package/src/FlameGraph/FlameGraphComponent/Flamegraph_render.ts +54 -23
  32. package/src/FlameGraph/FlameGraphComponent/index.tsx +46 -3
  33. package/src/FlameGraph/FlameGraphRenderer.tsx +211 -6
  34. package/src/ProfilerTable.module.scss +1 -2
  35. package/src/ProfilerTable.tsx +52 -46
  36. package/src/Tooltip/Tooltip.tsx +1 -75
  37. package/src/format/format.ts +98 -0
  38. package/src/i18n.tsx +39 -0
  39. package/src/shims/Table.module.scss +9 -2
  40. package/src/shims/Table.tsx +44 -40
@@ -30,6 +30,15 @@ import { ViewTypes } from './FlameGraphComponent/viewTypes';
30
30
  import { GraphVizPane } from './FlameGraphComponent/GraphVizPane';
31
31
  import { isSameFlamebearer } from './uniqueness';
32
32
  import { normalize } from './normalize';
33
+ import {
34
+ formatSampleCount,
35
+ getFormatter,
36
+ localizeDurationString,
37
+ } from '../format/format';
38
+ import {
39
+ FlamegraphI18nContext,
40
+ type FlamegraphMessages,
41
+ } from '../i18n';
33
42
 
34
43
  // Refers to a node in the flamegraph
35
44
  interface Node {
@@ -92,6 +101,9 @@ class FlameGraphRenderer extends Component<
92
101
  FlamegraphRendererProps,
93
102
  FlamegraphRendererState
94
103
  > {
104
+ static contextType = FlamegraphI18nContext;
105
+ declare context: FlamegraphMessages;
106
+
95
107
  resetFlamegraphState = {
96
108
  focusedNode: Maybe.nothing<Node>(),
97
109
  zoom: Maybe.nothing<Node>(),
@@ -275,6 +287,55 @@ class FlameGraphRenderer extends Component<
275
287
  });
276
288
  };
277
289
 
290
+ /**
291
+ * 检查指定名称的函数是否在当前聚焦节点的子树中
292
+ */
293
+ isNameInFocusedSubtree = (name: string): boolean => {
294
+ const { focusedNode } = this.state.flamegraphConfigs;
295
+
296
+ // 如果没有聚焦节点,任何 name 都算"在子树中"
297
+ if (focusedNode.isNothing) {
298
+ return true;
299
+ }
300
+
301
+ const { flamebearer } = this.state;
302
+ if (!flamebearer || !flamebearer.levels) {
303
+ return true;
304
+ }
305
+
306
+ const { names, levels, format } = flamebearer;
307
+ const ff = createFF(format);
308
+ const focusedI = (focusedNode as any).value.i;
309
+ const focusedJ = (focusedNode as any).value.j;
310
+
311
+ // 获取聚焦节点的范围
312
+ const focusedLevel = levels[focusedI];
313
+ if (!focusedLevel) return true;
314
+
315
+ const focusedOffset = ff.getBarOffset(focusedLevel, focusedJ);
316
+ const focusedTotal = ff.getBarTotal(focusedLevel, focusedJ);
317
+ const focusedEnd = focusedOffset + focusedTotal;
318
+
319
+ // 遍历聚焦节点及其下面的所有层级,检查是否有匹配的函数名
320
+ for (let i = focusedI; i < levels.length; i++) {
321
+ const level = levels[i];
322
+ for (let j = 0; j < level.length; j += ff.jStep) {
323
+ const offset = ff.getBarOffset(level, j);
324
+ const total = ff.getBarTotal(level, j);
325
+
326
+ // 检查这个节点是否在聚焦范围内
327
+ if (offset >= focusedOffset && offset + total <= focusedEnd) {
328
+ const nameIndex = ff.getBarName(level, j);
329
+ if (names[nameIndex] === name) {
330
+ return true;
331
+ }
332
+ }
333
+ }
334
+ }
335
+
336
+ return false;
337
+ };
338
+
278
339
  setActiveItem = (item: { name: string }) => {
279
340
  const { name } = item;
280
341
 
@@ -288,10 +349,25 @@ class FlameGraphRenderer extends Component<
288
349
  }
289
350
  }
290
351
 
291
- // clicking for the first time
292
- this.setState({
293
- selectedItem: Maybe.just(name),
294
- });
352
+ // 检查选中的 item 是否在当前聚焦的子树中
353
+ // 如果不在,需要重置聚焦状态,以便在火焰图中正确显示高亮
354
+ const isInSubtree = this.isNameInFocusedSubtree(name);
355
+
356
+ if (isInSubtree) {
357
+ // 在子树中,只更新选中项
358
+ this.setState({
359
+ selectedItem: Maybe.just(name),
360
+ });
361
+ } else {
362
+ // 不在子树中,重置聚焦并更新选中项
363
+ this.setState({
364
+ selectedItem: Maybe.just(name),
365
+ flamegraphConfigs: {
366
+ ...this.state.flamegraphConfigs,
367
+ focusedNode: this.resetFlamegraphState.focusedNode,
368
+ },
369
+ });
370
+ }
295
371
  };
296
372
 
297
373
  getHighlightQuery = () => {
@@ -331,6 +407,66 @@ class FlameGraphRenderer extends Component<
331
407
  });
332
408
  };
333
409
 
410
+ clearFocus = () => {
411
+ this.setState((prevState) => ({
412
+ ...prevState,
413
+ flamegraphConfigs: {
414
+ ...prevState.flamegraphConfigs,
415
+ ...this.resetFlamegraphState,
416
+ },
417
+ }));
418
+ };
419
+
420
+ getActiveRange = () => {
421
+ const { flamebearer } = this.state;
422
+ const { zoom, focusedNode } = this.state.flamegraphConfigs;
423
+ const totalTicks = flamebearer.numTicks;
424
+
425
+ if (!totalTicks) {
426
+ return { rangeMin: 0, rangeMax: 1 };
427
+ }
428
+
429
+ const ff = createFF(flamebearer.format);
430
+
431
+ const getRangeFromNode = (node: Node) => {
432
+ const level = flamebearer.levels[node.i];
433
+ if (!level) {
434
+ return { rangeMin: 0, rangeMax: 1 };
435
+ }
436
+
437
+ const offset = ff.getBarOffset(level, node.j);
438
+ const total = ff.getBarTotal(level, node.j);
439
+ return {
440
+ rangeMin: offset / totalTicks,
441
+ rangeMax: (offset + total) / totalTicks,
442
+ };
443
+ };
444
+
445
+ return zoom.match({
446
+ Just: (z) => {
447
+ return focusedNode.match({
448
+ Just: (f) => {
449
+ const focusRange = getRangeFromNode(f);
450
+ const zoomRange = getRangeFromNode(z);
451
+ if (
452
+ focusRange.rangeMax - focusRange.rangeMin <
453
+ zoomRange.rangeMax - zoomRange.rangeMin
454
+ ) {
455
+ return focusRange;
456
+ }
457
+ return zoomRange;
458
+ },
459
+ Nothing: () => getRangeFromNode(z),
460
+ });
461
+ },
462
+ Nothing: () =>
463
+ focusedNode.match({
464
+ Just: (f) => getRangeFromNode(f),
465
+ Nothing: () => ({ rangeMin: 0, rangeMax: 1 }),
466
+ }),
467
+ });
468
+ };
469
+
334
470
  // used as a variable instead of keeping in the state
335
471
  // so that the flamegraph doesn't rerender unnecessarily
336
472
  isDirty = () => {
@@ -381,6 +517,60 @@ class FlameGraphRenderer extends Component<
381
517
 
382
518
  const toolbarVisible = this.shouldShowToolbar();
383
519
 
520
+ const dataUnavailable =
521
+ !this.state.flamebearer || this.state.flamebearer.names.length <= 1;
522
+ const i18n = this.context;
523
+ const isZh = i18n.location !== 'Location';
524
+ const totalTicks = this.state.flamebearer.numTicks;
525
+ const formatter = getFormatter(
526
+ totalTicks,
527
+ this.state.flamebearer.sampleRate,
528
+ this.state.flamebearer.units
529
+ );
530
+ const totalValueRaw = formatter.format(
531
+ totalTicks,
532
+ this.state.flamebearer.sampleRate
533
+ );
534
+ const totalValueText =
535
+ localizeDurationString(totalValueRaw, isZh) || totalValueRaw;
536
+ const totalSamplesText = formatSampleCount(
537
+ totalTicks,
538
+ i18n.sampleCountFormat
539
+ );
540
+ const samplesLabel = i18n.tooltipSamples.replace(/[::]\s*$/, '');
541
+ const unitLabel =
542
+ i18n.tooltipUnitTitles[this.state.flamebearer.units]?.formattedValue ||
543
+ i18n.tooltipUnitTitles.unknown.formattedValue;
544
+ const activeRange = this.getActiveRange();
545
+ const hasFocus =
546
+ activeRange.rangeMin > 0 || activeRange.rangeMax < 1;
547
+ const activeRangeTicks = Math.max(
548
+ 0,
549
+ Math.min(
550
+ totalTicks,
551
+ (activeRange.rangeMax - activeRange.rangeMin) * totalTicks
552
+ )
553
+ );
554
+ const focusPercent =
555
+ totalTicks > 0 ? (activeRangeTicks / totalTicks) * 100 : 0;
556
+ const focusLabel = (() => {
557
+ const ff = createFF(this.state.flamebearer.format);
558
+ const focusNode = this.state.flamegraphConfigs.focusedNode.mapOrElse(
559
+ () =>
560
+ this.state.flamegraphConfigs.zoom.mapOrElse(() => null, (z) => z),
561
+ (f) => f
562
+ );
563
+ if (!focusNode) {
564
+ return '';
565
+ }
566
+ const level = this.state.flamebearer.levels[focusNode.i];
567
+ if (!level) {
568
+ return '';
569
+ }
570
+ const nameIndex = ff.getBarName(level, focusNode.j);
571
+ return this.state.flamebearer.names[nameIndex] || '';
572
+ })();
573
+
384
574
  const flameGraphPane = (
385
575
  <Graph
386
576
  key="flamegraph-pane"
@@ -403,6 +593,23 @@ class FlameGraphRenderer extends Component<
403
593
  toolbarVisible={toolbarVisible}
404
594
  setPalette={this.handleSetPalette}
405
595
  enableSandwichView={this.props.enableSandwichView}
596
+ breadcrumb={
597
+ dataUnavailable
598
+ ? undefined
599
+ : {
600
+ totalValueText,
601
+ totalSamplesText,
602
+ samplesLabel,
603
+ unitLabel,
604
+ hasFocus,
605
+ focusPercent,
606
+ focusLabel,
607
+ ofTotalPlacement: isZh ? 'before' : 'after',
608
+ ofTotalLabel: i18n.ofTotal,
609
+ clearFocusLabel: i18n.clearFocus,
610
+ onClearFocus: this.clearFocus,
611
+ }
612
+ }
406
613
  />
407
614
  );
408
615
 
@@ -510,8 +717,6 @@ class FlameGraphRenderer extends Component<
510
717
  // // rightTicks?: number;
511
718
  // } & addTicks;
512
719
 
513
- const dataUnavailable =
514
- !this.state.flamebearer || this.state.flamebearer.names.length <= 1;
515
720
  const panes = decidePanesOrder(
516
721
  this.state.view,
517
722
  flameGraphPane,
@@ -1,7 +1,6 @@
1
1
  // src/ProfilerTable.module.scss
2
2
 
3
3
  .tableContextMenu {
4
-
5
4
  // 继承 react-menu 的基础样式
6
5
  :global(.szh-menu) {
7
6
  background-color: var(--ps-ui-background, #fff);
@@ -40,4 +39,4 @@
40
39
  width: 16px;
41
40
  height: 16px;
42
41
  }
43
- }
42
+ }
@@ -426,11 +426,11 @@ const getTableBody = ({
426
426
  return rows.length > 0
427
427
  ? { bodyRows: rows, type: 'filled' as const }
428
428
  : {
429
- value: (
430
- <div className="unsupported-format">{messages.noItemsFound}</div>
431
- ),
432
- type: 'not-filled' as const,
433
- };
429
+ value: (
430
+ <div className="unsupported-format">{messages.noItemsFound}</div>
431
+ ),
432
+ type: 'not-filled' as const,
433
+ };
434
434
  };
435
435
 
436
436
  export interface ProfilerTableProps {
@@ -469,9 +469,15 @@ function Table({
469
469
  const [anchorPoint, setAnchorPoint] = useState({ x: 0, y: 0 });
470
470
  const [contextMenuTarget, setContextMenuTarget] = useState<string | null>(null);
471
471
 
472
- // 双击处理:聚焦到火焰图
472
+ // 双击处理:高亮 + 聚焦到火焰图
473
473
  const handleDoubleClick = useCallback(
474
474
  (name: string) => {
475
+ // 先确保该项被高亮(如果未高亮则高亮,如果已高亮则保持)
476
+ if (!selectedItem.isJust || selectedItem.value !== name) {
477
+ handleTableItemClick({ name });
478
+ }
479
+
480
+ // 然后聚焦到火焰图
475
481
  if (!onFocusOnNode) return;
476
482
 
477
483
  const node = findNodeByName(flamebearer, name);
@@ -479,7 +485,7 @@ function Table({
479
485
  onFocusOnNode(node.i, node.j);
480
486
  }
481
487
  },
482
- [flamebearer, onFocusOnNode]
488
+ [flamebearer, onFocusOnNode, selectedItem, handleTableItemClick]
483
489
  );
484
490
 
485
491
  // 右键菜单处理
@@ -535,46 +541,46 @@ function Table({
535
541
  () =>
536
542
  isDoubles
537
543
  ? [
538
- {
539
- sortable: 1,
540
- name: 'name' as const,
541
- label: i18n.location,
542
- },
543
- {
544
- sortable: 1,
545
- name: 'baseline' as const,
546
- label: i18n.baseline,
547
- default: true,
548
- },
549
- {
550
- sortable: 1,
551
- name: 'comparison' as const,
552
- label: i18n.comparison,
553
- },
554
- {
555
- sortable: 1,
556
- name: 'diff' as const,
557
- label: i18n.diff,
558
- },
559
- ]
544
+ {
545
+ sortable: 1,
546
+ name: 'name' as const,
547
+ label: i18n.location,
548
+ },
549
+ {
550
+ sortable: 1,
551
+ name: 'baseline' as const,
552
+ label: i18n.baseline,
553
+ default: true,
554
+ },
555
+ {
556
+ sortable: 1,
557
+ name: 'comparison' as const,
558
+ label: i18n.comparison,
559
+ },
560
+ {
561
+ sortable: 1,
562
+ name: 'diff' as const,
563
+ label: i18n.diff,
564
+ },
565
+ ]
560
566
  : [
561
- {
562
- sortable: 1,
563
- name: 'name' as const,
564
- label: i18n.location,
565
- },
566
- {
567
- sortable: 1,
568
- name: 'self' as const,
569
- label: i18n.self,
570
- default: true,
571
- },
572
- {
573
- sortable: 1,
574
- name: 'total' as const,
575
- label: i18n.total,
576
- },
577
- ],
567
+ {
568
+ sortable: 1,
569
+ name: 'name' as const,
570
+ label: i18n.location,
571
+ },
572
+ {
573
+ sortable: 1,
574
+ name: 'self' as const,
575
+ label: i18n.self,
576
+ default: true,
577
+ },
578
+ {
579
+ sortable: 1,
580
+ name: 'total' as const,
581
+ label: i18n.total,
582
+ },
583
+ ],
578
584
  [i18n, isDoubles]
579
585
  );
580
586
 
@@ -20,6 +20,7 @@ import LeftClickIcon from './LeftClickIcon';
20
20
  import styles from './Tooltip.module.scss';
21
21
  import { useFlamegraphI18n } from '../i18n';
22
22
  import type { FlamegraphPalette } from '../FlameGraph/FlameGraphComponent/colorPalette';
23
+ import { localizeDurationString, parseNumericWithUnit } from '../format/format';
23
24
 
24
25
  export type TooltipData = {
25
26
  units: Units;
@@ -56,81 +57,6 @@ export interface TooltipProps {
56
57
  ) => void;
57
58
  }
58
59
 
59
- /** 解析各种带单位的字符串,例如:
60
- * "0.63 seconds" / "0.63 分钟" / "< 1 ms" / "+0.24minutes"
61
- */
62
- function parseNumericWithUnit(
63
- input?: string | number
64
- ): { value: number; unit: string; hasLessThan: boolean; sign: string } | null {
65
- if (input == null) return null;
66
-
67
- if (typeof input === 'number') {
68
- return { value: input, unit: '', hasLessThan: false, sign: '' };
69
- }
70
-
71
- // 统一 Unicode 减号
72
- let s = String(input).trim().replace(/\u2212/g, '-');
73
- if (!s) return null;
74
-
75
- let hasLessThan = false;
76
- if (s.startsWith('<')) {
77
- hasLessThan = true;
78
- s = s.slice(1).trim();
79
- }
80
-
81
- const m = s.match(/^([+\-]?)(\d*\.?\d+)\s*(.*)$/);
82
- if (!m) return null;
83
-
84
- const sign = m[1] || '';
85
- const numStr = m[2];
86
- const unit = (m[3] || '').trim();
87
-
88
- const value = parseFloat(numStr);
89
- if (!Number.isFinite(value)) return null;
90
-
91
- return { value, unit, hasLessThan, sign };
92
- }
93
-
94
- /** 把 DurationFormatter 输出的英文单位翻译成中文,并保证数字和单位之间有空格 */
95
- function localizeDurationString(
96
- input?: string,
97
- isZh = false
98
- ): string | undefined {
99
- if (!input || !isZh) return input;
100
-
101
- const parsed = parseNumericWithUnit(input);
102
- if (!parsed) return input;
103
-
104
- const { hasLessThan, unit, sign, value } = parsed;
105
-
106
- let unitZh = unit;
107
- const u = unit.toLowerCase();
108
-
109
- if (u === 'ms') unitZh = '毫秒';
110
- else if (u === 'μs' || u === 'µs' || u === 'us') unitZh = '微秒';
111
- else if (u === 'second' || u === 'seconds') unitZh = '秒';
112
- else if (u === 'minute' || u === 'minutes' || u === 'min' || u === 'mins')
113
- unitZh = '分钟';
114
- else if (u === 'hour' || u === 'hours') unitZh = '小时';
115
- else if (u === 'day' || u === 'days') unitZh = '天';
116
- else if (u === 'month' || u === 'months') unitZh = '月';
117
- else if (u === 'year' || u === 'years') unitZh = '年';
118
-
119
- // 尽量保留原来的数值部分
120
- const m = String(input)
121
- .trim()
122
- .replace(/\u2212/g, '-')
123
- .match(/^<?\s*([+\-]?)(\d*\.?\d+)/);
124
- const valueStr =
125
- m && m[2] ? `${m[1] || ''}${m[2]}` : `${sign}${Math.abs(value)}`;
126
-
127
- const prefix = hasLessThan ? '< ' : '';
128
- if (!unitZh) {
129
- return `${prefix}${valueStr}`.trim();
130
- }
131
- return `${prefix}${valueStr} ${unitZh}`.trim();
132
- }
133
-
134
60
  /** CPU 时间差异:构造 "+0.24 minutes" / "-0.12 s" 这种文本(注意带空格) */
135
61
  function formatTimeDiff(
136
62
  baselineFormatted?: string,
@@ -5,6 +5,104 @@ export function numberWithCommas(x: number): string {
5
5
  return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
6
6
  }
7
7
 
8
+ type NumericWithUnit = {
9
+ value: number;
10
+ unit: string;
11
+ hasLessThan: boolean;
12
+ sign: string;
13
+ };
14
+
15
+ export function parseNumericWithUnit(
16
+ input?: string | number
17
+ ): NumericWithUnit | null {
18
+ if (input == null) return null;
19
+
20
+ if (typeof input === 'number') {
21
+ return { value: input, unit: '', hasLessThan: false, sign: '' };
22
+ }
23
+
24
+ let s = String(input).trim().replace(/\u2212/g, '-');
25
+ if (!s) return null;
26
+
27
+ let hasLessThan = false;
28
+ if (s.startsWith('<')) {
29
+ hasLessThan = true;
30
+ s = s.slice(1).trim();
31
+ }
32
+
33
+ const m = s.match(/^([+\-]?)(\d*\.?\d+)\s*(.*)$/);
34
+ if (!m) return null;
35
+
36
+ const sign = m[1] || '';
37
+ const numStr = m[2];
38
+ const unit = (m[3] || '').trim();
39
+
40
+ const value = parseFloat(numStr);
41
+ if (!Number.isFinite(value)) return null;
42
+
43
+ return { value, unit, hasLessThan, sign };
44
+ }
45
+
46
+ export function localizeDurationString(
47
+ input?: string,
48
+ isZh = false
49
+ ): string | undefined {
50
+ if (!input || !isZh) return input;
51
+
52
+ const parsed = parseNumericWithUnit(input);
53
+ if (!parsed) return input;
54
+
55
+ const { unit } = parsed;
56
+
57
+ let unitZh = unit;
58
+ const u = unit.toLowerCase();
59
+
60
+ if (u === 'ms') unitZh = '毫秒';
61
+ else if (u === 'μs' || u === 'µs' || u === 'us') unitZh = '微秒';
62
+ else if (u === 'second' || u === 'seconds') unitZh = '秒';
63
+ else if (u === 'minute' || u === 'minutes' || u === 'min' || u === 'mins')
64
+ unitZh = '分钟';
65
+ else if (u === 'hour' || u === 'hours') unitZh = '小时';
66
+ else if (u === 'day' || u === 'days') unitZh = '天';
67
+ else if (u === 'month' || u === 'months') unitZh = '月';
68
+ else if (u === 'year' || u === 'years') unitZh = '年';
69
+
70
+ const m = String(input).match(/^([<\s]*)([+\-]?\d*\.?\d+)(.*)$/);
71
+ if (!m) return input;
72
+
73
+ const prefix = m[1] || '';
74
+ const numberPart = m[2] || '';
75
+
76
+ return `${prefix}${numberPart} ${unitZh}`.trim();
77
+ }
78
+
79
+ export type SampleCountFormat = Array<{ value: number; label: string }>;
80
+
81
+ const defaultSampleCountFormat: SampleCountFormat = [
82
+ { value: 1e15, label: 'Quad' },
83
+ { value: 1e12, label: 'Tri' },
84
+ { value: 1e9, label: 'B' },
85
+ { value: 1e6, label: 'M' },
86
+ { value: 1e3, label: 'K' },
87
+ ];
88
+
89
+ export function formatSampleCount(
90
+ value: number,
91
+ format: SampleCountFormat = defaultSampleCountFormat
92
+ ): string {
93
+ const absValue = Math.abs(value);
94
+ for (const { value: threshold, label } of format) {
95
+ if (absValue >= threshold) {
96
+ const scaled = value / threshold;
97
+ const fixed = scaled.toFixed(2);
98
+ const trimmed = fixed.replace(/\.?0+$/, '');
99
+ return `${trimmed} ${label}`;
100
+ }
101
+ }
102
+
103
+ return numberWithCommas(value);
104
+ }
105
+
8
106
  export function formatPercent(ratio: number) {
9
107
  const percent = ratioToPercent(ratio);
10
108
  return `${percent}%`;
package/src/i18n.tsx CHANGED
@@ -73,6 +73,15 @@ export type FlamegraphMessages = {
73
73
  focusOnThisFunction: string;
74
74
  tableDoubleClickToFocus: string;
75
75
  tableRightClickForOptions: string;
76
+
77
+ // 火焰图聚焦时的 collapsed 提示
78
+ collapsedLevelsSingular: string; // "total (1 level collapsed)"
79
+ collapsedLevelsPlural: string; // "total (n levels collapsed)"
80
+
81
+ // Breadcrumb
82
+ ofTotal: string;
83
+ clearFocus: string;
84
+ sampleCountFormat: Array<{ value: number; label: string }>;
76
85
  };
77
86
 
78
87
  const defaultTooltipUnitTitles: Record<Units, TooltipUnitMessages> = {
@@ -227,6 +236,21 @@ export const defaultMessages: FlamegraphMessages = {
227
236
  focusOnThisFunction: 'Focus on this function',
228
237
  tableDoubleClickToFocus: 'Double-click to focus in flamegraph',
229
238
  tableRightClickForOptions: 'Right-click for more options',
239
+
240
+ // 火焰图聚焦时的 collapsed 提示
241
+ collapsedLevelsSingular: 'total (1 level collapsed)',
242
+ collapsedLevelsPlural: 'total ({n} levels collapsed)',
243
+
244
+ // Breadcrumb
245
+ ofTotal: 'of total',
246
+ clearFocus: 'Clear focus',
247
+ sampleCountFormat: [
248
+ { value: 1e15, label: 'Quad' },
249
+ { value: 1e12, label: 'Tri' },
250
+ { value: 1e9, label: 'B' },
251
+ { value: 1e6, label: 'M' },
252
+ { value: 1e3, label: 'K' },
253
+ ],
230
254
  };
231
255
 
232
256
  export const zhCNMessages: FlamegraphMessages = {
@@ -286,9 +310,24 @@ export const zhCNMessages: FlamegraphMessages = {
286
310
  focusOnThisFunction: '聚焦到此函数',
287
311
  tableDoubleClickToFocus: '双击可聚焦到火焰图',
288
312
  tableRightClickForOptions: '右键查看更多选项',
313
+
314
+ // 火焰图聚焦时的 collapsed 提示
315
+ collapsedLevelsSingular: '总计(已折叠 1 层)',
316
+ collapsedLevelsPlural: '总计(已折叠 {n} 层)',
317
+
318
+ // Breadcrumb
319
+ ofTotal: '占总量',
320
+ clearFocus: '清除聚焦',
321
+ sampleCountFormat: [
322
+ { value: 1e12, label: '兆' },
323
+ { value: 1e8, label: '亿' },
324
+ { value: 1e4, label: '万' },
325
+ { value: 1e3, label: '千' },
326
+ ],
289
327
  };
290
328
 
291
329
  const I18nContext = createContext<FlamegraphMessages>(defaultMessages);
330
+ export const FlamegraphI18nContext = I18nContext;
292
331
 
293
332
  export type FlamegraphI18nProviderProps = {
294
333
  messages?: Partial<FlamegraphMessages>;
@@ -49,10 +49,17 @@
49
49
  }
50
50
 
51
51
  tr {
52
+ /* 选中行样式 - 使用更明显的 antd 风格蓝色 */
52
53
  &.isRowSelected {
53
54
  cursor: pointer;
54
- background: var(--ps-table-highlight-row-bg) !important;
55
- color: var(--ps-table-highlight-row-text);
55
+ /* antd 选中蓝色,更明显但不会与红绿/红蓝火焰图冲突 */
56
+ background: var(--ps-table-highlight-row-bg, #bae0ff) !important;
57
+ color: var(--ps-table-highlight-row-text, #0958d9);
58
+
59
+ /* 左侧添加强调边框 */
60
+ td:first-child {
61
+ box-shadow: inset 3px 0 0 0 var(--ps-table-highlight-border, #1677ff);
62
+ }
56
63
  }
57
64
 
58
65
  &.isRowDisabled td {