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.
- package/README.md +8 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +937 -714
- package/dist/style.css +1 -1
- package/dist/types/components/WaveformChart.vue.d.ts +1 -0
- package/dist/types/components/core/constants.d.ts +2 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +8 -15
- 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/useWaveformLayout.d.ts +0 -1
- package/dist/types/components/core/useWaveformPresentation.d.ts +5 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +9 -5
- package/dist/types/components/data/types.d.ts +1 -1
- package/dist/types/components/index.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/components/waveform.d.ts +1 -1
- 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 +4 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/chart.d.ts +7 -0
- package/dist/types/types/index.d.ts +1 -1
- package/package.json +5 -1
- package/src/App.vue +10 -4
- package/src/components/WaveformChart.vue +1 -0
- package/src/components/WaveformChartView.vue +15 -12
- package/src/components/core/constants.ts +3 -0
- 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/useWaveformLayout.ts +0 -3
- package/src/components/core/useWaveformPresentation.ts +17 -2
- package/src/components/core/waveformChartTypes.ts +7 -3
- package/src/components/data/types.ts +1 -0
- package/src/components/index.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 +86 -74
- 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/waveform.ts +1 -0
- package/src/components/waveformChartCases/sizingAndTitles.test.ts +59 -0
- package/src/components/waveformChartCases/viewportLifecycle.test.ts +132 -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 +2 -2
- package/src/demo/DemoGridFrameControls.vue +29 -0
- package/src/demo/plotMarginControls.test.ts +29 -0
- package/src/demo/types.ts +4 -1
- package/src/index.ts +1 -0
- package/src/styles.css +1 -0
- package/src/types/chart.ts +8 -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
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"
|
|
@@ -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
package/src/styles.css
CHANGED
package/src/types/chart.ts
CHANGED
|
@@ -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
|
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
|
|
package/src/utils/formatters.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/utils/sampling.ts
CHANGED
|
@@ -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[] =
|
|
40
|
+
const sampled: WaveformPoint[] = []
|
|
41
|
+
sampled.length = sampledLength
|
|
41
42
|
|
|
42
43
|
// 始终保留第一个和最后一个点
|
|
43
44
|
sampled[0] = data[0]!
|