@kylincloud/flamegraph 0.35.23 → 0.35.25

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 (35) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/FlameGraph/FlameGraphComponent/Flamegraph.d.ts +7 -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 +3 -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 +9 -1
  11. package/dist/FlameGraph/FlameGraphRenderer.d.ts.map +1 -1
  12. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  13. package/dist/format/format.d.ts +14 -0
  14. package/dist/format/format.d.ts.map +1 -1
  15. package/dist/i18n.d.ts +7 -0
  16. package/dist/i18n.d.ts.map +1 -1
  17. package/dist/index.cjs.js +3 -3
  18. package/dist/index.cjs.js.map +1 -1
  19. package/dist/index.esm.js +3 -3
  20. package/dist/index.esm.js.map +1 -1
  21. package/dist/index.node.cjs.js +4 -4
  22. package/dist/index.node.cjs.js.map +1 -1
  23. package/dist/index.node.esm.js +4 -4
  24. package/dist/index.node.esm.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/FlameGraph/FlameGraphComponent/Flamegraph.ts +34 -8
  27. package/src/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.module.scss +106 -0
  28. package/src/FlameGraph/FlameGraphComponent/FlamegraphBreadcrumb.tsx +98 -0
  29. package/src/FlameGraph/FlameGraphComponent/Flamegraph_render.ts +134 -85
  30. package/src/FlameGraph/FlameGraphComponent/canvas.module.css +9 -0
  31. package/src/FlameGraph/FlameGraphComponent/index.tsx +105 -21
  32. package/src/FlameGraph/FlameGraphRenderer.tsx +169 -21
  33. package/src/Tooltip/Tooltip.tsx +1 -75
  34. package/src/format/format.ts +98 -0
  35. package/src/i18n.tsx +27 -0
@@ -23,6 +23,7 @@ import { SandwichIcon, HeadFirstIcon, TailFirstIcon } from '../../Icons';
23
23
  import { PX_PER_LEVEL } from './constants';
24
24
  import Header from './Header';
25
25
  import { FlamegraphPalette } from './colorPalette';
26
+ import FlamegraphBreadcrumb from './FlamegraphBreadcrumb';
26
27
  import type { ViewTypes } from './viewTypes';
27
28
  import { FitModes, HeadMode, TailMode } from '../../fitMode/fitMode';
28
29
  import indexStyles from './styles.module.scss';
@@ -56,20 +57,41 @@ interface FlamegraphProps {
56
57
 
57
58
  /** 是否显示右键菜单中的「打开 Sandwich 视图」项,默认 true */
58
59
  enableSandwichView?: boolean;
60
+
61
+ breadcrumb?: {
62
+ totalValueText: string;
63
+ totalSamplesText: string;
64
+ samplesLabel: string;
65
+ unitLabel: string;
66
+ hasFocus: boolean;
67
+ focusPercent?: number;
68
+ focusLabel?: string;
69
+ ofTotalPlacement?: 'before' | 'after';
70
+ ofTotalLabel: string;
71
+ clearFocusLabel: string;
72
+ onClearFocus: () => void;
73
+ };
59
74
  }
60
75
 
61
76
  export default function FlameGraphComponent(props: FlamegraphProps) {
62
77
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
78
+ const textCanvasRef = React.useRef<HTMLCanvasElement>(null);
63
79
  const flamegraph = useRef<Flamegraph>();
80
+ const flamegraphText = useRef<Flamegraph>();
64
81
  const i18n = useFlamegraphI18n();
82
+ const resizeLogRef = useRef({
83
+ lastWidth: 0,
84
+ lastHeight: 0,
85
+ });
65
86
 
66
87
  // ====== 新增:提取 canvas 渲染需要的 i18n messages ======
67
88
  const canvasMessages = useMemo(
68
89
  () => ({
69
90
  collapsedLevelsSingular: i18n.collapsedLevelsSingular,
70
91
  collapsedLevelsPlural: i18n.collapsedLevelsPlural,
92
+ isZh: i18n.location !== 'Location',
71
93
  }),
72
- [i18n.collapsedLevelsSingular, i18n.collapsedLevelsPlural]
94
+ [i18n.collapsedLevelsSingular, i18n.collapsedLevelsPlural, i18n.location]
73
95
  );
74
96
 
75
97
  const [rightClickedNode, setRightClickedNode] = React.useState<
@@ -92,6 +114,7 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
92
114
  selectedItem,
93
115
  updateView,
94
116
  enableSandwichView = true,
117
+ breadcrumb,
95
118
  } = props;
96
119
 
97
120
  const { onZoom, onReset, isDirty, onFocusOnNode } = props;
@@ -100,13 +123,26 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
100
123
 
101
124
  const debouncedRenderCanvas = useCallback(
102
125
  debounce(() => {
103
- renderCanvas();
126
+ renderRectCanvas();
127
+ renderTextCanvas();
104
128
  }, 50),
105
129
  []
106
130
  );
107
131
 
108
132
  useResizeObserver(canvasRef, () => {
109
133
  if (flamegraph) {
134
+ if (canvasRef.current) {
135
+ const width = canvasRef.current.clientWidth;
136
+ const height = canvasRef.current.clientHeight;
137
+ const info = resizeLogRef.current;
138
+ const widthDelta = Math.abs(width - info.lastWidth);
139
+ const widthChanged = widthDelta >= 2;
140
+ if (!widthChanged) {
141
+ return;
142
+ }
143
+ info.lastWidth = width;
144
+ info.lastHeight = height;
145
+ }
110
146
  debouncedRenderCanvas();
111
147
  }
112
148
  });
@@ -306,32 +342,58 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
306
342
 
307
343
  flamegraph.current = f;
308
344
  }
345
+ if (textCanvasRef.current) {
346
+ const f = new Flamegraph(
347
+ flamebearer,
348
+ textCanvasRef.current,
349
+ focusedNode,
350
+ fitMode,
351
+ highlightQuery,
352
+ zoom,
353
+ palette,
354
+ canvasMessages
355
+ );
356
+ flamegraphText.current = f;
357
+ }
309
358
  };
310
359
 
311
360
  // ====== 修改:添加 canvasMessages 依赖 ======
312
361
  React.useEffect(() => {
313
362
  constructCanvas();
314
- renderCanvas();
363
+ renderRectCanvas();
364
+ renderTextCanvas();
315
365
  }, [palette, canvasMessages]);
316
366
 
317
367
  React.useEffect(() => {
318
368
  constructCanvas();
319
- renderCanvas();
369
+ renderRectCanvas();
370
+ renderTextCanvas();
320
371
  }, [
321
372
  canvasRef.current,
322
373
  flamebearer,
323
374
  focusedNode,
324
375
  fitMode,
325
- highlightQuery,
326
376
  zoom,
327
377
  ]);
328
378
 
329
- const renderCanvas = () => {
379
+ React.useEffect(() => {
380
+ constructCanvas();
381
+ renderRectCanvas();
382
+ renderTextCanvas();
383
+ }, [highlightQuery]);
384
+
385
+ const renderRectCanvas = () => {
330
386
  canvasRef?.current?.setAttribute('data-state', 'rendering');
331
- flamegraph?.current?.render();
387
+ flamegraph?.current?.render({ renderText: false });
332
388
  canvasRef?.current?.setAttribute('data-state', 'rendered');
333
389
  };
334
390
 
391
+ const renderTextCanvas = () => {
392
+ textCanvasRef?.current?.setAttribute('data-state', 'rendering');
393
+ flamegraphText?.current?.render({ renderRects: false });
394
+ textCanvasRef?.current?.setAttribute('data-state', 'rendered');
395
+ };
396
+
335
397
  const dataUnavailable =
336
398
  !flamebearer || (flamebearer && flamebearer.names.length <= 1);
337
399
 
@@ -356,6 +418,21 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
356
418
  'vertical-orientation': flamebearer.format === 'double',
357
419
  })}
358
420
  >
421
+ {breadcrumb && (
422
+ <FlamegraphBreadcrumb
423
+ totalValueText={breadcrumb.totalValueText}
424
+ totalSamplesText={breadcrumb.totalSamplesText}
425
+ samplesLabel={breadcrumb.samplesLabel}
426
+ unitLabel={breadcrumb.unitLabel}
427
+ hasFocus={breadcrumb.hasFocus}
428
+ focusPercent={breadcrumb.focusPercent}
429
+ focusLabel={breadcrumb.focusLabel}
430
+ ofTotalPlacement={breadcrumb.ofTotalPlacement}
431
+ ofTotalLabel={breadcrumb.ofTotalLabel}
432
+ clearFocusLabel={breadcrumb.clearFocusLabel}
433
+ onClearFocus={breadcrumb.onClearFocus}
434
+ />
435
+ )}
359
436
  {/* Header 已简化,仅在 single 模式下显示标题 */}
360
437
  {/* DiffLegend 已移到 Toolbar */}
361
438
  {headerVisible && (
@@ -368,6 +445,7 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
368
445
  <div
369
446
  data-testid={dataTestId}
370
447
  style={{
448
+ position: 'relative',
371
449
  opacity: dataUnavailable && !showSingleLevel ? 0 : 1,
372
450
  }}
373
451
  >
@@ -379,22 +457,28 @@ export default function FlameGraphComponent(props: FlamegraphProps) {
379
457
  ref={canvasRef}
380
458
  onClick={!disableClick ? onClick : undefined}
381
459
  />
460
+ <canvas
461
+ height="0"
462
+ data-testid="flamegraph-text-canvas"
463
+ className={clsx('flamegraph-text-canvas', styles.textCanvas)}
464
+ ref={textCanvasRef}
465
+ />
466
+ {flamegraph && canvasRef && (
467
+ <Highlight
468
+ barHeight={PX_PER_LEVEL}
469
+ canvasRef={canvasRef}
470
+ zoom={zoom}
471
+ xyToHighlightData={xyToHighlightData}
472
+ />
473
+ )}
474
+ {flamegraph && (
475
+ <ContextMenuHighlight
476
+ barHeight={PX_PER_LEVEL}
477
+ node={rightClickedNode}
478
+ />
479
+ )}
382
480
  </div>
383
481
  {showCredit ? <LogoLink /> : ''}
384
- {flamegraph && canvasRef && (
385
- <Highlight
386
- barHeight={PX_PER_LEVEL}
387
- canvasRef={canvasRef}
388
- zoom={zoom}
389
- xyToHighlightData={xyToHighlightData}
390
- />
391
- )}
392
- {flamegraph && (
393
- <ContextMenuHighlight
394
- barHeight={PX_PER_LEVEL}
395
- node={rightClickedNode}
396
- />
397
- )}
398
482
  {flamegraph && (
399
483
  <FlamegraphTooltip
400
484
  format={flamebearer.format}
@@ -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>(),
@@ -135,24 +147,28 @@ class FlameGraphRenderer extends Component<
135
147
  prevProps: FlamegraphRendererProps,
136
148
  prevState: FlamegraphRendererState
137
149
  ) {
138
- // TODO: this is a slow operation
139
- const prevFlame = normalize(prevProps);
140
- const currFlame = normalize(this.props);
141
-
142
- if (!this.isSameFlamebearer(prevFlame, currFlame)) {
143
- const newConfigs = this.calcNewConfigs(prevFlame, currFlame);
144
-
145
- // Batch these updates to not do unnecessary work
146
- // eslint-disable-next-line react/no-did-update-set-state
147
- this.setState({
148
- flamebearer: currFlame,
149
- flamegraphConfigs: {
150
- ...this.state.flamegraphConfigs,
151
- ...newConfigs,
152
- },
153
- selectedItem: Maybe.nothing(),
154
- });
155
- return;
150
+ const propsChanged =
151
+ prevProps.profile !== this.props.profile ||
152
+ prevProps.flamebearer !== this.props.flamebearer;
153
+ if (propsChanged) {
154
+ const prevFlame = prevState.flamebearer;
155
+ const currFlame = normalize(this.props);
156
+
157
+ if (!this.isSameFlamebearer(prevFlame, currFlame)) {
158
+ const newConfigs = this.calcNewConfigs(prevFlame, currFlame);
159
+
160
+ // Batch these updates to not do unnecessary work
161
+ // eslint-disable-next-line react/no-did-update-set-state
162
+ this.setState({
163
+ flamebearer: currFlame,
164
+ flamegraphConfigs: {
165
+ ...this.state.flamegraphConfigs,
166
+ ...newConfigs,
167
+ },
168
+ selectedItem: Maybe.nothing(),
169
+ });
170
+ return;
171
+ }
156
172
  }
157
173
 
158
174
  // flamegraph configs changed
@@ -395,6 +411,66 @@ class FlameGraphRenderer extends Component<
395
411
  });
396
412
  };
397
413
 
414
+ clearFocus = () => {
415
+ this.setState((prevState) => ({
416
+ ...prevState,
417
+ flamegraphConfigs: {
418
+ ...prevState.flamegraphConfigs,
419
+ ...this.resetFlamegraphState,
420
+ },
421
+ }));
422
+ };
423
+
424
+ getActiveRange = () => {
425
+ const { flamebearer } = this.state;
426
+ const { zoom, focusedNode } = this.state.flamegraphConfigs;
427
+ const totalTicks = flamebearer.numTicks;
428
+
429
+ if (!totalTicks) {
430
+ return { rangeMin: 0, rangeMax: 1 };
431
+ }
432
+
433
+ const ff = createFF(flamebearer.format);
434
+
435
+ const getRangeFromNode = (node: Node) => {
436
+ const level = flamebearer.levels[node.i];
437
+ if (!level) {
438
+ return { rangeMin: 0, rangeMax: 1 };
439
+ }
440
+
441
+ const offset = ff.getBarOffset(level, node.j);
442
+ const total = ff.getBarTotal(level, node.j);
443
+ return {
444
+ rangeMin: offset / totalTicks,
445
+ rangeMax: (offset + total) / totalTicks,
446
+ };
447
+ };
448
+
449
+ return zoom.match({
450
+ Just: (z) => {
451
+ return focusedNode.match({
452
+ Just: (f) => {
453
+ const focusRange = getRangeFromNode(f);
454
+ const zoomRange = getRangeFromNode(z);
455
+ if (
456
+ focusRange.rangeMax - focusRange.rangeMin <
457
+ zoomRange.rangeMax - zoomRange.rangeMin
458
+ ) {
459
+ return focusRange;
460
+ }
461
+ return zoomRange;
462
+ },
463
+ Nothing: () => getRangeFromNode(z),
464
+ });
465
+ },
466
+ Nothing: () =>
467
+ focusedNode.match({
468
+ Just: (f) => getRangeFromNode(f),
469
+ Nothing: () => ({ rangeMin: 0, rangeMax: 1 }),
470
+ }),
471
+ });
472
+ };
473
+
398
474
  // used as a variable instead of keeping in the state
399
475
  // so that the flamegraph doesn't rerender unnecessarily
400
476
  isDirty = () => {
@@ -445,6 +521,60 @@ class FlameGraphRenderer extends Component<
445
521
 
446
522
  const toolbarVisible = this.shouldShowToolbar();
447
523
 
524
+ const dataUnavailable =
525
+ !this.state.flamebearer || this.state.flamebearer.names.length <= 1;
526
+ const i18n = this.context;
527
+ const isZh = i18n.location !== 'Location';
528
+ const totalTicks = this.state.flamebearer.numTicks;
529
+ const formatter = getFormatter(
530
+ totalTicks,
531
+ this.state.flamebearer.sampleRate,
532
+ this.state.flamebearer.units
533
+ );
534
+ const totalValueRaw = formatter.format(
535
+ totalTicks,
536
+ this.state.flamebearer.sampleRate
537
+ );
538
+ const totalValueText =
539
+ localizeDurationString(totalValueRaw, isZh) || totalValueRaw;
540
+ const totalSamplesText = formatSampleCount(
541
+ totalTicks,
542
+ i18n.sampleCountFormat
543
+ );
544
+ const samplesLabel = i18n.tooltipSamples.replace(/[::]\s*$/, '');
545
+ const unitLabel =
546
+ i18n.tooltipUnitTitles[this.state.flamebearer.units]?.formattedValue ||
547
+ i18n.tooltipUnitTitles.unknown.formattedValue;
548
+ const activeRange = this.getActiveRange();
549
+ const hasFocus =
550
+ activeRange.rangeMin > 0 || activeRange.rangeMax < 1;
551
+ const activeRangeTicks = Math.max(
552
+ 0,
553
+ Math.min(
554
+ totalTicks,
555
+ (activeRange.rangeMax - activeRange.rangeMin) * totalTicks
556
+ )
557
+ );
558
+ const focusPercent =
559
+ totalTicks > 0 ? (activeRangeTicks / totalTicks) * 100 : 0;
560
+ const focusLabel = (() => {
561
+ const ff = createFF(this.state.flamebearer.format);
562
+ const focusNode = this.state.flamegraphConfigs.focusedNode.mapOrElse(
563
+ () =>
564
+ this.state.flamegraphConfigs.zoom.mapOrElse(() => null, (z) => z),
565
+ (f) => f
566
+ );
567
+ if (!focusNode) {
568
+ return '';
569
+ }
570
+ const level = this.state.flamebearer.levels[focusNode.i];
571
+ if (!level) {
572
+ return '';
573
+ }
574
+ const nameIndex = ff.getBarName(level, focusNode.j);
575
+ return this.state.flamebearer.names[nameIndex] || '';
576
+ })();
577
+
448
578
  const flameGraphPane = (
449
579
  <Graph
450
580
  key="flamegraph-pane"
@@ -467,10 +597,30 @@ class FlameGraphRenderer extends Component<
467
597
  toolbarVisible={toolbarVisible}
468
598
  setPalette={this.handleSetPalette}
469
599
  enableSandwichView={this.props.enableSandwichView}
600
+ breadcrumb={
601
+ dataUnavailable
602
+ ? undefined
603
+ : {
604
+ totalValueText,
605
+ totalSamplesText,
606
+ samplesLabel,
607
+ unitLabel,
608
+ hasFocus,
609
+ focusPercent,
610
+ focusLabel,
611
+ ofTotalPlacement: isZh ? 'before' : 'after',
612
+ ofTotalLabel: i18n.ofTotal,
613
+ clearFocusLabel: i18n.clearFocus,
614
+ onClearFocus: this.clearFocus,
615
+ }
616
+ }
470
617
  />
471
618
  );
472
619
 
473
620
  const sandwichPane = (() => {
621
+ if (this.state.view !== 'sandwich') {
622
+ return <div className={styles.sandwichPane} key="sandwich-pane" />;
623
+ }
474
624
  if (this.state.selectedItem.isNothing) {
475
625
  return (
476
626
  <div className={styles.sandwichPane} key="sandwich-pane">
@@ -574,8 +724,6 @@ class FlameGraphRenderer extends Component<
574
724
  // // rightTicks?: number;
575
725
  // } & addTicks;
576
726
 
577
- const dataUnavailable =
578
- !this.state.flamebearer || this.state.flamebearer.names.length <= 1;
579
727
  const panes = decidePanesOrder(
580
728
  this.state.view,
581
729
  flameGraphPane,
@@ -671,4 +819,4 @@ function decidePanesOrder(
671
819
  }
672
820
  }
673
821
 
674
- export default FlameGraphRenderer;
822
+ export default FlameGraphRenderer;
@@ -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}%`;