waveform-analysis 0.1.30 → 0.1.32
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.
- package/README.md +12 -2
- package/dist/index.cjs +1 -1
- package/dist/index.js +827 -602
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +2 -2
- package/dist/types/components/core/useWaveformChartController.d.ts +2 -14
- package/dist/types/components/core/useWaveformChartLifecycle.d.ts +4 -1
- package/dist/types/components/core/useWaveformData.d.ts +1 -1
- package/dist/types/components/core/waveformChartTypes.d.ts +8 -5
- package/dist/types/components/data/types.d.ts +1 -1
- package/dist/types/components/interaction/pointerCapture.d.ts +1 -0
- package/dist/types/components/interaction/viewportInteractionState.d.ts +39 -0
- package/dist/types/components/interaction/zoomConstraints.d.ts +14 -0
- package/dist/types/core/rendering.d.ts +1 -4
- package/dist/types/core/renderingStrategies.d.ts +22 -0
- package/dist/types/demo/types.d.ts +0 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/chart.d.ts +5 -0
- package/dist/types/types/index.d.ts +1 -1
- package/package.json +5 -1
- package/src/App.vue +0 -4
- package/src/components/WaveformChartView.vue +2 -2
- package/src/components/core/useWaveformChartController.ts +7 -2
- package/src/components/core/useWaveformChartLifecycle.ts +47 -3
- package/src/components/core/useWaveformData.ts +6 -1
- package/src/components/core/waveformChartTypes.ts +6 -4
- package/src/components/data/types.ts +1 -0
- package/src/components/interaction/WaveformTooltip.test.ts +39 -0
- package/src/components/interaction/WaveformTooltip.vue +72 -22
- package/src/components/interaction/pointerCapture.ts +10 -0
- package/src/components/interaction/useWaveformHover.ts +1 -1
- package/src/components/interaction/useWaveformViewport.ts +101 -75
- package/src/components/interaction/useWaveformZoom.ts +46 -14
- package/src/components/interaction/viewportInteractionState.test.ts +101 -0
- package/src/components/interaction/viewportInteractionState.ts +89 -0
- package/src/components/interaction/zoomConstraints.ts +167 -0
- package/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts +0 -65
- package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -0
- package/src/components/waveformChartCases/viewportReset.test.ts +114 -0
- package/src/components/waveformChartCases/zoomPointConstraints.test.ts +248 -0
- package/src/core/rendering.ts +11 -81
- package/src/core/renderingStrategies.test.ts +90 -0
- package/src/core/renderingStrategies.ts +131 -0
- package/src/data/simulatedWaveforms.ts +9 -10
- package/src/demo/DemoChartHost.vue +1 -2
- package/src/demo/types.ts +0 -1
- package/src/index.ts +1 -0
- package/src/types/chart.ts +6 -0
- package/src/types/index.ts +1 -0
- package/src/utils/formatters.test.ts +4 -3
- package/src/utils/formatters.ts +1 -2
- package/src/utils/sampling.ts +2 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { flushAnimationFrames } from '../../test/setup'
|
|
5
|
+
import { mountSizedChart } from '../../test/waveformChart'
|
|
6
|
+
|
|
7
|
+
const regularPoints = Array.from({ length: 1_200 }, (_, index) => ({ x: index, y: index % 11 }))
|
|
8
|
+
|
|
9
|
+
function visibleXCount(domain: [number, number], values = regularPoints): number {
|
|
10
|
+
return new Set(
|
|
11
|
+
values.filter((point) => point.x >= domain[0] && point.x <= domain[1]).map((point) => point.x),
|
|
12
|
+
).size
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function prepareOverlay(wrapper: VueWrapper, selector: string) {
|
|
16
|
+
const overlay = wrapper.get(selector)
|
|
17
|
+
const width = Number(overlay.attributes('width'))
|
|
18
|
+
const height = Number(overlay.attributes('height'))
|
|
19
|
+
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
20
|
+
value: () => ({ left: 0, top: 0, width, height }),
|
|
21
|
+
})
|
|
22
|
+
return { overlay, width, height }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function dispatchWheel(
|
|
26
|
+
overlay: ReturnType<VueWrapper['get']>,
|
|
27
|
+
width: number,
|
|
28
|
+
height: number,
|
|
29
|
+
deltaY: number,
|
|
30
|
+
) {
|
|
31
|
+
overlay.element.dispatchEvent(
|
|
32
|
+
new WheelEvent('wheel', {
|
|
33
|
+
deltaY,
|
|
34
|
+
clientX: width / 2,
|
|
35
|
+
clientY: height / 2,
|
|
36
|
+
bubbles: true,
|
|
37
|
+
cancelable: true,
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
flushAnimationFrames()
|
|
41
|
+
await flushPromises()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dispatchBox(
|
|
45
|
+
overlay: ReturnType<VueWrapper['get']>,
|
|
46
|
+
startX: number,
|
|
47
|
+
endX: number,
|
|
48
|
+
height: number,
|
|
49
|
+
pointerId: number,
|
|
50
|
+
) {
|
|
51
|
+
for (const [type, clientX] of [
|
|
52
|
+
['pointerdown', startX],
|
|
53
|
+
['pointermove', endX],
|
|
54
|
+
['pointerup', endX],
|
|
55
|
+
] as const) {
|
|
56
|
+
const event = new MouseEvent(type, {
|
|
57
|
+
button: 0,
|
|
58
|
+
clientX,
|
|
59
|
+
clientY: height / 2,
|
|
60
|
+
bubbles: true,
|
|
61
|
+
})
|
|
62
|
+
Object.defineProperty(event, 'pointerId', { value: pointerId })
|
|
63
|
+
overlay.element.dispatchEvent(event)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('WaveformChart point-aware zoom constraints', () => {
|
|
68
|
+
it('wheel-zooms 1,200 samples to two distinct X values, stops, and still zooms out', async () => {
|
|
69
|
+
const wrapper = await mountSizedChart(
|
|
70
|
+
{ kind: 'points', points: regularPoints },
|
|
71
|
+
{ minVisiblePoints: 2 },
|
|
72
|
+
)
|
|
73
|
+
const { overlay, width, height } = prepareOverlay(
|
|
74
|
+
wrapper,
|
|
75
|
+
'.waveform-chart__overlay--independent',
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
await dispatchWheel(overlay, width, height, -4_000)
|
|
79
|
+
await dispatchWheel(overlay, width, height, -4_000)
|
|
80
|
+
const twoPointDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
81
|
+
expect(visibleXCount(twoPointDomain)).toBe(2)
|
|
82
|
+
|
|
83
|
+
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
|
84
|
+
await dispatchWheel(overlay, width, height, -1_000)
|
|
85
|
+
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
|
86
|
+
|
|
87
|
+
await dispatchWheel(overlay, width, height, 4_000)
|
|
88
|
+
const partiallyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
89
|
+
expect(partiallyZoomedOut[1] - partiallyZoomedOut[0]).toBeGreaterThan(
|
|
90
|
+
twoPointDomain[1] - twoPointDomain[0],
|
|
91
|
+
)
|
|
92
|
+
await dispatchWheel(overlay, width, height, 4_000)
|
|
93
|
+
const fullyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
94
|
+
expect(fullyZoomedOut).toEqual([0, 1_199])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('keeps two fractional samples when the wheel focus falls between sample positions', async () => {
|
|
98
|
+
const points = Array.from({ length: 1_000 }, (_, index) => ({
|
|
99
|
+
x: -5 + (index * 10) / 999,
|
|
100
|
+
y: index,
|
|
101
|
+
}))
|
|
102
|
+
const wrapper = await mountSizedChart(
|
|
103
|
+
{ kind: 'points', points },
|
|
104
|
+
{ minVisiblePoints: 2, initialXDomain: [-5, 5] },
|
|
105
|
+
)
|
|
106
|
+
const { overlay, width, height } = prepareOverlay(
|
|
107
|
+
wrapper,
|
|
108
|
+
'.waveform-chart__overlay--independent',
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
for (let index = 0; index < 3; index += 1) {
|
|
112
|
+
overlay.element.dispatchEvent(
|
|
113
|
+
new WheelEvent('wheel', {
|
|
114
|
+
deltaY: -4_000,
|
|
115
|
+
clientX: width * 0.5032,
|
|
116
|
+
clientY: height / 2,
|
|
117
|
+
bubbles: true,
|
|
118
|
+
cancelable: true,
|
|
119
|
+
}),
|
|
120
|
+
)
|
|
121
|
+
flushAnimationFrames()
|
|
122
|
+
await flushPromises()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
126
|
+
expect(visibleXCount(domain, points)).toBe(2)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('uses repeated box zooms to reach exactly two distinct X values', async () => {
|
|
130
|
+
const wrapper = await mountSizedChart(
|
|
131
|
+
{ kind: 'points', points: regularPoints },
|
|
132
|
+
{ minVisiblePoints: 2 },
|
|
133
|
+
)
|
|
134
|
+
const { overlay, width, height } = prepareOverlay(
|
|
135
|
+
wrapper,
|
|
136
|
+
'.waveform-chart__overlay--independent',
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
for (let index = 0; index < 3; index += 1) {
|
|
140
|
+
dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, index + 1)
|
|
141
|
+
await flushPromises()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
145
|
+
expect(visibleXCount(domain)).toBe(2)
|
|
146
|
+
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
|
147
|
+
dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, 10)
|
|
148
|
+
await flushPromises()
|
|
149
|
+
const constrainedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
150
|
+
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
|
151
|
+
expect(visibleXCount(constrainedDomain)).toBe(2)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('retains the default 40x maximum when no explicit limit is configured', async () => {
|
|
155
|
+
const wrapper = await mountSizedChart({ kind: 'points', points: regularPoints })
|
|
156
|
+
const { overlay, width, height } = prepareOverlay(
|
|
157
|
+
wrapper,
|
|
158
|
+
'.waveform-chart__overlay--independent',
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
await dispatchWheel(overlay, width, height, -4_000)
|
|
162
|
+
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
163
|
+
expect(domain[1] - domain[0]).toBeCloseTo(1_199 / 40)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('retains two endpoint samples while replacing an independent data window', async () => {
|
|
167
|
+
const sourcePoints = Array.from({ length: 1_000 }, (_, index) => ({
|
|
168
|
+
x: -5 + (index * 10) / 999,
|
|
169
|
+
y: index,
|
|
170
|
+
}))
|
|
171
|
+
const wrapper = await mountSizedChart(
|
|
172
|
+
{ kind: 'points', points: sourcePoints },
|
|
173
|
+
{ minVisiblePoints: 2, initialXDomain: [-5, 5] },
|
|
174
|
+
)
|
|
175
|
+
const { overlay, width, height } = prepareOverlay(
|
|
176
|
+
wrapper,
|
|
177
|
+
'.waveform-chart__overlay--independent',
|
|
178
|
+
)
|
|
179
|
+
for (let index = 0; index < 10; index += 1) {
|
|
180
|
+
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
|
181
|
+
overlay.element.dispatchEvent(
|
|
182
|
+
new WheelEvent('wheel', {
|
|
183
|
+
deltaY: -1_000,
|
|
184
|
+
clientX: width * 0.5032,
|
|
185
|
+
clientY: height / 2,
|
|
186
|
+
bubbles: true,
|
|
187
|
+
cancelable: true,
|
|
188
|
+
}),
|
|
189
|
+
)
|
|
190
|
+
flushAnimationFrames()
|
|
191
|
+
await flushPromises()
|
|
192
|
+
if ((wrapper.emitted('zoom-change')?.length ?? 0) === eventCount) break
|
|
193
|
+
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
194
|
+
const windowPoints = sourcePoints.filter(
|
|
195
|
+
(point) => point.x >= domain[0] && point.x <= domain[1],
|
|
196
|
+
)
|
|
197
|
+
await wrapper.setProps({ data: { kind: 'points', points: windowPoints } })
|
|
198
|
+
await flushPromises()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const finalDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
202
|
+
const endpointDomain = wrapper
|
|
203
|
+
.findAll('.waveform-chart__axis-endpoint')
|
|
204
|
+
.slice(0, 2)
|
|
205
|
+
.map((endpoint) => Number(endpoint.text()) / 1_000) as [number, number]
|
|
206
|
+
expect(visibleXCount(finalDomain, sourcePoints)).toBe(2)
|
|
207
|
+
expect(visibleXCount(endpointDomain, sourcePoints)).toBe(2)
|
|
208
|
+
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
|
209
|
+
await dispatchWheel(overlay, width, height, -1_000)
|
|
210
|
+
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('recomputes irregular point constraints for shared tracks after data replacement', async () => {
|
|
214
|
+
const createData = (offset: number) => ({
|
|
215
|
+
kind: 'series' as const,
|
|
216
|
+
series: [
|
|
217
|
+
{
|
|
218
|
+
id: 'first',
|
|
219
|
+
name: 'First',
|
|
220
|
+
data: {
|
|
221
|
+
kind: 'points' as const,
|
|
222
|
+
points: [0, 1, 4, 20].map((x) => ({ x: x + offset, y: x })),
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
id: 'second',
|
|
227
|
+
name: 'Second',
|
|
228
|
+
data: {
|
|
229
|
+
kind: 'points' as const,
|
|
230
|
+
points: [0, 3, 9, 20].map((x) => ({ x: x + offset, y: x })),
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
})
|
|
235
|
+
const wrapper = await mountSizedChart(createData(0), {
|
|
236
|
+
displayMode: 'separated',
|
|
237
|
+
minVisiblePoints: 2,
|
|
238
|
+
})
|
|
239
|
+
const { overlay, width, height } = prepareOverlay(wrapper, '.waveform-chart__overlay')
|
|
240
|
+
|
|
241
|
+
await dispatchWheel(overlay, width, height, -4_000)
|
|
242
|
+
await wrapper.setProps({ data: createData(100) })
|
|
243
|
+
await flushPromises()
|
|
244
|
+
await dispatchWheel(overlay, width, height, 4_000)
|
|
245
|
+
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
|
246
|
+
expect(domain).toEqual([100, 120])
|
|
247
|
+
})
|
|
248
|
+
})
|
package/src/core/rendering.ts
CHANGED
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 = (
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
>
|
|
9
|
+
type ErrorGenerator = (
|
|
10
|
+
time: number,
|
|
11
|
+
value: number,
|
|
12
|
+
) => Pick<WaveformPoint, 'error' | 'lowerError' | 'upperError'>
|
|
13
13
|
|
|
14
|
-
interface SimulatedSeriesDefinition
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
...
|
|
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-
|
|
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"
|
package/src/demo/types.ts
CHANGED
package/src/index.ts
CHANGED
package/src/types/chart.ts
CHANGED
|
@@ -38,6 +38,12 @@ export interface WaveformZoomEndPayload {
|
|
|
38
38
|
gesture?: 'wheel' | 'box'
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Identifies the viewport reset by a double-click gesture. */
|
|
42
|
+
export interface WaveformZoomResetPayload {
|
|
43
|
+
trackIndex?: number
|
|
44
|
+
seriesIds?: string[]
|
|
45
|
+
}
|
|
46
|
+
|
|
41
47
|
/** 标注颜色样式 */
|
|
42
48
|
export interface WaveformAnnotationStyle {
|
|
43
49
|
borderColor?: string
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
|
43
|
+
it('derives the exponent from the largest absolute endpoint', () => {
|
|
44
44
|
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
|
|
45
|
-
expect(resolveScientificAxisExponent(-
|
|
46
|
-
expect(resolveScientificAxisExponent(-
|
|
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
|
|