waveform-analysis 0.1.53 → 0.1.55

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.
@@ -5,63 +5,151 @@ import { flushAnimationFrames } from '../../test/setup'
5
5
  import { mountSizedChart } from '../../test/waveformChart'
6
6
 
7
7
  describe('WaveformChart incomplete data hover', () => {
8
- it('keeps the shared crosshair at the query time and marks out-of-range series unavailable', async () => {
9
- const wrapper = await mountSizedChart(
10
- {
11
- kind: 'series',
12
- series: [
13
- {
14
- name: '完整数据',
15
- data: {
16
- kind: 'points',
17
- points: [
18
- { x: -1, y: 1 },
19
- { x: -0.5, y: 2 },
20
- { x: 1, y: 3 },
21
- ],
8
+ it.each(['separated', 'compact'] as const)(
9
+ 'clamps each track crosshair and tooltip values to data endpoints in %s mode',
10
+ async (displayMode) => {
11
+ const wrapper = await mountSizedChart(
12
+ {
13
+ kind: 'series',
14
+ series: [
15
+ {
16
+ name: '完整数据',
17
+ data: {
18
+ kind: 'points',
19
+ points: [
20
+ { x: -1, y: 1 },
21
+ { x: -0.5, y: 2 },
22
+ { x: 1, y: 3 },
23
+ ],
24
+ },
22
25
  },
23
- },
24
- {
25
- name: '从零开始',
26
- data: {
27
- kind: 'points',
28
- points: [
29
- { x: 0, y: 10 },
30
- { x: 1, y: 20 },
31
- ],
26
+ {
27
+ name: '从零开始',
28
+ data: {
29
+ kind: 'points',
30
+ points: [
31
+ { x: 0, y: 10 },
32
+ { x: 1, y: 20 },
33
+ ],
34
+ },
32
35
  },
33
- },
34
- ],
35
- },
36
- { displayMode: 'separated' },
37
- )
38
- const overlay = wrapper.get('.waveform-chart__overlay--shared')
39
- const overlayWidth = Number(overlay.attributes('width'))
40
- Object.defineProperty(overlay.element, 'getBoundingClientRect', {
41
- value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
42
- })
36
+ ],
37
+ },
38
+ { displayMode },
39
+ )
40
+ const overlay = wrapper.get('.waveform-chart__overlay--shared')
41
+ const overlayWidth = Number(overlay.attributes('width'))
42
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
43
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
44
+ })
43
45
 
44
- overlay.element.dispatchEvent(
45
- new MouseEvent('pointermove', {
46
- clientX: overlayWidth / 4,
47
- clientY: 120,
48
- bubbles: true,
49
- }),
50
- )
51
- flushAnimationFrames()
52
- await flushPromises()
46
+ overlay.element.dispatchEvent(
47
+ new MouseEvent('pointermove', {
48
+ clientX: overlayWidth / 4,
49
+ clientY: 120,
50
+ bubbles: true,
51
+ }),
52
+ )
53
+ flushAnimationFrames()
54
+ await flushPromises()
53
55
 
54
- const crosshairPositions = wrapper
55
- .findAll('.waveform-chart__crosshair line')
56
- .map((line) => Number(line.attributes('x1')))
57
- expect(crosshairPositions).toHaveLength(2)
58
- expect(crosshairPositions[0]).toBeCloseTo(overlayWidth / 4)
59
- expect(crosshairPositions[1]).toBeCloseTo(overlayWidth / 4)
60
- const tooltipRows = wrapper.findAll('.waveform-chart__tooltip-series')
61
- expect(tooltipRows).toHaveLength(2)
62
- expect(tooltipRows[0].text()).toContain('(x:-500 y:2)')
63
- expect(tooltipRows[1].text()).toContain('无数据')
64
- expect(tooltipRows[1].text()).not.toContain('x:')
65
- expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: -0.5, y: 2 }])
66
- })
56
+ const crosshairPositions = wrapper
57
+ .findAll('.waveform-chart__crosshair line')
58
+ .map((line) => Number(line.attributes('x1')))
59
+ expect(crosshairPositions).toHaveLength(2)
60
+ expect(crosshairPositions[0]).toBeCloseTo(overlayWidth / 4)
61
+ expect(crosshairPositions[1]).toBeCloseTo(overlayWidth / 2)
62
+ const tooltipRows = wrapper.findAll('.waveform-chart__tooltip-series')
63
+ expect(tooltipRows).toHaveLength(2)
64
+ expect(tooltipRows[0].text()).toContain('(x:-500 y:2)')
65
+ expect(tooltipRows[1].text()).toContain('(x:0 y:10)')
66
+ expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: -0.5, y: 2 }])
67
+ },
68
+ )
69
+
70
+ it.each(['separated', 'compact'] as const)(
71
+ 'uses the longest series for a shared track crosshair while clamping each tooltip row in %s mode',
72
+ async (displayMode) => {
73
+ const wrapper = await mountSizedChart(
74
+ {
75
+ kind: 'series',
76
+ series: [
77
+ {
78
+ name: '范围参考',
79
+ data: {
80
+ kind: 'points',
81
+ points: [
82
+ { x: -2, y: 1 },
83
+ { x: 2, y: 2 },
84
+ ],
85
+ },
86
+ },
87
+ {
88
+ name: '最长曲线',
89
+ trackId: 'same-track',
90
+ data: {
91
+ kind: 'points',
92
+ points: [
93
+ { x: 0, y: 10 },
94
+ { x: 1, y: 20 },
95
+ ],
96
+ },
97
+ },
98
+ {
99
+ name: '较短曲线',
100
+ trackId: 'same-track',
101
+ data: {
102
+ kind: 'points',
103
+ points: [
104
+ { x: 0.25, y: 30 },
105
+ { x: 0.5, y: 40 },
106
+ ],
107
+ },
108
+ },
109
+ ],
110
+ },
111
+ { displayMode },
112
+ )
113
+ const overlay = wrapper.get('.waveform-chart__overlay--shared')
114
+ const overlayWidth = Number(overlay.attributes('width'))
115
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
116
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
117
+ })
118
+
119
+ overlay.element.dispatchEvent(
120
+ new MouseEvent('pointermove', {
121
+ clientX: overlayWidth / 8,
122
+ clientY: 120,
123
+ bubbles: true,
124
+ }),
125
+ )
126
+ flushAnimationFrames()
127
+ await flushPromises()
128
+
129
+ let crosshairPositions = wrapper
130
+ .findAll('.waveform-chart__crosshair line')
131
+ .map((line) => Number(line.attributes('x1')))
132
+ expect(crosshairPositions).toHaveLength(2)
133
+ expect(crosshairPositions[1]).toBeCloseTo(overlayWidth / 2)
134
+ expect(wrapper.text()).toContain('最长曲线(x:0 y:10)')
135
+ expect(wrapper.text()).toContain('较短曲线(x:250 y:30)')
136
+
137
+ overlay.element.dispatchEvent(
138
+ new MouseEvent('pointermove', {
139
+ clientX: (overlayWidth * 7) / 8,
140
+ clientY: 120,
141
+ bubbles: true,
142
+ }),
143
+ )
144
+ flushAnimationFrames()
145
+ await flushPromises()
146
+
147
+ crosshairPositions = wrapper
148
+ .findAll('.waveform-chart__crosshair line')
149
+ .map((line) => Number(line.attributes('x1')))
150
+ expect(crosshairPositions[1]).toBeCloseTo((overlayWidth * 3) / 4)
151
+ expect(wrapper.text()).toContain('最长曲线(x:1,000 y:20)')
152
+ expect(wrapper.text()).toContain('较短曲线(x:500 y:40)')
153
+ },
154
+ )
67
155
  })
@@ -49,4 +49,58 @@ describe('WorkerSamplingRepository sampling targets', () => {
49
49
  if (response.type !== 'sample-viewport-response') return
50
50
  expect(response.results[0]?.diagnostics.renderedPointCount).toBeLessThanOrEqual(4)
51
51
  })
52
+
53
+ it.each(['lttb', 'average', 'sum'] as const)(
54
+ 'returns exactly the fixed target for %s when enough points are visible',
55
+ (strategy) => {
56
+ const source = Array.from({ length: 100_000 }, (_, index) => ({
57
+ x: index,
58
+ y: Math.sin(index / 10),
59
+ }))
60
+ const repository = new WorkerSamplingRepository()
61
+ repository.handle({
62
+ type: 'register-dataset',
63
+ requestId: 1,
64
+ datasetId: strategy,
65
+ revision: 0,
66
+ points: source,
67
+ })
68
+ const response = repository.handle({
69
+ type: 'sample-viewport',
70
+ requestId: 2,
71
+ series: [
72
+ {
73
+ ...sampleSeries(strategy),
74
+ xDomain: [0, 99_999],
75
+ strategy,
76
+ maxPointCount: 1_000,
77
+ },
78
+ ],
79
+ })
80
+
81
+ expect(response).toMatchObject({
82
+ results: [{ diagnostics: { visiblePointCount: 100_000, renderedPointCount: 1_000 } }],
83
+ })
84
+ },
85
+ )
86
+
87
+ it('does not add points when the visible source has fewer than the fixed target', () => {
88
+ const repository = new WorkerSamplingRepository()
89
+ register(repository, 'small-target')
90
+ const response = repository.handle({
91
+ type: 'sample-viewport',
92
+ requestId: 3,
93
+ series: [
94
+ {
95
+ ...sampleSeries('small-target'),
96
+ strategy: 'lttb',
97
+ maxPointCount: 100,
98
+ },
99
+ ],
100
+ })
101
+
102
+ expect(response).toMatchObject({
103
+ results: [{ diagnostics: { visiblePointCount: 12, renderedPointCount: 12 } }],
104
+ })
105
+ })
52
106
  })
@@ -11,6 +11,11 @@ import type {
11
11
  WaveformSamplingMode,
12
12
  WaveformSamplingStrategy,
13
13
  } from '../types'
14
+ import {
15
+ samplingSizeOptions,
16
+ showsSamplingSize,
17
+ usesFixedPointCount,
18
+ } from './wasmSamplingDemoSampling'
14
19
 
15
20
  const SERIES_COUNT = 10
16
21
  const POINTS_PER_SERIES = 100_000
@@ -68,6 +73,7 @@ const data = createDenseData()
68
73
  const mode = ref<WaveformSamplingMode>('auto')
69
74
  const strategy = ref<WaveformSamplingStrategy>('peak')
70
75
  const autoThreshold = ref(1_000)
76
+ const maxPointsPerPixel = ref(3)
71
77
  const maxPointCount = ref(1_000)
72
78
  const minVisiblePoints = ref(2)
73
79
  const annotations = ref<WaveformAnnotation[]>([])
@@ -78,7 +84,7 @@ const rendering = computed<WaveformRenderingOptions>(() => ({
78
84
  mode: mode.value,
79
85
  strategy: strategy.value,
80
86
  autoThreshold: autoThreshold.value,
81
- maxPointCount: maxPointCount.value,
87
+ ...samplingSizeOptions(strategy.value, maxPointsPerPixel.value, maxPointCount.value),
82
88
  rawPointLimit: 100_000,
83
89
  wasmFailureFallback: 'javascript',
84
90
  },
@@ -128,8 +134,8 @@ function pointCount(value: number | undefined) {
128
134
  size="small"
129
135
  />
130
136
  </label>
131
- <label>
132
- <span>最多渲染点</span>
137
+ <label v-if="usesFixedPointCount(strategy)">
138
+ <span>目标渲染点数</span>
133
139
  <InputNumber
134
140
  v-model:value="maxPointCount"
135
141
  :min="1"
@@ -138,6 +144,17 @@ function pointCount(value: number | undefined) {
138
144
  size="small"
139
145
  />
140
146
  </label>
147
+ <label v-else-if="showsSamplingSize(strategy)">
148
+ <span>每像素点数</span>
149
+ <InputNumber
150
+ v-model:value="maxPointsPerPixel"
151
+ :min="0.1"
152
+ :max="10"
153
+ :step="0.1"
154
+ size="small"
155
+ />
156
+ </label>
157
+ <span v-else class="sampling-demo__sampling-disabled">当前策略不执行降采样</span>
141
158
  <label>
142
159
  <span>最少可见点</span>
143
160
  <InputNumber v-model:value="minVisiblePoints" :min="2" :max="1000" :step="1" size="small" />
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ samplingSizeOptions,
5
+ showsSamplingSize,
6
+ usesFixedPointCount,
7
+ } from './wasmSamplingDemoSampling'
8
+
9
+ describe('WASM sampling demo sizing', () => {
10
+ it('uses pixel density for peak-oriented and extrema strategies', () => {
11
+ expect(samplingSizeOptions('auto', 3, 1_000)).toEqual({ maxPointsPerPixel: 3 })
12
+ expect(samplingSizeOptions('peak', 3, 1_000)).toEqual({ maxPointsPerPixel: 3 })
13
+ expect(samplingSizeOptions('minmax', 3, 1_000)).toEqual({ maxPointsPerPixel: 3 })
14
+ })
15
+
16
+ it('uses a fixed point count for LTTB and aggregate strategies', () => {
17
+ for (const strategy of ['lttb', 'average', 'sum'] as const) {
18
+ expect(usesFixedPointCount(strategy)).toBe(true)
19
+ expect(samplingSizeOptions(strategy, 3, 1_000)).toEqual({ maxPointCount: 1_000 })
20
+ }
21
+ })
22
+
23
+ it('does not show a sampling-size control or pass a size for none', () => {
24
+ expect(showsSamplingSize('none')).toBe(false)
25
+ expect(samplingSizeOptions('none', 3, 1_000)).toEqual({})
26
+ })
27
+ })
@@ -0,0 +1,23 @@
1
+ import type { WaveformSamplingOptions, WaveformSamplingStrategy } from '../types'
2
+
3
+ type SamplingSizeOptions = Pick<WaveformSamplingOptions, 'maxPointCount' | 'maxPointsPerPixel'>
4
+
5
+ const fixedPointCountStrategies: readonly WaveformSamplingStrategy[] = ['lttb', 'average', 'sum']
6
+
7
+ export function usesFixedPointCount(strategy: WaveformSamplingStrategy) {
8
+ return fixedPointCountStrategies.includes(strategy)
9
+ }
10
+
11
+ export function showsSamplingSize(strategy: WaveformSamplingStrategy) {
12
+ return strategy !== 'none'
13
+ }
14
+
15
+ export function samplingSizeOptions(
16
+ strategy: WaveformSamplingStrategy,
17
+ maxPointsPerPixel: number,
18
+ maxPointCount: number,
19
+ ): SamplingSizeOptions {
20
+ if (usesFixedPointCount(strategy)) return { maxPointCount }
21
+ if (!showsSamplingSize(strategy)) return {}
22
+ return { maxPointsPerPixel }
23
+ }