waveform-analysis 0.1.66 → 0.1.67

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 (60) hide show
  1. package/README.md +124 -3
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +1059 -939
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/WaveformChart.vue.d.ts +4 -0
  6. package/dist/types/components/core/grid.d.ts +1 -1
  7. package/dist/types/components/core/layout.d.ts +2 -2
  8. package/dist/types/components/core/pagination.d.ts +2 -0
  9. package/dist/types/components/core/title.d.ts +2 -1
  10. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -0
  11. package/dist/types/components/core/useWaveformChartController.d.ts +6 -0
  12. package/dist/types/components/core/useWaveformLayout.d.ts +1 -0
  13. package/dist/types/components/core/useWaveformPresentation.d.ts +1 -0
  14. package/dist/types/components/core/useWaveformYAxisLayout.d.ts +31 -0
  15. package/dist/types/components/core/waveformChartTypes.d.ts +9 -2
  16. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +1 -1
  17. package/dist/types/components/data/types.d.ts +1 -1
  18. package/dist/types/components/interaction/interactionTarget.d.ts +1 -0
  19. package/dist/types/components/interaction/remoteViewport.d.ts +8 -0
  20. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -2
  21. package/dist/types/components/interaction/viewportDomain.d.ts +1 -2
  22. package/dist/types/components/interaction/viewportPan.d.ts +4 -0
  23. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -0
  24. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +4 -0
  25. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +5 -1
  26. package/dist/types/data/recordedDampedWaveform.d.ts +1 -0
  27. package/dist/types/index.d.ts +1 -1
  28. package/dist/types/types/chart.d.ts +13 -0
  29. package/dist/types/types/index.d.ts +1 -1
  30. package/package.json +1 -1
  31. package/src/components/WaveformChart.css +5 -0
  32. package/src/components/WaveformChart.vue +2 -0
  33. package/src/components/WaveformChartView.vue +14 -3
  34. package/src/components/core/grid.ts +4 -1
  35. package/src/components/core/layout.ts +16 -2
  36. package/src/components/core/pagination.ts +17 -0
  37. package/src/components/core/title.ts +7 -3
  38. package/src/components/core/trackLayoutBuilder.ts +14 -4
  39. package/src/components/core/useWaveformChartController.ts +17 -1
  40. package/src/components/core/useWaveformChartLifecycle.ts +10 -16
  41. package/src/components/core/useWaveformLayout.ts +35 -91
  42. package/src/components/core/useWaveformPresentation.ts +17 -3
  43. package/src/components/core/useWaveformYAxisLayout.ts +140 -0
  44. package/src/components/core/waveformChartTypes.ts +12 -0
  45. package/src/components/core/yAxisLayoutMetrics.ts +11 -2
  46. package/src/components/data/types.ts +3 -0
  47. package/src/components/interaction/interactionTarget.ts +10 -0
  48. package/src/components/interaction/remoteViewport.ts +53 -0
  49. package/src/components/interaction/useWaveformViewport.ts +17 -55
  50. package/src/components/interaction/useWaveformZoom.ts +7 -7
  51. package/src/components/interaction/viewportDomain.ts +10 -14
  52. package/src/components/interaction/viewportPan.ts +82 -0
  53. package/src/components/rendering/WaveformLegend.vue +10 -1
  54. package/src/components/rendering/WaveformTrack.vue +13 -2
  55. package/src/components/rendering/WaveformTrackAxes.vue +15 -4
  56. package/src/data/recordedDampedWaveform.ts +3 -0
  57. package/src/data/simulatedWaveforms.ts +12 -10
  58. package/src/index.ts +3 -0
  59. package/src/types/chart.ts +16 -0
  60. package/src/types/index.ts +3 -0
@@ -93,6 +93,7 @@ export function useWaveformPresentation(context: PresentationContext) {
93
93
  return position === 'top' || position === 'bottom' ? 'horizontal' : 'vertical'
94
94
  }
95
95
 
96
+ const isEdgeCompact = computed(() => props.layoutPreset === 'edge-compact')
96
97
  const resolvedTitleText = computed(() => props.title?.text.trim() ?? '')
97
98
  const titleAreaReserved = computed(
98
99
  () =>
@@ -146,10 +147,17 @@ export function useWaveformPresentation(context: PresentationContext) {
146
147
  }))
147
148
  const titleLayout = computed(() =>
148
149
  calculateRotatedTitleLayout({
149
- naturalWidth: measuredTitleWidth.value || estimatedTitleWidth.value,
150
+ naturalWidth:
151
+ isEdgeCompact.value && !titleIsRotated.value && measuredTitleHeight.value > 0
152
+ ? Math.min(
153
+ measuredTitleWidth.value || titleAvailableWidth.value,
154
+ titleAvailableWidth.value,
155
+ )
156
+ : measuredTitleWidth.value || estimatedTitleWidth.value,
150
157
  naturalHeight: measuredTitleHeight.value || titleFontSize.value * TITLE_LINE_HEIGHT,
151
158
  availableWidth: titleAvailableWidth.value,
152
159
  rotation: titleRotation.value,
160
+ edgeCompact: isEdgeCompact.value,
153
161
  }),
154
162
  )
155
163
  const titleAreaHeight = computed(() =>
@@ -159,13 +167,18 @@ export function useWaveformPresentation(context: PresentationContext) {
159
167
  typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
160
168
  const resolvedPlotMargin = computed(() => ({
161
169
  top: resolvePlotMargin(props.plotMargin.top, margin.top),
162
- bottom: resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
170
+ bottom: Math.max(
171
+ isEdgeCompact.value ? 44 : 0,
172
+ resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
173
+ ),
163
174
  }))
164
175
  const chartTopMargin = computed(() => resolvedPlotMargin.value.top)
165
176
  const drawingHeight = computed(() =>
166
177
  Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
167
178
  )
168
- const xAxisTitleY = computed(() => Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET))
179
+ const xAxisTitleY = computed(() =>
180
+ Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET + (isEdgeCompact.value ? 3 : 0)),
181
+ )
169
182
  const innerHeight = computed(() =>
170
183
  Math.max(
171
184
  0,
@@ -202,6 +215,7 @@ export function useWaveformPresentation(context: PresentationContext) {
202
215
  chartHeight,
203
216
  containerStyle,
204
217
  isCleanView,
218
+ isEdgeCompact,
205
219
  isPresentationMode,
206
220
  resolvedZeroLine,
207
221
  legendBackgroundColor,
@@ -0,0 +1,140 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+
3
+ import { margin, MINIMUM_PLOT_WIDTH } from './constants'
4
+ import { getGridGap, type NormalizedWaveformGridOptions } from './grid'
5
+ import { buildYAxisSlots } from './layout'
6
+ import type { DisplayTrack } from './types'
7
+ import type { ResolvedWaveformChartProps } from './waveformChartTypes'
8
+ import { resolveYAxisLayoutMetrics } from './yAxisLayoutMetrics'
9
+ import { Y_AXIS_OUTER_PADDING } from './yAxisConstants'
10
+
11
+ interface YAxisLayoutContext {
12
+ props: ResolvedWaveformChartProps
13
+ chartTracks: ComputedRef<DisplayTrack[]>
14
+ layoutTracks: ComputedRef<DisplayTrack[]>
15
+ chartWidth: ComputedRef<number>
16
+ gridOptions: ComputedRef<NormalizedWaveformGridOptions>
17
+ viewportYDomains: ComputedRef<Record<string, [number, number]>>
18
+ yAxisTickCount: ComputedRef<number>
19
+ }
20
+
21
+ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
22
+ const {
23
+ props,
24
+ chartTracks,
25
+ layoutTracks,
26
+ chartWidth,
27
+ gridOptions,
28
+ viewportYDomains,
29
+ yAxisTickCount,
30
+ } = context
31
+ const isEdgeCompact = computed(() => props.layoutPreset === 'edge-compact')
32
+ const showUnits = computed(() => !isEdgeCompact.value || props.unitDisplayMode === 'axis')
33
+ const yAxisMetrics = computed(() =>
34
+ resolveYAxisLayoutMetrics(
35
+ chartTracks.value,
36
+ props.overlayMode,
37
+ props.yDomain,
38
+ props.yDomains,
39
+ viewportYDomains.value,
40
+ props.axes?.y?.nice !== false,
41
+ yAxisTickCount.value,
42
+ props.displayMode === 'compact',
43
+ props.axes?.y?.upperPaddingRatio,
44
+ showUnits.value,
45
+ ),
46
+ )
47
+ const hasYAxisLabels = computed(() =>
48
+ chartTracks.value.some(
49
+ (track) =>
50
+ track.visibleSeries.length === 1 &&
51
+ Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel),
52
+ ),
53
+ )
54
+ const hasVisibleWaveformData = computed(() =>
55
+ chartTracks.value.some((track) => track.visibleSeries.length > 0),
56
+ )
57
+ const chartLeftMargin = computed(() =>
58
+ Math.max(
59
+ margin.left,
60
+ hasYAxisLabels.value
61
+ ? yAxisMetrics.value.fullClearance
62
+ : hasVisibleWaveformData.value
63
+ ? yAxisMetrics.value.tickClearance
64
+ : 0,
65
+ ),
66
+ )
67
+ const yAxisSlots = computed(() =>
68
+ buildYAxisSlots(
69
+ layoutTracks.value.filter((track) => track.visibleSeries.length > 0),
70
+ props.overlayMode,
71
+ props.yDomain,
72
+ props.yDomains,
73
+ yAxisTickCount.value,
74
+ props.axes?.y?.nice !== false,
75
+ props.displayMode === 'compact',
76
+ viewportYDomains.value,
77
+ props.axes?.y?.upperPaddingRatio,
78
+ showUnits.value,
79
+ ),
80
+ )
81
+ const resolvedChartLeftMargin = computed(() =>
82
+ isEdgeCompact.value
83
+ ? margin.right + Math.max(0, compactLeftClearance.value - Y_AXIS_OUTER_PADDING)
84
+ : props.overlayMode === 'multi-axis'
85
+ ? Math.max(chartLeftMargin.value, yAxisSlots.value.clearance.left)
86
+ : chartLeftMargin.value,
87
+ )
88
+ // The same slot places the title and reserves its entire 20px band. The outer
89
+ // padding belongs to the chart edge, rather than the old fixed 48px minimum.
90
+ const compactLeftClearance = computed(() =>
91
+ !hasVisibleWaveformData.value
92
+ ? 0
93
+ : props.overlayMode === 'multi-axis' || hasYAxisLabels.value
94
+ ? yAxisSlots.value.clearance.left
95
+ : yAxisMetrics.value.tickClearance,
96
+ )
97
+ const chartRightMargin = computed(() =>
98
+ props.overlayMode === 'multi-axis'
99
+ ? Math.max(margin.right, yAxisSlots.value.clearance.right)
100
+ : margin.right,
101
+ )
102
+ const innerWidth = computed(() =>
103
+ Math.max(0, chartWidth.value - resolvedChartLeftMargin.value - chartRightMargin.value),
104
+ )
105
+ const yAxisLayout = computed(() => {
106
+ const baseGap = getGridGap(props.displayMode)
107
+ const columnCount = gridOptions.value.columnCount
108
+ const hasMultipleColumns = columnCount > 1
109
+ const fullGap = Math.max(
110
+ baseGap,
111
+ isEdgeCompact.value ? yAxisSlots.value.clearance.left : yAxisMetrics.value.fullClearance,
112
+ )
113
+ const tickGap = Math.max(baseGap, yAxisMetrics.value.tickClearance)
114
+ const plotWidth = (innerWidth.value - fullGap * Math.max(0, columnCount - 1)) / columnCount
115
+ const canReserveLabelClearance = plotWidth >= MINIMUM_PLOT_WIDTH
116
+ return {
117
+ horizontalGap:
118
+ props.overlayMode === 'multi-axis' && hasMultipleColumns && hasVisibleWaveformData.value
119
+ ? Math.max(baseGap, yAxisSlots.value.clearance.left + yAxisSlots.value.clearance.right)
120
+ : hasMultipleColumns && hasVisibleWaveformData.value
121
+ ? hasYAxisLabels.value && canReserveLabelClearance
122
+ ? fullGap
123
+ : tickGap
124
+ : baseGap,
125
+ hideSecondaryLabels:
126
+ props.overlayMode !== 'multi-axis' &&
127
+ hasMultipleColumns &&
128
+ hasYAxisLabels.value &&
129
+ !canReserveLabelClearance,
130
+ }
131
+ })
132
+ return {
133
+ yAxisMetrics,
134
+ yAxisSlots,
135
+ resolvedChartLeftMargin,
136
+ innerWidth,
137
+ yAxisLayout,
138
+ hasVisibleWaveformData,
139
+ }
140
+ }
@@ -3,6 +3,8 @@ import type {
3
3
  WaveformAxesOptions,
4
4
  WaveformData,
5
5
  WaveformDisplayMode,
6
+ WaveformUnitDisplayMode,
7
+ WaveformLayoutPreset,
6
8
  WaveformFrameStyle,
7
9
  WaveformInteractionMode,
8
10
  WaveformLegendOptions,
@@ -18,6 +20,7 @@ import type {
18
20
  WaveformZeroLineOptions,
19
21
  WaveformZoomIntentPayload,
20
22
  WaveformZoomEndPayload,
23
+ WaveformPanEndPayload,
21
24
  WaveformZoomResetPayload,
22
25
  } from '../data/types'
23
26
  import type { WaveformGridOptions } from './grid'
@@ -26,6 +29,10 @@ export interface WaveformChartProps {
26
29
  data: WaveformData
27
30
  displayMode?: WaveformDisplayMode
28
31
  overlayMode?: WaveformOverlayMode
32
+ /** Default axis units; optional chart-wide single-series legend units. */
33
+ unitDisplayMode?: WaveformUnitDisplayMode
34
+ /** Default spacing or compact title/time labels with a separate pagination band. */
35
+ layoutPreset?: WaveformLayoutPreset
29
36
  width?: number
30
37
  height?: number
31
38
  xLabel?: string
@@ -36,6 +43,8 @@ export interface WaveformChartProps {
36
43
  /** Snap viewport endpoints outwards to integer X-axis display units. */
37
44
  integerZoom?: boolean
38
45
  pannable?: boolean
46
+ /** Global record bounds in seconds; enables horizontal panning beyond loaded data. */
47
+ panXDomain?: [number, number]
39
48
  minZoomSpan?: number
40
49
  minVisiblePoints?: number
41
50
  maxZoomScale?: number | null
@@ -68,6 +77,8 @@ export interface WaveformChartProps {
68
77
  type DefaultedProp =
69
78
  | 'displayMode'
70
79
  | 'overlayMode'
80
+ | 'unitDisplayMode'
81
+ | 'layoutPreset'
71
82
  | 'yLabel'
72
83
  | 'lineColor'
73
84
  | 'showTooltip'
@@ -97,6 +108,7 @@ export interface WaveformChartEmit {
97
108
  (event: 'zoom-intent', payload: WaveformZoomIntentPayload): void
98
109
  (event: 'zoom-change', domain: [number, number]): void
99
110
  (event: 'zoom-end', payload: WaveformZoomEndPayload): void
111
+ (event: 'pan-end', payload: WaveformPanEndPayload): void
100
112
  (event: 'zoom-reset', payload: WaveformZoomResetPayload): void
101
113
  (event: 'update:annotations', annotations: WaveformAnnotation[]): void
102
114
  (event: 'update:hidden-series-ids', ids: string[]): void
@@ -40,11 +40,20 @@ export function resolveYAxisLayoutMetrics(
40
40
  nice: boolean,
41
41
  tickCount: number,
42
42
  compact: boolean,
43
+ upperPaddingRatio = 0,
44
+ showUnits = true,
43
45
  ): YAxisLayoutMetrics {
44
46
  const axisText = chartTracks
45
47
  .filter((track) => track.visibleSeries.length > 0)
46
48
  .flatMap((track) =>
47
- resolveRenderedYAxisSeriesGroups(track, overlayMode, yDomain, yDomains, viewportYDomains),
49
+ resolveRenderedYAxisSeriesGroups(
50
+ track,
51
+ overlayMode,
52
+ yDomain,
53
+ yDomains,
54
+ viewportYDomains,
55
+ upperPaddingRatio,
56
+ ),
48
57
  )
49
58
  .map(
50
59
  (group) =>
@@ -52,7 +61,7 @@ export function resolveYAxisLayoutMetrics(
52
61
  group.domain,
53
62
  nice,
54
63
  undefined,
55
- group.seriesList[0]?.unit,
64
+ showUnits ? group.seriesList[0]?.unit : undefined,
56
65
  tickCount,
57
66
  compact,
58
67
  ).tickTextWidth,
@@ -7,11 +7,14 @@
7
7
  export type {
8
8
  WaveformPoint,
9
9
  WaveformDisplayMode,
10
+ WaveformUnitDisplayMode,
11
+ WaveformLayoutPreset,
10
12
  WaveformOverlayMode,
11
13
  WaveformInteractionMode,
12
14
  WaveformXDomainStrategy,
13
15
  WaveformZoomIntentPayload,
14
16
  WaveformZoomEndPayload,
17
+ WaveformPanEndPayload,
15
18
  WaveformZoomResetPayload,
16
19
  WaveformAnnotationStyle,
17
20
  WaveformAnnotation,
@@ -0,0 +1,10 @@
1
+ export function isEditableTarget(target: EventTarget | null): boolean {
2
+ return (
3
+ target instanceof Element &&
4
+ Boolean(
5
+ target.closest(
6
+ 'button, input, select, textarea, [contenteditable]:not([contenteditable="false"])',
7
+ ),
8
+ )
9
+ )
10
+ }
@@ -0,0 +1,53 @@
1
+ import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
2
+ import {
3
+ constrainZoomDomain,
4
+ resolveMinimumZoomSpan,
5
+ type ZoomSeriesGroup,
6
+ } from './zoomConstraints'
7
+
8
+ export function clampViewportDomain(
9
+ domain: [number, number],
10
+ boundary: [number, number],
11
+ ): [number, number] {
12
+ const span = domain[1] - domain[0]
13
+ const boundarySpan = boundary[1] - boundary[0]
14
+ if (span >= boundarySpan) return [...boundary]
15
+ if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
16
+ if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
17
+ return domain
18
+ }
19
+
20
+ export function remotePanBoundary(props: Pick<ResolvedWaveformChartProps, 'panXDomain'>) {
21
+ const domain = props.panXDomain
22
+ return domain && domain.every(Number.isFinite) && domain[0] < domain[1] ? domain : undefined
23
+ }
24
+
25
+ /** Restore remote windows without requiring points inside a not-yet-loaded interval. */
26
+ export function restoreViewportDomain(
27
+ domain: [number, number],
28
+ baseDomain: [number, number],
29
+ groups: readonly ZoomSeriesGroup[],
30
+ props: ResolvedWaveformChartProps,
31
+ ): [number, number] {
32
+ const boundary = remotePanBoundary(props)
33
+ return boundary
34
+ ? clampViewportDomain(domain, boundary)
35
+ : constrainZoomDomain(domain, baseDomain, groups, props)
36
+ }
37
+
38
+ /** Wheel/box zoom keeps the remote position and uses loaded data for scale limits. */
39
+ export function constrainViewportZoom(
40
+ domain: [number, number],
41
+ baseDomain: [number, number],
42
+ groups: readonly ZoomSeriesGroup[],
43
+ props: ResolvedWaveformChartProps,
44
+ ) {
45
+ const boundary = remotePanBoundary(props)
46
+ if (!boundary) return constrainZoomDomain(domain, baseDomain, groups, props)
47
+ return constrainZoomDomain(domain, boundary, [], {
48
+ ...props,
49
+ minVisiblePoints: 0,
50
+ maxZoomScale: null,
51
+ minZoomSpan: resolveMinimumZoomSpan(baseDomain, groups, props),
52
+ })
53
+ }
@@ -2,17 +2,17 @@ import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
2
2
  import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
3
3
  import { MINIMUM_SELECTION_SIZE } from '../core/constants'
4
4
  import type { DisplayTrack, TrackLayout } from '../core/types'
5
- import { hasFixedYDomainForTrack } from '../core/yDomain'
6
5
  import type { ResolvedWaveformChartProps, ViewportSelectionState } from '../core/waveformChartTypes'
7
6
  import type { WaveformChartEmit } from '../core/waveformChartTypes'
8
7
  import type { AnnotationSeriesCandidate } from '../annotation'
9
8
  import { tryReleasePointerCapture } from './pointerCapture'
10
9
  import { transitionViewportInteraction } from './viewportInteractionState'
11
- import { clampViewportDomain as clampDomain, createViewportDomainSetter } from './viewportDomain'
12
- import { alignIntegerPanDomain } from './integerZoom'
13
- import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
10
+ import { createViewportDomainSetter } from './viewportDomain'
11
+ import { constrainViewportZoom } from './remoteViewport'
12
+ import { createViewportPan } from './viewportPan'
13
+ import { transformForDomain } from './zoomConstraints'
14
14
  import { emitBoxZoomIntent } from './zoomEventPayload'
15
- interface ViewportContext {
15
+ export interface ViewportContext {
16
16
  props: ResolvedWaveformChartProps
17
17
  emit: WaveformChartEmit
18
18
  selection: Ref<ViewportSelectionState | null>
@@ -141,54 +141,7 @@ export function useWaveformViewport(context: ViewportContext) {
141
141
  trackLayouts.value.find((item) => item.hasVisibleSeries)
142
142
  if (track) beginViewportDrag(event, track.index, false)
143
143
  }
144
- const applyPan = (active: ViewportSelectionState, track: TrackLayout) => {
145
- const width = track.width || 1
146
- const height = track.height || 1
147
- const dx = active.currentX - active.startX
148
- const dy = active.currentY - active.startY
149
- const xSpan = active.xDomain[1] - active.xDomain[0]
150
- const sourceXDomain = active.independent
151
- ? resolveInitialTrackDomain(track)
152
- : initialXDomain.value
153
- const nextX = alignIntegerPanDomain(
154
- clampDomain(
155
- [active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
156
- sourceXDomain,
157
- ),
158
- props,
159
- )
160
- if (active.independent) {
161
- const nextTransforms = [...independentTransforms.value]
162
- nextTransforms[track.index] = transformForDomain(nextX, sourceXDomain, width)
163
- independentTransforms.value = nextTransforms
164
- } else {
165
- sharedTransform.value = transformForDomain(nextX, sourceXDomain, innerWidth.value)
166
- }
167
- const targets = active.independent
168
- ? [track]
169
- : trackLayouts.value.filter((target) => target.hasVisibleSeries)
170
- const nextIndependentDomains = { ...independentYDomains.value }
171
- const nextSharedDomains = { ...sharedYDomains.value }
172
- targets.forEach((target) => {
173
- const chartTrack = chartTracks.value.find((item) => item.id === target.id)
174
- if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) {
175
- return
176
- }
177
- const key = target.series?.trackId ?? target.series?.id ?? target.id
178
- const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
179
- const boundary = chartTrack?.yDomain ?? source
180
- const ySpan = source[1] - source[0]
181
- const nextY = clampDomain(
182
- [source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
183
- boundary,
184
- )
185
- if (active.independent) nextIndependentDomains[target.index] = nextY
186
- else nextSharedDomains[key] = nextY
187
- })
188
- if (active.independent) independentYDomains.value = nextIndependentDomains
189
- else sharedYDomains.value = nextSharedDomains
190
- emit('zoom-change', nextX)
191
- }
144
+ const applyPan = createViewportPan(context)
192
145
  const updateViewportDrag = (event: PointerEvent) => {
193
146
  if (isPresentationMode.value) return
194
147
  const active = selection.value
@@ -238,7 +191,7 @@ export function useWaveformViewport(context: ViewportContext) {
238
191
  const groups = active.independent
239
192
  ? [track.seriesList]
240
193
  : trackLayouts.value.filter((item) => item.hasVisibleSeries).map((item) => item.seriesList)
241
- const xDomain = constrainZoomDomain(
194
+ const xDomain = constrainViewportZoom(
242
195
  [track.xScale.invert(left), track.xScale.invert(right)],
243
196
  baseXDomain,
244
197
  groups,
@@ -320,7 +273,16 @@ export function useWaveformViewport(context: ViewportContext) {
320
273
  activeOverlay.value = undefined
321
274
  event.preventDefault()
322
275
  if (completed.kind === 'pan') {
323
- applyPan(completed, track)
276
+ const domain = applyPan(completed, track)
277
+ if (domain.some((value, index) => Math.abs(value - completed.xDomain[index]) > 1e-12)) {
278
+ emit('pan-end', {
279
+ start: domain[0],
280
+ end: domain[1],
281
+ ...(completed.independent
282
+ ? { trackIndex: track.index, seriesIds: track.seriesList.map((series) => series.id) }
283
+ : {}),
284
+ })
285
+ }
324
286
  void nextTick(configureZoom)
325
287
  return
326
288
  }
@@ -15,13 +15,9 @@ import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
15
15
  import type { TrackLayout } from '../core/types'
16
16
  import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
17
17
  import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
18
+ import { constrainViewportZoom } from './remoteViewport'
18
19
  import { normalizeIntegerZoomDomain } from './integerZoom'
19
- import {
20
- constrainZoomDomain,
21
- resolveMinimumZoomSpan,
22
- transformForDomain,
23
- type ZoomSeriesGroup,
24
- } from './zoomConstraints'
20
+ import { resolveMinimumZoomSpan, transformForDomain, type ZoomSeriesGroup } from './zoomConstraints'
25
21
  import {
26
22
  emitIndependentWheelZoomEnds,
27
23
  emitSharedWheelZoomEnd,
@@ -240,7 +236,11 @@ export function useWaveformZoom(context: ZoomContext) {
240
236
  number,
241
237
  number,
242
238
  ]
243
- return transformForDomain(constrainZoomDomain(requested, domain, groups, props), domain, width)
239
+ return transformForDomain(
240
+ constrainViewportZoom(requested, domain, groups, props),
241
+ domain,
242
+ width,
243
+ )
244
244
  }
245
245
  const canZoomTrack = (track: TrackLayout): boolean => {
246
246
  const minimum = Number(props.minVisiblePoints)
@@ -4,7 +4,8 @@ import type { ZoomTransform } from 'd3'
4
4
  import type { TrackLayout } from '../core/types'
5
5
  import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
6
6
  import type { AnnotationSeriesCandidate } from '../annotation'
7
- import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
7
+ import { transformForDomain } from './zoomConstraints'
8
+ import { constrainViewportZoom } from './remoteViewport'
8
9
 
9
10
  interface ViewportDomainContext {
10
11
  props: ResolvedWaveformChartProps
@@ -21,17 +22,7 @@ interface ViewportDomainContext {
21
22
  configureZoom: () => void
22
23
  }
23
24
 
24
- export function clampViewportDomain(
25
- domain: [number, number],
26
- boundary: [number, number],
27
- ): [number, number] {
28
- const span = domain[1] - domain[0]
29
- const boundarySpan = boundary[1] - boundary[0]
30
- if (span >= boundarySpan) return [...boundary]
31
- if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
32
- if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
33
- return domain
34
- }
25
+ export { clampViewportDomain } from './remoteViewport'
35
26
 
36
27
  export function createViewportDomainSetter(context: ViewportDomainContext) {
37
28
  return (domain: [number, number], trackIndex?: number) => {
@@ -49,7 +40,12 @@ export function createViewportDomainSetter(context: ViewportDomainContext) {
49
40
  const track = context.trackLayouts.value.find((item) => item.index === index)
50
41
  if (!track) return
51
42
  const boundary = context.resolveInitialTrackDomain(track)
52
- const constrained = constrainZoomDomain(domain, boundary, [track.seriesList], context.props)
43
+ const constrained = constrainViewportZoom(
44
+ domain,
45
+ boundary,
46
+ [track.seriesList],
47
+ context.props,
48
+ )
53
49
  nextTransforms[index] = transformForDomain(constrained, boundary, track.width)
54
50
  })
55
51
  context.independentTransforms.value = nextTransforms
@@ -58,7 +54,7 @@ export function createViewportDomainSetter(context: ViewportDomainContext) {
58
54
  const groups = context.trackLayouts.value
59
55
  .filter((track) => track.hasVisibleSeries)
60
56
  .map((track) => track.seriesList)
61
- const constrained = constrainZoomDomain(domain, boundary, groups, context.props)
57
+ const constrained = constrainViewportZoom(domain, boundary, groups, context.props)
62
58
  context.sharedTransform.value = transformForDomain(
63
59
  constrained,
64
60
  boundary,
@@ -0,0 +1,82 @@
1
+ import type { TrackLayout } from '../core/types'
2
+ import { hasFixedYDomainForTrack } from '../core/yDomain'
3
+ import type { ViewportSelectionState } from '../core/waveformChartTypes'
4
+ import type { ViewportContext } from './useWaveformViewport'
5
+ import { clampViewportDomain as clampDomain } from './viewportDomain'
6
+ import { alignIntegerPanDomain } from './integerZoom'
7
+ import { remotePanBoundary } from './remoteViewport'
8
+ import { transformForDomain } from './zoomConstraints'
9
+
10
+ export function createViewportPan(context: ViewportContext) {
11
+ const {
12
+ props,
13
+ emit,
14
+ resolveInitialTrackDomain,
15
+ initialXDomain,
16
+ independentTransforms,
17
+ sharedTransform,
18
+ innerWidth,
19
+ trackLayouts,
20
+ chartTracks,
21
+ independentYDomains,
22
+ sharedYDomains,
23
+ } = context
24
+ const applyPan = (active: ViewportSelectionState, track: TrackLayout) => {
25
+ const width = track.width || 1
26
+ const height = track.height || 1
27
+ const dx = active.currentX - active.startX
28
+ const dy = active.currentY - active.startY
29
+ const xSpan = active.xDomain[1] - active.xDomain[0]
30
+ const sourceXDomain = active.independent
31
+ ? resolveInitialTrackDomain(track)
32
+ : initialXDomain.value
33
+ const boundary = remotePanBoundary(props) ?? sourceXDomain
34
+ const nextX = clampDomain(
35
+ alignIntegerPanDomain(
36
+ clampDomain(
37
+ [active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
38
+ boundary,
39
+ ),
40
+ props,
41
+ ),
42
+ boundary,
43
+ )
44
+ if (active.independent) {
45
+ const nextTransforms = [...independentTransforms.value]
46
+ nextTransforms[track.index] = transformForDomain(nextX, sourceXDomain, width)
47
+ independentTransforms.value = nextTransforms
48
+ } else {
49
+ sharedTransform.value = transformForDomain(nextX, sourceXDomain, innerWidth.value)
50
+ }
51
+ if (remotePanBoundary(props)) {
52
+ emit('zoom-change', nextX)
53
+ return nextX
54
+ }
55
+ const targets = active.independent
56
+ ? [track]
57
+ : trackLayouts.value.filter((target) => target.hasVisibleSeries)
58
+ const nextIndependentDomains = { ...independentYDomains.value }
59
+ const nextSharedDomains = { ...sharedYDomains.value }
60
+ targets.forEach((target) => {
61
+ const chartTrack = chartTracks.value.find((item) => item.id === target.id)
62
+ if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) {
63
+ return
64
+ }
65
+ const key = target.series?.trackId ?? target.series?.id ?? target.id
66
+ const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
67
+ const boundary = chartTrack?.yDomain ?? source
68
+ const ySpan = source[1] - source[0]
69
+ const nextY = clampDomain(
70
+ [source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
71
+ boundary,
72
+ )
73
+ if (active.independent) nextIndependentDomains[target.index] = nextY
74
+ else nextSharedDomains[key] = nextY
75
+ })
76
+ if (active.independent) independentYDomains.value = nextIndependentDomains
77
+ else sharedYDomains.value = nextSharedDomains
78
+ emit('zoom-change', nextX)
79
+ return nextX
80
+ }
81
+ return applyPan
82
+ }
@@ -18,11 +18,13 @@ interface Props {
18
18
  width: number
19
19
  height: number
20
20
  interactive?: boolean
21
+ showUnits?: boolean
21
22
  hiddenSeriesIds?: string[]
22
23
  }
23
24
 
24
25
  const props = withDefaults(defineProps<Props>(), {
25
26
  interactive: false,
27
+ showUnits: false,
26
28
  hiddenSeriesIds: () => [],
27
29
  })
28
30
  const emit = defineEmits<{
@@ -30,6 +32,11 @@ const emit = defineEmits<{
30
32
  }>()
31
33
  const hiddenSeriesIdSet = computed(() => new Set(props.hiddenSeriesIds))
32
34
 
35
+ function legendLabel(series: DisplaySeries): string {
36
+ const unit = series.unit?.trim()
37
+ return props.showUnits && unit && unit !== '--' ? `${series.name} (${unit})` : series.name
38
+ }
39
+
33
40
  function isHidden(seriesId: string): boolean {
34
41
  return hiddenSeriesIdSet.value.has(seriesId)
35
42
  }
@@ -113,7 +120,9 @@ function toggleSeries(seriesId: string) {
113
120
  transform="translate(13 8)"
114
121
  />
115
122
  </svg>
116
- <span class="waveform-legend__label" :title="item.name">{{ item.name }}</span>
123
+ <span class="waveform-legend__label" :title="legendLabel(item)">{{
124
+ legendLabel(item)
125
+ }}</span>
117
126
  </button>
118
127
  </div>
119
128
  </div>