waveform-analysis 0.1.25 → 0.1.27

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waveform-analysis",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "main": "./dist/index.cjs",
5
5
  "module": "./dist/index.js",
6
6
  "types": "./dist/types/index.d.ts",
@@ -86,6 +86,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
86
86
  ? scaleLinear(
87
87
  options.initialXDomains?.[displayTrack.id] ??
88
88
  options.initialXDomains?.[series.id] ??
89
+ options.initialXDomain ??
89
90
  displayTrack.xDomain,
90
91
  [0, cell.width],
91
92
  )
@@ -3,6 +3,7 @@ import {
3
3
  select,
4
4
  zoom,
5
5
  zoomIdentity,
6
+ zoomTransform,
6
7
  type D3ZoomEvent,
7
8
  type ZoomBehavior,
8
9
  type ZoomTransform,
@@ -237,6 +238,15 @@ export function useWaveformZoom(context: ZoomContext) {
237
238
  const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
238
239
  return tracks.length > 0 && tracks.every(canZoomTrack)
239
240
  }
241
+ const canHandleWheelZoom = (event: Event, canZoomIn: boolean): boolean => {
242
+ if (event.type !== 'wheel') return false
243
+ const deltaY = (event as WheelEvent).deltaY
244
+ if (deltaY < 0) return canZoomIn
245
+ if (deltaY > 0) {
246
+ return zoomTransform(event.currentTarget as Element).k > ZOOM_CONSTRAINTS.MIN_SCALE
247
+ }
248
+ return false
249
+ }
240
250
  const configureZoom = () => {
241
251
  clearZoomBindings()
242
252
  if (
@@ -256,10 +266,7 @@ export function useWaveformZoom(context: ZoomContext) {
256
266
  if (!overlay) return
257
267
  const dataDomain = resolveInitialTrackDomain(track)
258
268
  const behavior = zoom<SVGRectElement, unknown>()
259
- .filter(
260
- (event) =>
261
- event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track),
262
- )
269
+ .filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
263
270
  .scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
264
271
  .extent([
265
272
  [0, 0],
@@ -287,10 +294,7 @@ export function useWaveformZoom(context: ZoomContext) {
287
294
  const overlay = sharedOverlayElement.value
288
295
  if (!overlay) return
289
296
  const behavior = zoom<SVGRectElement, unknown>()
290
- .filter(
291
- (event) =>
292
- event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(),
293
- )
297
+ .filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
294
298
  .scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
295
299
  .extent([
296
300
  [0, 0],
@@ -94,7 +94,7 @@ const resolvedFrameStyle = computed(() => {
94
94
  />
95
95
 
96
96
  <rect
97
- v-if="!track.isEmpty && !cleanView"
97
+ v-if="!track.isEmpty"
98
98
  class="waveform-track__plot-frame waveform-chart__plot-frame"
99
99
  :width="track.width ?? innerWidth"
100
100
  :height="track.height"
@@ -103,14 +103,14 @@ watch(
103
103
 
104
104
  <template>
105
105
  <g
106
- v-if="track.showXAxis && !cleanView"
106
+ v-if="track.showXAxis"
107
107
  ref="xAxisElement"
108
108
  class="waveform-track__axis waveform-track__axis--x waveform-chart__axis waveform-chart__axis--x"
109
109
  :class="{ 'waveform-track__axis--line-hidden': axes?.x?.lineVisible === false }"
110
110
  :transform="`translate(0, ${track.height})`"
111
111
  />
112
112
  <g
113
- v-if="track.showXAxis && !cleanView"
113
+ v-if="track.showXAxis"
114
114
  class="waveform-track__axis-endpoints waveform-chart__axis-endpoints"
115
115
  :transform="`translate(0, ${track.height})`"
116
116
  font-family="sans-serif"
@@ -137,7 +137,7 @@ watch(
137
137
  </text>
138
138
  </g>
139
139
  <text
140
- v-if="track.showXAxis && track.xAxisExponent && !cleanView"
140
+ v-if="track.showXAxis && track.xAxisExponent"
141
141
  class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
142
142
  :x="track.width ?? innerWidth"
143
143
  :y="track.height + 27"
@@ -148,7 +148,7 @@ watch(
148
148
  </text>
149
149
 
150
150
  <g
151
- v-for="axis in track.isEmpty || cleanView ? [] : track.yAxes"
151
+ v-for="axis in track.isEmpty ? [] : track.yAxes"
152
152
  :key="`y-axis-${track.index}-${axis.index}`"
153
153
  :ref="(element) => setYAxisElement(element, axis.index)"
154
154
  class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y"
@@ -161,9 +161,7 @@ watch(
161
161
  :transform="`translate(${axis.x}, 0)`"
162
162
  />
163
163
  <text
164
- v-for="axis in track.isEmpty || cleanView
165
- ? []
166
- : track.yAxes.filter((item) => item.exponentLabel)"
164
+ v-for="axis in track.isEmpty ? [] : track.yAxes.filter((item) => item.exponentLabel)"
167
165
  :key="`y-axis-exponent-${track.index}-${axis.index}`"
168
166
  class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
169
167
  :data-y-axis-index="axis.index"
@@ -225,7 +225,7 @@ describe('WaveformChart', () => {
225
225
  }
226
226
  })
227
227
 
228
- it('uses each track x domain in independent mode instead of the shared initial domain', async () => {
228
+ it('uses the shared initial x domain for every independent track by default', async () => {
229
229
  const wrapper = await mountSizedChart(
230
230
  {
231
231
  kind: 'series',
@@ -262,7 +262,9 @@ describe('WaveformChart', () => {
262
262
  )
263
263
 
264
264
  const tracks = wrapper.findAll('.waveform-chart__track')
265
- expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('6.00')
265
+ expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00')
266
+ expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
267
+ expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00')
266
268
  expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
267
269
  })
268
270
 
@@ -146,7 +146,7 @@ describe('WaveformChart', () => {
146
146
  kind: 'points',
147
147
  points: [
148
148
  { x: 0, y: -1 },
149
- { x: 1, y: 1 },
149
+ { x: 10_000, y: 1 },
150
150
  ],
151
151
  },
152
152
  },
@@ -227,8 +227,8 @@ describe('WaveformChart', () => {
227
227
  data: {
228
228
  kind: 'points',
229
229
  points: [
230
- { x: 0, y: 1 },
231
- { x: 1, y: 2 },
230
+ { x: 0, y: 10_000 },
231
+ { x: 10_000, y: 20_000 },
232
232
  ],
233
233
  },
234
234
  },
@@ -239,7 +239,7 @@ describe('WaveformChart', () => {
239
239
  kind: 'points',
240
240
  points: [
241
241
  { x: 0, y: 2 },
242
- { x: 1, y: 3 },
242
+ { x: 10_000, y: 3 },
243
243
  ],
244
244
  },
245
245
  },
@@ -252,6 +252,8 @@ describe('WaveformChart', () => {
252
252
  annotations: [{ id: 'note', seriesId: 'first', x: 0.5, y: 0, text: 'hidden note' }],
253
253
  zeroLine: { visible: true },
254
254
  title: { text: 'hidden title' },
255
+ axes: { x: { lineVisible: false }, y: { lineVisible: false } },
256
+ frameStyle: { borderColor: '#dc2626', borderWidth: 2, borderStyle: 'dashed' as const },
255
257
  }
256
258
  const regularWrapper = await mountSizedChart(data, sharedProps)
257
259
  const wrapper = await mountSizedChart(data, {
@@ -285,13 +287,31 @@ describe('WaveformChart', () => {
285
287
  expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true)
286
288
  expect(wrapper.get('.waveform-chart__title-area').attributes('aria-hidden')).toBe('true')
287
289
  expect(wrapper.find('.waveform-chart__title-visual').exists()).toBe(false)
288
- expect(wrapper.find('.waveform-chart__axis').exists()).toBe(false)
290
+ const xAxis = wrapper.get('.waveform-chart__axis--x')
291
+ const yAxes = wrapper.findAll('.waveform-chart__axis--y')
292
+ expect(yAxes).toHaveLength(2)
293
+ expect(xAxis.findAll('.tick line').length).toBeGreaterThan(0)
294
+ expect(xAxis.findAll('.tick text').length).toBeGreaterThan(0)
295
+ yAxes.forEach((axis) => {
296
+ expect(axis.findAll('.tick line').length).toBeGreaterThan(0)
297
+ expect(axis.findAll('.tick text').length).toBeGreaterThan(0)
298
+ expect(axis.get('path.domain').attributes('display')).toBe('none')
299
+ })
300
+ expect(xAxis.get('path.domain').attributes('display')).toBe('none')
301
+ expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2)
302
+ expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).not.toBe('')
303
+ expect(wrapper.get('.waveform-chart__axis-exponent--y').text()).not.toBe('')
289
304
  expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
290
- expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(false)
305
+ expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
306
+ stroke: '#dc2626',
307
+ 'stroke-width': '2',
308
+ 'stroke-dasharray': '6 4',
309
+ })
291
310
  expect(wrapper.find('.waveform-chart__plot-background').exists()).toBe(false)
292
311
  expect(wrapper.find('.waveform-chart__watermark').exists()).toBe(false)
293
312
  expect(wrapper.find('.waveform-chart__legend-layer').exists()).toBe(false)
294
313
  expect(wrapper.find('.waveform-chart__label').exists()).toBe(false)
314
+ expect(wrapper.find('.waveform-chart__y-axis-label').exists()).toBe(false)
295
315
  expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false)
296
316
  expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false)
297
317
  expect(wrapper.find('.ant-pagination').exists()).toBe(false)
@@ -241,7 +241,7 @@ describe('WaveformChart presentation mode', () => {
241
241
  await flushPromises()
242
242
  overlay.element.dispatchEvent(
243
243
  new WheelEvent('wheel', {
244
- deltaY: -200,
244
+ deltaY: 200,
245
245
  clientX: width / 2,
246
246
  clientY: height / 2,
247
247
  bubbles: true,
@@ -38,6 +38,21 @@ describe('WaveformChart', () => {
38
38
  expect(domain[1]).toBeLessThanOrEqual(1)
39
39
  expect(endpoints()[0]).not.toBe(initialEndpoints[0])
40
40
  expect(endpoints()[1]).toBe(initialEndpoints[1])
41
+
42
+ overlays[0].element.dispatchEvent(
43
+ new WheelEvent('wheel', {
44
+ deltaY: 4_000,
45
+ clientX: firstWidth - 1,
46
+ clientY: 130,
47
+ bubbles: true,
48
+ cancelable: true,
49
+ }),
50
+ )
51
+ flushAnimationFrames()
52
+ await flushPromises()
53
+
54
+ expect(endpoints()[0]).toBe(initialEndpoints[0])
55
+ expect(endpoints()[1]).toBe(initialEndpoints[1])
41
56
  })
42
57
 
43
58
  it('emits one zoom-end payload after a shared zoom gesture completes', async () => {
@@ -207,7 +207,7 @@ describe('WaveformChart', () => {
207
207
  expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10)
208
208
  })
209
209
 
210
- it('keeps one global domain while loading narrower and wider windows', async () => {
210
+ it('zooms a shared viewport back out after loading a narrower data window', async () => {
211
211
  const wrapper = await mountSizedChart(
212
212
  {
213
213
  kind: 'points',
@@ -256,9 +256,57 @@ describe('WaveformChart', () => {
256
256
 
257
257
  const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
258
258
  await dispatchWheel(4000)
259
- const currentDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
259
+ const zoomedOutDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
260
+ expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
261
+ expect(zoomedOutDomain[1] - zoomedOutDomain[0]).toBeCloseTo(100)
262
+
263
+ const fullDomainEventCount = wrapper.emitted('zoom-change')?.length ?? 0
264
+ await dispatchWheel(4000)
265
+ expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(fullDomainEventCount)
266
+ })
267
+
268
+ it('allows zooming out when the minimum visible point count blocks zooming in', async () => {
269
+ const wrapper = await mountSizedChart({
270
+ kind: 'points',
271
+ points: [
272
+ { x: 0, y: 0 },
273
+ { x: 1, y: 10 },
274
+ { x: 2, y: 20 },
275
+ ],
276
+ })
277
+ const overlay = wrapper.get('.waveform-chart__overlay--independent')
278
+ const width = Number(overlay.attributes('width'))
279
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
280
+ value: () => ({ left: 0, top: 0, width, height: 290 }),
281
+ })
282
+ const dispatchWheel = async (deltaY: number) => {
283
+ overlay.element.dispatchEvent(
284
+ new WheelEvent('wheel', {
285
+ deltaY,
286
+ clientX: width / 2,
287
+ clientY: 145,
288
+ bubbles: true,
289
+ cancelable: true,
290
+ }),
291
+ )
292
+ flushAnimationFrames()
293
+ await flushPromises()
294
+ }
295
+
296
+ await dispatchWheel(-4000)
297
+ const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
298
+ expect(zoomedDomain[1] - zoomedDomain[0]).toBeCloseTo(0.05)
299
+
300
+ await wrapper.setProps({ minVisiblePoints: 10 })
301
+ await flushPromises()
302
+ const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
303
+ await dispatchWheel(-1000)
260
304
  expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
261
- expect(currentDomain[1] - currentDomain[0]).toBeCloseTo(2.5)
305
+
306
+ await dispatchWheel(4000)
307
+ const zoomedOutDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
308
+ expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
309
+ expect(zoomedOutDomain).toEqual([0, 2])
262
310
  })
263
311
 
264
312
  it('resets a shared viewport on double-click and emits zoom-reset', async () => {
@@ -0,0 +1,114 @@
1
+ import { flushPromises } from '@vue/test-utils'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { flushAnimationFrames } from '../../test/setup'
5
+ import { mountSizedChart } from '../../test/waveformChart'
6
+
7
+ describe('WaveformChart wheel zoom out', () => {
8
+ it('restores an independent initial domain after replacing data with a narrow window', async () => {
9
+ const createData = (start: number, end: number) => ({
10
+ kind: 'series' as const,
11
+ series: [
12
+ {
13
+ id: 'channel-a',
14
+ name: '通道 A',
15
+ data: {
16
+ kind: 'points' as const,
17
+ points: [
18
+ { x: start, y: 0 },
19
+ { x: end, y: 1 },
20
+ ],
21
+ },
22
+ },
23
+ ],
24
+ })
25
+ const wrapper = await mountSizedChart(createData(-5, 5), {
26
+ displayMode: 'independent',
27
+ initialXDomain: [-5, 5],
28
+ })
29
+ const overlay = wrapper.get('.waveform-chart__overlay--independent')
30
+ const overlayWidth = Number(overlay.attributes('width'))
31
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
32
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
33
+ })
34
+ const dispatchWheel = async (deltaY: number) => {
35
+ overlay.element.dispatchEvent(
36
+ new WheelEvent('wheel', {
37
+ deltaY,
38
+ clientX: overlayWidth / 2,
39
+ clientY: 145,
40
+ bubbles: true,
41
+ cancelable: true,
42
+ }),
43
+ )
44
+ flushAnimationFrames()
45
+ await flushPromises()
46
+ }
47
+ const endpoints = () => ({
48
+ start: wrapper.get('.waveform-chart__axis-endpoint--start').text(),
49
+ end: wrapper.get('.waveform-chart__axis-endpoint--end').text(),
50
+ })
51
+
52
+ expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' })
53
+ await dispatchWheel(-4000)
54
+ expect(endpoints()).not.toEqual({ start: '-5.00', end: '5.00' })
55
+
56
+ await wrapper.setProps({ data: createData(-0.125, 0.125) })
57
+ await flushPromises()
58
+ await dispatchWheel(4000)
59
+
60
+ expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' })
61
+ })
62
+
63
+ it('emits one zoom-end payload after zooming a shared viewport out', async () => {
64
+ vi.useFakeTimers()
65
+ try {
66
+ const wrapper = await mountSizedChart({
67
+ kind: 'points',
68
+ points: [
69
+ { x: 0, y: 0 },
70
+ { x: 2, y: 1 },
71
+ ],
72
+ })
73
+ const overlay = wrapper.get('.waveform-chart__overlay')
74
+ const overlayWidth = Number(overlay.attributes('width'))
75
+ Object.defineProperty(overlay.element, 'getBoundingClientRect', {
76
+ value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
77
+ })
78
+ const dispatchWheel = (deltaY: number) =>
79
+ overlay.element.dispatchEvent(
80
+ new WheelEvent('wheel', {
81
+ deltaY,
82
+ clientX: overlayWidth / 2,
83
+ clientY: 145,
84
+ bubbles: true,
85
+ cancelable: true,
86
+ }),
87
+ )
88
+
89
+ dispatchWheel(-4000)
90
+ flushAnimationFrames()
91
+ await vi.advanceTimersByTimeAsync(200)
92
+ await flushPromises()
93
+ const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
94
+ const zoomedSpan = zoomedDomain[1] - zoomedDomain[0]
95
+
96
+ dispatchWheel(1000)
97
+ flushAnimationFrames()
98
+ expect(wrapper.emitted('zoom-end')).toHaveLength(1)
99
+ await vi.advanceTimersByTimeAsync(200)
100
+ await flushPromises()
101
+
102
+ const zoomedOutPayload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as {
103
+ start: number
104
+ end: number
105
+ gesture: string
106
+ }
107
+ expect(wrapper.emitted('zoom-end')).toHaveLength(2)
108
+ expect(zoomedOutPayload.end - zoomedOutPayload.start).toBeGreaterThan(zoomedSpan)
109
+ expect(zoomedOutPayload.gesture).toBe('wheel')
110
+ } finally {
111
+ vi.useRealTimers()
112
+ }
113
+ })
114
+ })