waveform-analysis 0.1.40 → 0.1.42

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 (28) hide show
  1. package/README.md +19 -3
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +478 -477
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/core/grid.d.ts +3 -0
  6. package/dist/types/components/core/layout.d.ts +4 -3
  7. package/dist/types/components/core/trackLayoutBuilder.d.ts +1 -0
  8. package/dist/types/components/core/useWaveformChartController.d.ts +1 -0
  9. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -4
  10. package/dist/types/components/core/useWaveformLayout.d.ts +1 -0
  11. package/dist/types/types/chart.d.ts +2 -0
  12. package/package.json +1 -1
  13. package/src/components/annotation/WaveformAnnotationEditor.css +30 -0
  14. package/src/components/annotation/WaveformAnnotationEditor.vue +1 -0
  15. package/src/components/annotation/components.test.ts +14 -1
  16. package/src/components/core/fixedYDomainLayout.test.ts +94 -6
  17. package/src/components/core/grid.test.ts +48 -0
  18. package/src/components/core/grid.ts +20 -7
  19. package/src/components/core/layout.test.ts +31 -5
  20. package/src/components/core/layout.ts +21 -8
  21. package/src/components/core/trackLayoutBuilder.ts +14 -9
  22. package/src/components/core/useWaveformChartLifecycle.ts +25 -15
  23. package/src/components/core/useWaveformLayout.ts +9 -2
  24. package/src/components/waveformChartCases/compactAndSharedHover.test.ts +2 -2
  25. package/src/components/waveformChartCases/dataLifecycle.test.ts +2 -2
  26. package/src/components/waveformChartCases/gridFill.test.ts +95 -0
  27. package/src/components/waveformChartCases/layoutAndDomains.test.ts +23 -3
  28. package/src/types/chart.ts +2 -0
@@ -20,6 +20,13 @@ const Y_AXIS_OUTER_PADDING = 4
20
20
  const Y_AXIS_LABEL_GAP = 6
21
21
  const Y_AXIS_LABEL_BAND_WIDTH = 24
22
22
 
23
+ export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number {
24
+ if (typeof splitNumber === 'number' && Number.isFinite(splitNumber)) {
25
+ return Math.max(2, Math.floor(splitNumber))
26
+ }
27
+ return 5
28
+ }
29
+
23
30
  export interface YAxisSeriesGroup {
24
31
  index: number
25
32
  side: 'left' | 'right'
@@ -104,11 +111,16 @@ export function axisTextMetrics(
104
111
  nice = true,
105
112
  tickValues?: number[],
106
113
  unit?: string,
114
+ tickCount = 10,
107
115
  ): { tickTextWidth: number } {
116
+ const effectiveTickCount =
117
+ typeof tickCount === 'number' && Number.isFinite(tickCount)
118
+ ? Math.max(2, Math.floor(tickCount))
119
+ : 10
108
120
  const scale = scaleLinear(domain, [1, 0])
109
- if (nice) scale.nice()
121
+ if (nice) scale.nice(effectiveTickCount)
110
122
  const [axisMin, axisMax] = scale.domain()
111
- const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax]))
123
+ const values = tickValues ?? scale.ticks(effectiveTickCount)
112
124
  const topTickValue = Math.max(...values)
113
125
  const maximumTickCharacters = Math.max(
114
126
  1,
@@ -121,9 +133,9 @@ export function axisTextMetrics(
121
133
  }
122
134
  }
123
135
 
124
- export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
136
+ export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number {
125
137
  return (
126
- axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
138
+ axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
127
139
  .tickTextWidth +
128
140
  Y_AXIS_TICK_PADDING +
129
141
  Y_AXIS_LABEL_GAP +
@@ -132,9 +144,9 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
132
144
  )
133
145
  }
134
146
 
135
- function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
147
+ function measureYAxisGroupTickClearance(group: YAxisSeriesGroup, tickCount?: number): number {
136
148
  return (
137
- axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
149
+ axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
138
150
  .tickTextWidth +
139
151
  Y_AXIS_TICK_PADDING +
140
152
  Y_AXIS_OUTER_PADDING
@@ -146,13 +158,14 @@ export function measureTrackYAxisClearance(
146
158
  overlayMode: WaveformOverlayMode,
147
159
  yDomain?: WaveformYDomain,
148
160
  yDomains?: Record<string, WaveformYDomain>,
161
+ tickCount?: number,
149
162
  ): { left: number; right: number } {
150
163
  return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
151
164
  (clearance, group) => {
152
165
  clearance[group.side] +=
153
166
  overlayMode === 'multi-axis' || track.visibleSeries.length === 1
154
- ? measureYAxisGroupClearance(group)
155
- : measureYAxisGroupTickClearance(group)
167
+ ? measureYAxisGroupClearance(group, tickCount)
168
+ : measureYAxisGroupTickClearance(group, tickCount)
156
169
  return clearance
157
170
  },
158
171
  { left: 0, right: 0 },
@@ -25,7 +25,7 @@ import {
25
25
  type GridCellGeometry,
26
26
  type NormalizedWaveformGridOptions,
27
27
  } from './grid'
28
- import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
28
+ import { axisTextMetrics, resolveYAxisSeriesGroups, resolveYAxisTickCount } from './layout'
29
29
  import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
30
30
  import { applyXDomainStrategy } from './xDomain'
31
31
  import {
@@ -54,6 +54,7 @@ export interface BuildTrackLayoutsOptions {
54
54
  yDomains?: Record<string, [number, number]>
55
55
  timeUnit: 's' | 'ms'
56
56
  xAxisLabelFormatter?: WaveformXAxisLabelFormatter
57
+ yAxisSplitNumber?: number
57
58
  rendering: ResolvedWaveformRenderingOptions
58
59
  hideSecondaryLabels: boolean
59
60
  yAxisLabelX: number
@@ -124,22 +125,26 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
124
125
  )
125
126
  const sideOffsets = { left: 0, right: 0 }
126
127
  const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
128
+ const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
129
+ const niceCount = Math.max(1, tickCount - 1)
127
130
  const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
128
- if (!group.fixed) scale.nice()
129
- const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
131
+ scale.nice(niceCount)
130
132
  const [axisStart, axisEnd] = scale.domain()
133
+ const majorTicks = Array.from(
134
+ { length: tickCount },
135
+ (_, index) => axisStart + ((axisEnd - axisStart) * index) / (tickCount - 1),
136
+ )
131
137
  const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
132
138
  const visibleMajorTicks = showAxisEnd
133
139
  ? majorTicks
134
140
  : majorTicks.filter((tick) => tick !== axisEnd)
135
- const tickValues = Array.from(
136
- new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
137
- )
141
+ const tickValues = visibleMajorTicks
138
142
  const { tickTextWidth } = axisTextMetrics(
139
- group.domain,
140
- !group.fixed,
143
+ scale.domain() as [number, number],
144
+ false,
141
145
  tickValues,
142
146
  group.seriesList[0]?.unit,
147
+ tickCount,
143
148
  )
144
149
  const clearance =
145
150
  tickTextWidth +
@@ -261,7 +266,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
261
266
  (isEmpty || hasVisibleSeries) &&
262
267
  (options.displayMode === 'independent' ||
263
268
  (options.displayMode === 'compact'
264
- ? cell.row === options.grid.rowCount - 1
269
+ ? (cell.isLastRow ?? cell.row === options.grid.rowCount - 1)
265
270
  : bottomCells.has(cell.slotIndex))),
266
271
  }
267
272
  })
@@ -11,6 +11,7 @@ import {
11
11
 
12
12
  import type { AnnotationSeriesCandidate } from '../annotation'
13
13
  import type { useWaveformAnnotationInteraction } from '../annotation'
14
+ import type { NormalizedWaveformGridOptions } from './grid'
14
15
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
15
16
  import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
16
17
  import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
@@ -33,7 +34,7 @@ interface LifecycleContext {
33
34
  currentPage: Ref<number>
34
35
  pageCount: ComputedRef<number>
35
36
  pagedTracks: ComputedRef<Array<DisplayTrack | undefined>>
36
- gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
37
+ gridOptions: ComputedRef<NormalizedWaveformGridOptions>
37
38
  chartSeries: ComputedRef<DisplaySeries[]>
38
39
  chartTracks: ComputedRef<DisplayTrack[]>
39
40
  trackLayouts: ComputedRef<TrackLayout[]>
@@ -220,6 +221,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
220
221
  currentPage,
221
222
  () => gridOptions.value.rowCount,
222
223
  () => gridOptions.value.columnCount,
224
+ () => gridOptions.value.fillIncompleteLastRow,
223
225
  activeInteractionMode,
224
226
  ],
225
227
  async () => {
@@ -239,20 +241,28 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
239
241
  },
240
242
  )
241
243
 
242
- watch([pageCount, () => props.grid?.rowCount, () => props.grid?.columnCount], () => {
243
- const previousPage = currentPage.value
244
- currentPage.value =
245
- currentPage.value > pageCount.value
246
- ? pageCount.value
247
- : currentPage.value !== 1
248
- ? 1
249
- : currentPage.value
250
- if (previousPage !== currentPage.value) {
251
- emit('page-change', currentPage.value, pageCount.value)
252
- }
253
- clearHover()
254
- void nextTick(configureZoom)
255
- })
244
+ watch(
245
+ [
246
+ pageCount,
247
+ () => props.grid?.rowCount,
248
+ () => props.grid?.columnCount,
249
+ () => props.grid?.fillIncompleteLastRow,
250
+ ],
251
+ () => {
252
+ const previousPage = currentPage.value
253
+ currentPage.value =
254
+ currentPage.value > pageCount.value
255
+ ? pageCount.value
256
+ : currentPage.value !== 1
257
+ ? 1
258
+ : currentPage.value
259
+ if (previousPage !== currentPage.value) {
260
+ emit('page-change', currentPage.value, pageCount.value)
261
+ }
262
+ clearHover()
263
+ void nextTick(configureZoom)
264
+ },
265
+ )
256
266
 
257
267
  watch(activeInteractionMode, () => {
258
268
  editorSeriesOptions.value = []
@@ -114,8 +114,13 @@ export function useWaveformLayout(context: LayoutContext) {
114
114
  )
115
115
  .map(
116
116
  (group) =>
117
- axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
118
- .tickTextWidth,
117
+ axisTextMetrics(
118
+ group.domain,
119
+ true,
120
+ undefined,
121
+ group.seriesList[0]?.unit,
122
+ props.axes?.y?.splitNumber,
123
+ ).tickTextWidth,
119
124
  )
120
125
  const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
121
126
  const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
@@ -161,6 +166,7 @@ export function useWaveformLayout(context: LayoutContext) {
161
166
  props.overlayMode,
162
167
  props.yDomain,
163
168
  props.yDomains,
169
+ props.axes?.y?.splitNumber,
164
170
  )
165
171
  return {
166
172
  left: Math.max(maximum.left, clearance.left),
@@ -302,6 +308,7 @@ export function useWaveformLayout(context: LayoutContext) {
302
308
  : sharedYDomains.value,
303
309
  timeUnit: props.timeUnit,
304
310
  xAxisLabelFormatter: props.axes?.x?.labelFormatter,
311
+ yAxisSplitNumber: props.axes?.y?.splitNumber,
305
312
  rendering: renderingOptions.value,
306
313
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
307
314
  yAxisLabelX: yAxisMetrics.value.labelCenterX,
@@ -65,9 +65,9 @@ describe('WaveformChart', () => {
65
65
  )
66
66
 
67
67
  expect(endTicks[0]).toHaveLength(1)
68
- expect(Number(endTicks[0][0].text())).toBe(0.9)
68
+ expect(Number(endTicks[0][0].text())).toBe(1)
69
69
  expect(endTicks[1]).toHaveLength(1)
70
- expect(Number(endTicks[1][0].text())).toBe(1.9)
70
+ expect(Number(endTicks[1][0].text())).toBe(2)
71
71
  expect(endTicks[2]).toHaveLength(0)
72
72
  expect(endTicks[3]).toHaveLength(0)
73
73
  })
@@ -261,7 +261,7 @@ describe('WaveformChart', () => {
261
261
  })
262
262
 
263
263
  expect(startTicks).toHaveLength(1)
264
- expect(Number(startTicks[0].text())).toBe(0.1)
264
+ expect(Number(startTicks[0].text())).toBe(0)
265
265
  },
266
266
  )
267
267
 
@@ -297,7 +297,7 @@ describe('WaveformChart', () => {
297
297
  { displayMode },
298
298
  )
299
299
 
300
- const expectedEndValues = [0.9, 4.9]
300
+ const expectedEndValues = [1, 5]
301
301
  wrapper.findAll('.waveform-chart__track').forEach((track, index) => {
302
302
  const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
303
303
  const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)
@@ -0,0 +1,95 @@
1
+ import { flushPromises } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { gridSeries, mountSizedChart } from '../../test/waveformChart'
5
+
6
+ function geometry(track: { attributes: (name: string) => string | undefined }) {
7
+ return {
8
+ left: Number(track.attributes('data-track-left')),
9
+ top: Number(track.attributes('data-track-top')),
10
+ width: Number(track.attributes('data-track-width')),
11
+ height: Number(track.attributes('data-track-height')),
12
+ }
13
+ }
14
+
15
+ describe('WaveformChart incomplete grid rows', () => {
16
+ it.each(['independent', 'separated', 'compact'] as const)(
17
+ 'fills an incomplete two by two row in %s mode',
18
+ async (displayMode) => {
19
+ const wrapper = await mountSizedChart(gridSeries(3), {
20
+ displayMode,
21
+ grid: { rowCount: 2, columnCount: 2, fillIncompleteLastRow: true },
22
+ })
23
+ const tracks = wrapper.findAll('.waveform-chart__track')
24
+ const first = geometry(tracks[0]!)
25
+ const last = geometry(tracks[2]!)
26
+
27
+ expect(tracks).toHaveLength(3)
28
+ expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(0)
29
+ expect(wrapper.findAll('.waveform-chart__grid-slot-placeholder')).toHaveLength(0)
30
+ expect(last.left).toBe(0)
31
+ expect(last.width).toBeGreaterThan(first.width * 1.8)
32
+ expect(last.top).toBeGreaterThan(first.top)
33
+ if (displayMode === 'independent') {
34
+ const overlays = wrapper.findAll('.waveform-chart__overlay--independent')
35
+ expect(overlays).toHaveLength(3)
36
+ expect(Number(overlays[2]?.attributes('width'))).toBe(last.width)
37
+ } else {
38
+ expect(wrapper.findAll('.waveform-chart__overlay--shared')).toHaveLength(1)
39
+ }
40
+ },
41
+ )
42
+
43
+ it('removes unused one-column rows and redistributes chart height', async () => {
44
+ const base = await mountSizedChart(gridSeries(3), {
45
+ displayMode: 'independent',
46
+ grid: { rowCount: 4, columnCount: 1 },
47
+ })
48
+ const filled = await mountSizedChart(gridSeries(3), {
49
+ displayMode: 'independent',
50
+ grid: { rowCount: 4, columnCount: 1, fillIncompleteLastRow: true },
51
+ })
52
+ const baseFirst = geometry(base.findAll('.waveform-chart__track')[0]!)
53
+ const filledTracks = filled.findAll('.waveform-chart__track')
54
+ const filledFirst = geometry(filledTracks[0]!)
55
+ const filledLast = geometry(filledTracks[2]!)
56
+
57
+ expect(filledTracks).toHaveLength(3)
58
+ expect(filledFirst.height).toBeGreaterThan(baseFirst.height)
59
+ expect(filledLast.top + filledLast.height).toBeGreaterThan(baseFirst.height * 3)
60
+ })
61
+
62
+ it('keeps the final two tracks evenly distributed in a two by three grid', async () => {
63
+ const wrapper = await mountSizedChart(gridSeries(5), {
64
+ displayMode: 'separated',
65
+ grid: { rowCount: 2, columnCount: 3, fillIncompleteLastRow: true },
66
+ })
67
+ const tracks = wrapper.findAll('.waveform-chart__track').map(geometry)
68
+
69
+ expect(tracks).toHaveLength(5)
70
+ expect(tracks[3]?.left).toBe(0)
71
+ expect(tracks[3]?.width).toBeCloseTo(tracks[4]?.width ?? 0)
72
+ expect(tracks[4]?.left).toBeGreaterThan((tracks[3]?.width ?? 0) + 1)
73
+ })
74
+
75
+ it('keeps full pages unchanged and fills a partial second page', async () => {
76
+ const data = gridSeries(5)
77
+ const standard = await mountSizedChart(data, {
78
+ displayMode: 'separated',
79
+ grid: { rowCount: 2, columnCount: 2 },
80
+ })
81
+ const filled = await mountSizedChart(data, {
82
+ displayMode: 'separated',
83
+ grid: { rowCount: 2, columnCount: 2, fillIncompleteLastRow: true },
84
+ })
85
+ const standardFirstPage = standard.findAll('.waveform-chart__track').map(geometry)
86
+ expect(filled.findAll('.waveform-chart__track').map(geometry)).toEqual(standardFirstPage)
87
+
88
+ await filled.get('.ant-pagination-next button').trigger('click')
89
+ await flushPromises()
90
+ const secondPage = filled.findAll('.waveform-chart__track').map(geometry)
91
+ expect(secondPage).toHaveLength(1)
92
+ expect(secondPage[0]?.left).toBe(0)
93
+ expect(secondPage[0]?.width).toBeGreaterThan(standardFirstPage[0]?.width ?? 0)
94
+ })
95
+ })
@@ -6,6 +6,26 @@ import { resizeObservers } from '../../test/setup'
6
6
  import { gridSeries, mountSizedChart } from '../../test/waveformChart'
7
7
 
8
8
  describe('WaveformChart', () => {
9
+ it('applies the configured Y-axis split number', async () => {
10
+ const wrapper = await mountSizedChart(
11
+ {
12
+ kind: 'points',
13
+ points: [
14
+ { x: 0, y: 3 },
15
+ { x: 1, y: 97 },
16
+ ],
17
+ },
18
+ { axes: { y: { splitNumber: 5 } } },
19
+ )
20
+
21
+ expect(
22
+ wrapper
23
+ .get('.waveform-chart__axis--y')
24
+ .findAll('.tick text')
25
+ .map((tick) => tick.text()),
26
+ ).toEqual(['0', '25', '50', '75', '100'])
27
+ })
28
+
9
29
  it('expands the Y-axis label gutter for signed values and long exponents', async () => {
10
30
  const wrapper = await mountSizedChart(
11
31
  {
@@ -296,7 +316,7 @@ describe('WaveformChart', () => {
296
316
  expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
297
317
  })
298
318
 
299
- it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => {
319
+ it('reacts to nice fixed Y-domain props and returns to automatic bounds', async () => {
300
320
  const wrapper = await mountSizedChart({
301
321
  kind: 'points',
302
322
  points: [
@@ -312,8 +332,8 @@ describe('WaveformChart', () => {
312
332
 
313
333
  await wrapper.setProps({ yDomain: [3, 97] })
314
334
  await flushPromises()
315
- expect(yTickLabels()).toContain('3')
316
- expect(yTickLabels()).toContain('97')
335
+ expect(yTickLabels()).toContain('0')
336
+ expect(yTickLabels()).toContain('100')
317
337
 
318
338
  await wrapper.setProps({ yDomain: undefined })
319
339
  await flushPromises()
@@ -174,6 +174,8 @@ export interface WaveformAxesOptions {
174
174
  }
175
175
  y?: {
176
176
  lineVisible?: boolean
177
+ /** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */
178
+ splitNumber?: number
177
179
  }
178
180
  }
179
181