waveform-analysis 0.1.29 → 0.1.31

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 (65) hide show
  1. package/README.md +8 -0
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +937 -714
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/WaveformChart.vue.d.ts +1 -0
  6. package/dist/types/components/core/constants.d.ts +2 -0
  7. package/dist/types/components/core/useWaveformChartController.d.ts +8 -15
  8. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +4 -1
  9. package/dist/types/components/core/useWaveformData.d.ts +1 -1
  10. package/dist/types/components/core/useWaveformLayout.d.ts +0 -1
  11. package/dist/types/components/core/useWaveformPresentation.d.ts +5 -0
  12. package/dist/types/components/core/waveformChartTypes.d.ts +9 -5
  13. package/dist/types/components/data/types.d.ts +1 -1
  14. package/dist/types/components/index.d.ts +1 -1
  15. package/dist/types/components/interaction/pointerCapture.d.ts +1 -0
  16. package/dist/types/components/interaction/viewportInteractionState.d.ts +39 -0
  17. package/dist/types/components/interaction/zoomConstraints.d.ts +14 -0
  18. package/dist/types/components/waveform.d.ts +1 -1
  19. package/dist/types/core/rendering.d.ts +1 -4
  20. package/dist/types/core/renderingStrategies.d.ts +22 -0
  21. package/dist/types/demo/types.d.ts +4 -2
  22. package/dist/types/index.d.ts +1 -1
  23. package/dist/types/types/chart.d.ts +7 -0
  24. package/dist/types/types/index.d.ts +1 -1
  25. package/package.json +5 -1
  26. package/src/App.vue +10 -4
  27. package/src/components/WaveformChart.vue +1 -0
  28. package/src/components/WaveformChartView.vue +15 -12
  29. package/src/components/core/constants.ts +3 -0
  30. package/src/components/core/useWaveformChartController.ts +7 -2
  31. package/src/components/core/useWaveformChartLifecycle.ts +47 -3
  32. package/src/components/core/useWaveformData.ts +6 -1
  33. package/src/components/core/useWaveformLayout.ts +0 -3
  34. package/src/components/core/useWaveformPresentation.ts +17 -2
  35. package/src/components/core/waveformChartTypes.ts +7 -3
  36. package/src/components/data/types.ts +1 -0
  37. package/src/components/index.ts +1 -0
  38. package/src/components/interaction/WaveformTooltip.test.ts +39 -0
  39. package/src/components/interaction/WaveformTooltip.vue +72 -22
  40. package/src/components/interaction/pointerCapture.ts +10 -0
  41. package/src/components/interaction/useWaveformHover.ts +1 -1
  42. package/src/components/interaction/useWaveformViewport.ts +86 -74
  43. package/src/components/interaction/useWaveformZoom.ts +46 -14
  44. package/src/components/interaction/viewportInteractionState.test.ts +101 -0
  45. package/src/components/interaction/viewportInteractionState.ts +89 -0
  46. package/src/components/interaction/zoomConstraints.ts +167 -0
  47. package/src/components/waveform.ts +1 -0
  48. package/src/components/waveformChartCases/sizingAndTitles.test.ts +59 -0
  49. package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -0
  50. package/src/components/waveformChartCases/zoomPointConstraints.test.ts +248 -0
  51. package/src/core/rendering.ts +11 -81
  52. package/src/core/renderingStrategies.test.ts +90 -0
  53. package/src/core/renderingStrategies.ts +131 -0
  54. package/src/data/simulatedWaveforms.ts +9 -10
  55. package/src/demo/DemoChartHost.vue +2 -2
  56. package/src/demo/DemoGridFrameControls.vue +29 -0
  57. package/src/demo/plotMarginControls.test.ts +29 -0
  58. package/src/demo/types.ts +4 -1
  59. package/src/index.ts +1 -0
  60. package/src/styles.css +1 -0
  61. package/src/types/chart.ts +8 -0
  62. package/src/types/index.ts +1 -0
  63. package/src/utils/formatters.test.ts +4 -3
  64. package/src/utils/formatters.ts +1 -2
  65. package/src/utils/sampling.ts +2 -1
@@ -3,6 +3,10 @@ import { bisector } from 'd3'
3
3
  import type { WaveformPoint } from '@/types'
4
4
  import { resolveWaveformPointErrors } from './data'
5
5
  import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
6
+ import {
7
+ resolveRenderablePointSelectionStrategy,
8
+ type VisiblePointRange,
9
+ } from './renderingStrategies'
6
10
 
7
11
  export {
8
12
  DEFAULT_WAVEFORM_RENDERING_OPTIONS,
@@ -13,11 +17,6 @@ export {
13
17
  const pointBisector = bisector((point: WaveformPoint) => point.x)
14
18
  const acceptAllPoints = () => true
15
19
 
16
- interface VisiblePointRange {
17
- start: number
18
- end: number
19
- }
20
-
21
20
  interface PointSeriesSource {
22
21
  points: WaveformPoint[]
23
22
  }
@@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues(
65
64
  return false
66
65
  }
67
66
 
68
- function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
69
- if (point && target[target.length - 1] !== point) target.push(point)
70
- }
71
-
72
67
  function selectRenderablePointsInRange(
73
68
  points: WaveformPoint[],
74
69
  range: VisiblePointRange,
@@ -76,82 +71,17 @@ function selectRenderablePointsInRange(
76
71
  width: number,
77
72
  options: ResolvedWaveformRenderingOptions,
78
73
  ): WaveformPoint[] {
79
- const domainStart = Math.min(domain[0], domain[1])
80
- const domainEnd = Math.max(domain[0], domain[1])
81
74
  const start = Math.max(0, range.start - 1)
82
75
  const end = Math.min(points.length, range.end + 1)
83
76
  const visibleCount = end - start
84
77
  if (visibleCount <= 0) return []
85
- if (!options.downsample || visibleCount <= options.downsampleThreshold) {
86
- return points.slice(start, end)
87
- }
88
-
89
- const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
90
- const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
91
- if (visibleCount <= maximumPointCount) return points.slice(start, end)
92
-
93
- const result: WaveformPoint[] = []
94
- const span = domainEnd - domainStart || 1
95
- const bucketIndexes = Array.from({ length: 4 }, () => -1)
96
- let activeBucket = -1
97
- let firstIndex = -1
98
- let lastIndex = -1
99
- let minimumIndex = -1
100
- let maximumIndex = -1
101
-
102
- const addBucketIndex = (index: number, count: number) => {
103
- if (index < 0) return count
104
- for (let position = 0; position < count; position += 1) {
105
- if (bucketIndexes[position] === index) return count
106
- }
107
- bucketIndexes[count] = index
108
- return count + 1
109
- }
110
-
111
- const flushBucket = () => {
112
- if (firstIndex < 0) return
113
- let count = 0
114
- count = addBucketIndex(firstIndex, count)
115
- count = addBucketIndex(minimumIndex, count)
116
- count = addBucketIndex(maximumIndex, count)
117
- count = addBucketIndex(lastIndex, count)
118
- for (let index = 1; index < count; index += 1) {
119
- const value = bucketIndexes[index]
120
- let position = index - 1
121
- while (position >= 0 && bucketIndexes[position] > value) {
122
- bucketIndexes[position + 1] = bucketIndexes[position]
123
- position -= 1
124
- }
125
- bucketIndexes[position + 1] = value
126
- }
127
- for (let index = 0; index < count; index += 1) {
128
- pushUniquePoint(result, points[bucketIndexes[index]])
129
- }
130
- }
131
-
132
- pushUniquePoint(result, points[start])
133
- for (let index = range.start; index < range.end; index += 1) {
134
- const point = points[index]
135
- const bucket = Math.min(
136
- bucketCount - 1,
137
- Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
138
- )
139
- if (bucket !== activeBucket) {
140
- flushBucket()
141
- activeBucket = bucket
142
- firstIndex = index
143
- lastIndex = index
144
- minimumIndex = index
145
- maximumIndex = index
146
- continue
147
- }
148
- lastIndex = index
149
- if (point.y < points[minimumIndex].y) minimumIndex = index
150
- if (point.y > points[maximumIndex].y) maximumIndex = index
151
- }
152
- flushBucket()
153
- pushUniquePoint(result, points[end - 1])
154
- return result
78
+ return resolveRenderablePointSelectionStrategy({ visibleCount, width, options })({
79
+ points,
80
+ range,
81
+ domain,
82
+ width,
83
+ options,
84
+ })
155
85
  }
156
86
 
157
87
  /**
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { WaveformPoint } from '../types'
4
+ import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions'
5
+ import {
6
+ peakPreservingPointSelectionStrategy,
7
+ resolveRenderablePointSelectionStrategy,
8
+ type RenderablePointSelectionContext,
9
+ } from './renderingStrategies'
10
+
11
+ const denseOptions = {
12
+ ...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
13
+ downsampleThreshold: 0,
14
+ maxPointsPerPixel: 1,
15
+ }
16
+
17
+ describe('renderable point selection strategies', () => {
18
+ it('resolves complete-point selection at the configured boundaries', () => {
19
+ const context: RenderablePointSelectionContext = {
20
+ points: [
21
+ { x: 0, y: 5 },
22
+ { x: 1, y: 1 },
23
+ { x: 2, y: 10 },
24
+ { x: 3, y: 3 },
25
+ { x: 4, y: 7 },
26
+ ],
27
+ range: { start: 0, end: 5 },
28
+ domain: [0, 4],
29
+ width: 100,
30
+ options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
31
+ }
32
+ const complete = resolveRenderablePointSelectionStrategy({
33
+ visibleCount: 100,
34
+ width: 100,
35
+ options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
36
+ })(context)
37
+ const disabled = resolveRenderablePointSelectionStrategy({
38
+ visibleCount: 10_000,
39
+ width: 100,
40
+ options: { ...denseOptions, downsample: false },
41
+ })(context)
42
+
43
+ expect(complete).toEqual(context.points)
44
+ expect(disabled).toEqual(context.points)
45
+ })
46
+
47
+ it('resolves peak-preserving selection for dense visible data', () => {
48
+ const context: RenderablePointSelectionContext = {
49
+ points: [
50
+ { x: 0, y: 5 },
51
+ { x: 1, y: 1 },
52
+ { x: 2, y: 10 },
53
+ { x: 3, y: 3 },
54
+ { x: 4, y: 7 },
55
+ ],
56
+ range: { start: 0, end: 5 },
57
+ domain: [0, 4],
58
+ width: 4,
59
+ options: denseOptions,
60
+ }
61
+ const selected = resolveRenderablePointSelectionStrategy({
62
+ visibleCount: 1_000,
63
+ width: 4,
64
+ options: denseOptions,
65
+ })(context)
66
+
67
+ expect(selected).toEqual(expect.arrayContaining([context.points[1], context.points[2]]))
68
+ })
69
+
70
+ it('retains first, last, minimum, and maximum points in a peak bucket', () => {
71
+ const points: WaveformPoint[] = [
72
+ { x: 0, y: 5 },
73
+ { x: 1, y: 1 },
74
+ { x: 2, y: 10 },
75
+ { x: 3, y: 3 },
76
+ { x: 4, y: 7 },
77
+ ]
78
+ const selected = peakPreservingPointSelectionStrategy({
79
+ points,
80
+ range: { start: 0, end: points.length },
81
+ domain: [0, 4],
82
+ width: 4,
83
+ options: denseOptions,
84
+ })
85
+
86
+ expect(selected[0]).toBe(points[0])
87
+ expect(selected.at(-1)).toBe(points.at(-1))
88
+ expect(selected.map((point) => point.y)).toEqual(expect.arrayContaining([1, 10]))
89
+ })
90
+ })
@@ -0,0 +1,131 @@
1
+ import type { WaveformPoint } from '../types'
2
+ import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
3
+
4
+ export interface VisiblePointRange {
5
+ start: number
6
+ end: number
7
+ }
8
+
9
+ export interface RenderablePointSelectionContext {
10
+ points: WaveformPoint[]
11
+ range: VisiblePointRange
12
+ domain: [number, number]
13
+ width: number
14
+ options: ResolvedWaveformRenderingOptions
15
+ }
16
+
17
+ export type RenderablePointSelectionStrategy = (
18
+ context: RenderablePointSelectionContext,
19
+ ) => WaveformPoint[]
20
+
21
+ function selectionBounds(range: VisiblePointRange, pointCount: number) {
22
+ return {
23
+ start: Math.max(0, range.start - 1),
24
+ end: Math.min(pointCount, range.end + 1),
25
+ }
26
+ }
27
+
28
+ function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
29
+ if (point && target[target.length - 1] !== point) target.push(point)
30
+ }
31
+
32
+ export const completePointSelectionStrategy: RenderablePointSelectionStrategy = (context) => {
33
+ const { start, end } = selectionBounds(context.range, context.points.length)
34
+ return context.points.slice(start, end)
35
+ }
36
+
37
+ export const peakPreservingPointSelectionStrategy: RenderablePointSelectionStrategy = (context) => {
38
+ const { points, range, domain, width, options } = context
39
+ const { start, end } = selectionBounds(range, points.length)
40
+ const visibleCount = end - start
41
+ if (visibleCount <= 0) return []
42
+
43
+ const domainStart = Math.min(domain[0], domain[1])
44
+ const domainEnd = Math.max(domain[0], domain[1])
45
+ const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
46
+ const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
47
+ const result: WaveformPoint[] = []
48
+ const span = domainEnd - domainStart || 1
49
+ const bucketIndexes = Array.from({ length: 4 }, () => -1)
50
+ let activeBucket = -1
51
+ let firstIndex = -1
52
+ let lastIndex = -1
53
+ let minimumIndex = -1
54
+ let maximumIndex = -1
55
+
56
+ const addBucketIndex = (index: number, count: number) => {
57
+ if (index < 0) return count
58
+ for (let position = 0; position < count; position += 1) {
59
+ if (bucketIndexes[position] === index) return count
60
+ }
61
+ bucketIndexes[count] = index
62
+ return count + 1
63
+ }
64
+
65
+ const flushBucket = () => {
66
+ if (firstIndex < 0) return
67
+ let count = 0
68
+ count = addBucketIndex(firstIndex, count)
69
+ count = addBucketIndex(minimumIndex, count)
70
+ count = addBucketIndex(maximumIndex, count)
71
+ count = addBucketIndex(lastIndex, count)
72
+ for (let index = 1; index < count; index += 1) {
73
+ const value = bucketIndexes[index]
74
+ let position = index - 1
75
+ while (position >= 0 && bucketIndexes[position] > value) {
76
+ bucketIndexes[position + 1] = bucketIndexes[position]
77
+ position -= 1
78
+ }
79
+ bucketIndexes[position + 1] = value
80
+ }
81
+ for (let index = 0; index < count; index += 1) {
82
+ pushUniquePoint(result, points[bucketIndexes[index]])
83
+ }
84
+ }
85
+
86
+ pushUniquePoint(result, points[start])
87
+ for (let index = range.start; index < range.end; index += 1) {
88
+ const point = points[index]
89
+ const bucket = Math.min(
90
+ bucketCount - 1,
91
+ Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
92
+ )
93
+ if (bucket !== activeBucket) {
94
+ flushBucket()
95
+ activeBucket = bucket
96
+ firstIndex = index
97
+ lastIndex = index
98
+ minimumIndex = index
99
+ maximumIndex = index
100
+ continue
101
+ }
102
+ lastIndex = index
103
+ if (point.y < points[minimumIndex].y) minimumIndex = index
104
+ if (point.y > points[maximumIndex].y) maximumIndex = index
105
+ }
106
+ flushBucket()
107
+ pushUniquePoint(result, points[end - 1])
108
+ return result
109
+ }
110
+
111
+ export interface RenderablePointSelectionStrategyRequest {
112
+ visibleCount: number
113
+ width: number
114
+ options: ResolvedWaveformRenderingOptions
115
+ }
116
+
117
+ export function resolveRenderablePointSelectionStrategy(
118
+ request: RenderablePointSelectionStrategyRequest,
119
+ ): RenderablePointSelectionStrategy {
120
+ const maximumPointCount = Math.max(
121
+ 4,
122
+ Math.floor(request.width * request.options.maxPointsPerPixel),
123
+ )
124
+ const shouldUseCompletePoints =
125
+ !request.options.downsample ||
126
+ request.visibleCount <= request.options.downsampleThreshold ||
127
+ request.visibleCount <= maximumPointCount
128
+ return shouldUseCompletePoints
129
+ ? completePointSelectionStrategy
130
+ : peakPreservingPointSelectionStrategy
131
+ }
@@ -6,16 +6,15 @@ const END_TIME = 5
6
6
  const TWO_PI = Math.PI * 2
7
7
 
8
8
  type SignalGenerator = (time: number, noise: number) => number
9
- type ErrorGenerator = (time: number, value: number) => Pick<
10
- WaveformPoint,
11
- 'error' | 'lowerError' | 'upperError'
12
- >
9
+ type ErrorGenerator = (
10
+ time: number,
11
+ value: number,
12
+ ) => Pick<WaveformPoint, 'error' | 'lowerError' | 'upperError'>
13
13
 
14
- interface SimulatedSeriesDefinition
15
- extends Pick<
16
- WaveformSeries,
17
- 'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
18
- > {
14
+ interface SimulatedSeriesDefinition extends Pick<
15
+ WaveformSeries,
16
+ 'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
17
+ > {
19
18
  signal: SignalGenerator
20
19
  errors?: ErrorGenerator
21
20
  }
@@ -39,7 +38,7 @@ function createPoints(
39
38
  return {
40
39
  x: time,
41
40
  y: value,
42
- ...(errors?.(time, value) ?? {}),
41
+ ...errors?.(time, value),
43
42
  }
44
43
  })
45
44
  }
@@ -22,8 +22,7 @@ defineExpose({ resetViewport })
22
22
  v-model:annotations="model.annotations"
23
23
  v-model:hidden-series-ids="model.hiddenSeriesIds"
24
24
  :data="model.data"
25
- :min-zoom-span="model.minZoomSpan"
26
- :min-visible-points="5"
25
+ :min-visible-points="2"
27
26
  :initial-x-domain="model.initialXDomain"
28
27
  :display-mode="model.displayMode"
29
28
  :overlay-mode="model.overlayMode"
@@ -45,6 +44,7 @@ defineExpose({ resetViewport })
45
44
  :clean-view="model.cleanView"
46
45
  :presentation-mode="model.presentationMode"
47
46
  :show-tooltip="model.showTooltip"
47
+ :plot-margin="model.plotMargin"
48
48
  :zero-line="model.zeroLine"
49
49
  :frame-number="model.frameWatermarkVisible ? 1 : undefined"
50
50
  :annotations-visible="model.annotationsVisible"
@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
8
8
  </script>
9
9
 
10
10
  <template>
11
+ <section class="control-section">
12
+ <h2>绘图区边距</h2>
13
+ <div class="plot-margin-controls">
14
+ <label class="frame-style-control">
15
+ <span>上边距</span>
16
+ <InputNumber
17
+ v-model:value="model.plotMarginTop"
18
+ :min="0"
19
+ :max="300"
20
+ :step="1"
21
+ addon-after="px"
22
+ size="small"
23
+ aria-label="绘图区上边距"
24
+ />
25
+ </label>
26
+ <label class="frame-style-control">
27
+ <span>下边距</span>
28
+ <InputNumber
29
+ v-model:value="model.plotMarginBottom"
30
+ :min="0"
31
+ :max="300"
32
+ :step="1"
33
+ addon-after="px"
34
+ size="small"
35
+ aria-label="绘图区下边距"
36
+ />
37
+ </label>
38
+ </div>
39
+ </section>
11
40
  <section class="control-section">
12
41
  <div class="control-section__header">
13
42
  <h2>零值参考线</h2>
@@ -0,0 +1,29 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { InputNumber } from 'ant-design-vue'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import App from '../App.vue'
6
+ import { WaveformChart } from '../components'
7
+
8
+ describe('plot margin demo controls', () => {
9
+ it('updates the chart top and bottom margins from the sidebar', async () => {
10
+ const wrapper = mount(App)
11
+ await flushPromises()
12
+ const chart = wrapper.getComponent(WaveformChart)
13
+ const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
14
+
15
+ expect(controls).toHaveLength(2)
16
+ expect(controls[0]?.props('value')).toBe(18)
17
+ expect(controls[1]?.props('value')).toBe(52)
18
+ expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
19
+
20
+ controls[0]?.vm.$emit('update:value', 30)
21
+ controls[1]?.vm.$emit('update:value', 70)
22
+ await flushPromises()
23
+
24
+ expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
25
+ expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
26
+ expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
27
+ wrapper.unmount()
28
+ })
29
+ })
package/src/demo/types.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  WaveformLegendPosition,
11
11
  WaveformLineStyle,
12
12
  WaveformOverlayMode,
13
+ WaveformPlotMargin,
13
14
  WaveformTitleOptions,
14
15
  WaveformZeroLineOptions,
15
16
  WaveformZoomEndPayload,
@@ -26,6 +27,8 @@ export interface DemoControlPanelModel {
26
27
  displayMode: WaveformDisplayMode
27
28
  overlayMode: WaveformOverlayMode
28
29
  showTooltip: boolean
30
+ plotMarginTop: number
31
+ plotMarginBottom: number
29
32
  cleanView: boolean
30
33
  presentationMode: boolean
31
34
  selectedSeriesId: string
@@ -83,7 +86,6 @@ export interface DemoControlPanelModel {
83
86
 
84
87
  export interface DemoChartModel {
85
88
  data: WaveformData
86
- minZoomSpan?: number
87
89
  initialXDomain?: [number, number]
88
90
  displayMode: WaveformDisplayMode
89
91
  overlayMode: WaveformOverlayMode
@@ -99,6 +101,7 @@ export interface DemoChartModel {
99
101
  cleanView: boolean
100
102
  presentationMode: boolean
101
103
  showTooltip: boolean
104
+ plotMargin: WaveformPlotMargin
102
105
  zeroLine: WaveformZeroLineOptions
103
106
  frameWatermarkVisible: boolean
104
107
  annotations: WaveformAnnotation[]
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
  WaveformAnnotationStyle,
18
18
  WaveformAnnotation,
19
19
  WaveformRenderingOptions,
20
+ WaveformPlotMargin,
20
21
  WaveformTitleTextStyle,
21
22
  WaveformTitleOptions,
22
23
  WaveformLegendPosition,
package/src/styles.css CHANGED
@@ -195,6 +195,7 @@ body {
195
195
  }
196
196
 
197
197
  .frame-style-controls,
198
+ .plot-margin-controls,
198
199
  .auxiliary-style-controls {
199
200
  display: grid;
200
201
  gap: 10px;
@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
74
74
  errorBarMinSpacing?: number
75
75
  }
76
76
 
77
+ /** Pixel margins reserved above and below the waveform plotting area. */
78
+ export interface WaveformPlotMargin {
79
+ /** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
80
+ top?: number
81
+ /** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
82
+ bottom?: number
83
+ }
84
+
77
85
  /** Text styling for the chart-level title. */
78
86
  export interface WaveformTitleTextStyle {
79
87
  color?: string
@@ -12,6 +12,7 @@ export type {
12
12
  WaveformAnnotationStyle,
13
13
  WaveformAnnotation,
14
14
  WaveformRenderingOptions,
15
+ WaveformPlotMargin,
15
16
  WaveformTitleTextStyle,
16
17
  WaveformTitleOptions,
17
18
  WaveformLegendPosition,
@@ -40,10 +40,11 @@ describe('waveform number formatters', () => {
40
40
  expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
41
41
  })
42
42
 
43
- it('derives the exponent from Math.max(axisMin, axisMax)', () => {
43
+ it('derives the exponent from the largest absolute endpoint', () => {
44
44
  expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
45
- expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3)
46
- expect(resolveScientificAxisExponent(-1000, 0)).toBeNull()
45
+ expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
46
+ expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(4)
47
+ expect(resolveScientificAxisExponent(-1000, 0)).toBe(3)
47
48
  expect(resolveScientificAxisExponent(0, 0)).toBeNull()
48
49
  })
49
50
 
@@ -42,8 +42,7 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
42
42
  export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
43
43
  if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
44
44
 
45
- const maxValue = Math.max(axisMin, axisMax)
46
- const absoluteMaxValue = Math.abs(maxValue)
45
+ const absoluteMaxValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
47
46
  return shouldUseScientificAxisLabel(absoluteMaxValue)
48
47
  ? Math.floor(Math.log10(absoluteMaxValue))
49
48
  : null
@@ -37,7 +37,8 @@ export function downsampleLTTB(data: WaveformPoint[], threshold: number): Wavefo
37
37
 
38
38
  // 确保阈值至少为 3
39
39
  const sampledLength = Math.max(3, Math.floor(threshold))
40
- const sampled: WaveformPoint[] = new Array(sampledLength)
40
+ const sampled: WaveformPoint[] = []
41
+ sampled.length = sampledLength
41
42
 
42
43
  // 始终保留第一个和最后一个点
43
44
  sampled[0] = data[0]!