waveform-analysis 0.1.65 → 0.1.66

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 (47) hide show
  1. package/README.md +23 -3
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +705 -617
  4. package/dist/types/components/WaveformChart.vue.d.ts +3 -2
  5. package/dist/types/components/core/constants.d.ts +2 -0
  6. package/dist/types/components/core/trackLayoutBuilder.d.ts +1 -0
  7. package/dist/types/components/core/types.d.ts +2 -1
  8. package/dist/types/components/core/useWaveformChartController.d.ts +3 -0
  9. package/dist/types/components/core/useWaveformPresentation.d.ts +2 -0
  10. package/dist/types/components/core/waveformChartTypes.d.ts +3 -1
  11. package/dist/types/components/core/yAxisTitleLayout.d.ts +4 -0
  12. package/dist/types/components/interaction/integerZoom.d.ts +12 -0
  13. package/dist/types/components/interaction/viewportDomain.d.ts +1 -0
  14. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -1
  15. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -6
  16. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +0 -4
  17. package/dist/types/components/rendering/WaveformZeroLines.vue.d.ts +12 -0
  18. package/dist/types/demo/types.d.ts +3 -0
  19. package/dist/types/index.d.ts +1 -0
  20. package/dist/types/types/chart.d.ts +4 -0
  21. package/package.json +1 -1
  22. package/src/App.vue +6 -0
  23. package/src/components/WaveformChart.vue +1 -0
  24. package/src/components/core/constants.ts +2 -0
  25. package/src/components/core/trackLayoutBuilder.ts +26 -56
  26. package/src/components/core/types.ts +2 -0
  27. package/src/components/core/useWaveformChartLifecycle.ts +1 -1
  28. package/src/components/core/useWaveformLayout.ts +32 -16
  29. package/src/components/core/useWaveformPresentation.ts +10 -0
  30. package/src/components/core/waveformChartTypes.ts +3 -0
  31. package/src/components/core/yAxisTitleLayout.ts +78 -0
  32. package/src/components/interaction/integerZoom.ts +64 -0
  33. package/src/components/interaction/useWaveformViewport.ts +8 -12
  34. package/src/components/interaction/useWaveformZoom.ts +6 -4
  35. package/src/components/interaction/viewportDomain.ts +12 -0
  36. package/src/components/interaction/zoomConstraints.ts +7 -5
  37. package/src/components/rendering/WaveformTrack.vue +11 -4
  38. package/src/components/rendering/WaveformTrackAxes.vue +1 -1
  39. package/src/components/rendering/WaveformTrackBackdrop.vue +2 -34
  40. package/src/components/rendering/WaveformZeroLines.vue +53 -0
  41. package/src/demo/DemoChartHost.vue +1 -0
  42. package/src/demo/DemoGridFrameControls.vue +12 -0
  43. package/src/demo/DemoViewControls.vue +4 -0
  44. package/src/demo/types.ts +3 -0
  45. package/src/index.ts +1 -0
  46. package/src/styles.css +3 -3
  47. package/src/types/chart.ts +4 -0
@@ -0,0 +1,78 @@
1
+ import { formatYAxisTickLabel } from './layout'
2
+ import type { TrackLayout, WaveformYAxisLayout } from './types'
3
+ import {
4
+ Y_AXIS_CHARACTER_WIDTH,
5
+ Y_AXIS_LABEL_BAND_WIDTH,
6
+ Y_AXIS_TICK_PADDING,
7
+ } from './yAxisConstants'
8
+
9
+ // Match the existing 14px channel names and 11px ticks in WaveformTrack.css.
10
+ const TITLE_FONT_SIZE = 14
11
+ const TICK_FONT_SIZE = 11
12
+ const TEXT_GAP = 2
13
+
14
+ function estimateTitleWidth(title: string): number {
15
+ return Array.from(title).reduce((width, character) => {
16
+ // Latin labels average well below one em; full-width characters occupy one em.
17
+ const em = /[\u2e80-\u9fff\uf900-\ufaff\uff01-\uff60]/u.test(character)
18
+ ? 1
19
+ : /[MW@%]/u.test(character)
20
+ ? 0.95
21
+ : /[ilI1., :;|'!]/u.test(character)
22
+ ? 0.4
23
+ : 0.65
24
+ return width + em * TITLE_FONT_SIZE
25
+ }, 0)
26
+ }
27
+
28
+ type TitleTrack = Pick<TrackLayout, 'height' | 'column' | 'yAxes'>
29
+
30
+ function titleDistance(track: TitleTrack, axis: WaveformYAxisLayout, yLabel?: string): number {
31
+ const series = axis.seriesList[0]
32
+ const title = series?.name.trim() || yLabel || ''
33
+ const halfTitleHeight = estimateTitleWidth(title) / 2
34
+ const titleTop = track.height / 2 - halfTitleHeight - TEXT_GAP
35
+ const titleBottom = track.height / 2 + halfTitleHeight + TEXT_GAP
36
+ const lowestTick = Math.min(...axis.tickValues)
37
+ const tickWidths = axis.tickValues.flatMap((value) => {
38
+ // D3 places ticks at scale(value) + 0.5. The lowest label is bottom-aligned;
39
+ // all other labels are vertically centered using dy="0.32em".
40
+ const y = axis.scale(value) + 0.5
41
+ const top = y - (value === lowestTick ? TICK_FONT_SIZE : TICK_FONT_SIZE / 2)
42
+ const bottom = value === lowestTick ? y : y + TICK_FONT_SIZE / 2
43
+ if (bottom < titleTop || top > titleBottom) return []
44
+ return [
45
+ formatYAxisTickLabel(
46
+ value,
47
+ axis.scale.domain() as [number, number],
48
+ axis.tickValues,
49
+ series?.unit,
50
+ ).length * Y_AXIS_CHARACTER_WIDTH,
51
+ ]
52
+ })
53
+ // The 20px title band already leaves 3px beside a 14px glyph.
54
+ return (
55
+ Math.max(Y_AXIS_CHARACTER_WIDTH, ...tickWidths) +
56
+ Y_AXIS_TICK_PADDING +
57
+ Y_AXIS_LABEL_BAND_WIDTH / 2
58
+ )
59
+ }
60
+
61
+ export function alignLeftYAxisTitles(layouts: TitleTrack[], yLabel?: string): void {
62
+ const distances = new Map<string, number>()
63
+ for (const track of layouts) {
64
+ let leftIndex = 0
65
+ for (const axis of track.yAxes) {
66
+ if (axis.side !== 'left') continue
67
+ const key = `${track.column}:${leftIndex++}`
68
+ distances.set(key, Math.max(distances.get(key) ?? 0, titleDistance(track, axis, yLabel)))
69
+ }
70
+ }
71
+ for (const track of layouts) {
72
+ let leftIndex = 0
73
+ for (const axis of track.yAxes) {
74
+ if (axis.side !== 'left') continue
75
+ axis.labelX = axis.x - distances.get(`${track.column}:${leftIndex++}`)!
76
+ }
77
+ }
78
+ }
@@ -0,0 +1,64 @@
1
+ import { scaleLinear } from 'd3'
2
+
3
+ export interface IntegerZoomOptions {
4
+ integerZoom?: boolean
5
+ timeUnit?: 's' | 'ms'
6
+ }
7
+
8
+ export function integerZoomTicks(
9
+ domain: [number, number],
10
+ count: number,
11
+ options: IntegerZoomOptions,
12
+ ) {
13
+ if (!options.integerZoom) return scaleLinear().domain(domain).ticks(count)
14
+ const factor = options.timeUnit === 's' ? 1 : 1000
15
+ return scaleLinear()
16
+ .domain(domain.map((value) => value * factor))
17
+ .ticks(count)
18
+ .filter(Number.isInteger)
19
+ .map((value) => value / factor)
20
+ }
21
+
22
+ export function formatIntegerZoomLabel(value: number): string {
23
+ return String(Math.round(value))
24
+ }
25
+
26
+ function stableInteger(value: number): number {
27
+ const rounded = Math.round(value)
28
+ return Math.abs(value - rounded) <= Number.EPSILON * Math.max(1, Math.abs(value)) * 16
29
+ ? rounded
30
+ : value
31
+ }
32
+
33
+ /** Align outwards in display units; all returned coordinates remain in seconds. */
34
+ export function alignIntegerZoomDomain(
35
+ domain: [number, number],
36
+ options: IntegerZoomOptions,
37
+ ): [number, number] {
38
+ if (!options.integerZoom) return domain
39
+ const factor = options.timeUnit === 's' ? 1 : 1000
40
+ const start = stableInteger(Math.min(domain[0], domain[1]) * factor)
41
+ const end = stableInteger(Math.max(domain[0], domain[1]) * factor)
42
+ if (!Number.isSafeInteger(Math.floor(start)) || !Number.isSafeInteger(Math.ceil(end)))
43
+ return domain
44
+ const left = Math.floor(start)
45
+ return [left / factor, Math.max(left + 1, Math.ceil(end)) / factor]
46
+ }
47
+
48
+ /** Recover aligned endpoints after a D3 transform, tolerating floating-point round-off. */
49
+ export function normalizeIntegerZoomDomain(
50
+ domain: [number, number],
51
+ options: IntegerZoomOptions,
52
+ ): [number, number] {
53
+ return alignIntegerZoomDomain(domain, options)
54
+ }
55
+
56
+ /** Translation snaps to the nearest unit so panning preserves the visible span. */
57
+ export function alignIntegerPanDomain(
58
+ domain: [number, number],
59
+ options: IntegerZoomOptions,
60
+ ): [number, number] {
61
+ if (!options.integerZoom) return domain
62
+ const factor = options.timeUnit === 's' ? 1 : 1000
63
+ return [Math.round(domain[0] * factor) / factor, Math.round(domain[1] * factor) / factor]
64
+ }
@@ -8,7 +8,8 @@ import type { WaveformChartEmit } from '../core/waveformChartTypes'
8
8
  import type { AnnotationSeriesCandidate } from '../annotation'
9
9
  import { tryReleasePointerCapture } from './pointerCapture'
10
10
  import { transitionViewportInteraction } from './viewportInteractionState'
11
- import { createViewportDomainSetter } from './viewportDomain'
11
+ import { clampViewportDomain as clampDomain, createViewportDomainSetter } from './viewportDomain'
12
+ import { alignIntegerPanDomain } from './integerZoom'
12
13
  import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
13
14
  import { emitBoxZoomIntent } from './zoomEventPayload'
14
15
  interface ViewportContext {
@@ -92,14 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
92
93
  height: Math.abs(active.currentY - active.startY),
93
94
  }
94
95
  })
95
- const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
96
- const span = domain[1] - domain[0]
97
- const boundarySpan = boundary[1] - boundary[0]
98
- if (span >= boundarySpan) return [...boundary]
99
- if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
100
- if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
101
- return domain
102
- }
103
96
  const currentYDomains = (): Record<string, [number, number]> =>
104
97
  Object.fromEntries(
105
98
  trackLayouts.value
@@ -157,9 +150,12 @@ export function useWaveformViewport(context: ViewportContext) {
157
150
  const sourceXDomain = active.independent
158
151
  ? resolveInitialTrackDomain(track)
159
152
  : initialXDomain.value
160
- const nextX = clampDomain(
161
- [active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
162
- sourceXDomain,
153
+ const nextX = alignIntegerPanDomain(
154
+ clampDomain(
155
+ [active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
156
+ sourceXDomain,
157
+ ),
158
+ props,
163
159
  )
164
160
  if (active.independent) {
165
161
  const nextTransforms = [...independentTransforms.value]
@@ -15,6 +15,7 @@ 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 { normalizeIntegerZoomDomain } from './integerZoom'
18
19
  import {
19
20
  constrainZoomDomain,
20
21
  resolveMinimumZoomSpan,
@@ -90,7 +91,8 @@ export function useWaveformZoom(context: ZoomContext) {
90
91
  const domain = event.transform
91
92
  .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
92
93
  .domain()
93
- emit('zoom-intent', { start: domain[0], end: domain[1], gesture: 'wheel' })
94
+ const [start, end] = normalizeIntegerZoomDomain(domain as [number, number], props)
95
+ emit('zoom-intent', { start, end, gesture: 'wheel' })
94
96
  scheduleZoomCommit()
95
97
  }
96
98
  const handleIndependentZoom = (
@@ -108,8 +110,8 @@ export function useWaveformZoom(context: ZoomContext) {
108
110
  .rescaleX(scaleLinear(resolveInitialTrackDomain(track), [0, track.width]))
109
111
  .domain()
110
112
  emit('zoom-intent', {
111
- start: domain[0],
112
- end: domain[1],
113
+ start: normalizeIntegerZoomDomain(domain as [number, number], props)[0],
114
+ end: normalizeIntegerZoomDomain(domain as [number, number], props)[1],
113
115
  trackIndex,
114
116
  seriesIds: track.seriesList.map((series) => series.id),
115
117
  gesture: 'wheel',
@@ -131,7 +133,7 @@ export function useWaveformZoom(context: ZoomContext) {
131
133
  const domain = transform
132
134
  .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
133
135
  .domain()
134
- emit('zoom-change', [domain[0], domain[1]])
136
+ emit('zoom-change', normalizeIntegerZoomDomain([domain[0], domain[1]], props))
135
137
  }
136
138
  if (!pendingIndependentZoomTransforms.size) return
137
139
  const nextTransforms = [...independentTransforms.value]
@@ -21,6 +21,18 @@ interface ViewportDomainContext {
21
21
  configureZoom: () => void
22
22
  }
23
23
 
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
+ }
35
+
24
36
  export function createViewportDomainSetter(context: ViewportDomainContext) {
25
37
  return (domain: [number, number], trackIndex?: number) => {
26
38
  if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
@@ -3,6 +3,7 @@ import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
3
3
  import type { WaveformPoint } from '../../types'
4
4
  import { pointSourceFromPoints, type WaveformPointSource } from '../../core/waveformPointSource'
5
5
  import { ZOOM_CONSTRAINTS } from '../core/constants'
6
+ import { alignIntegerZoomDomain, type IntegerZoomOptions } from './integerZoom'
6
7
 
7
8
  interface PointSeriesSource {
8
9
  points: WaveformPoint[]
@@ -11,7 +12,7 @@ interface PointSeriesSource {
11
12
 
12
13
  export type ZoomSeriesGroup = readonly PointSeriesSource[]
13
14
 
14
- interface ZoomConstraintOptions {
15
+ interface ZoomConstraintOptions extends IntegerZoomOptions {
15
16
  minZoomSpan?: number
16
17
  minVisiblePoints?: number
17
18
  maxZoomScale?: number | null
@@ -72,7 +73,7 @@ export function resolveMinimumZoomSpan(
72
73
  groups: readonly ZoomSeriesGroup[],
73
74
  options: ZoomConstraintOptions,
74
75
  ): number {
75
- const normalized = normalizedBoundary(boundary)
76
+ const normalized = alignIntegerZoomDomain(normalizedBoundary(boundary), options)
76
77
  const boundarySpan = normalized[1] - normalized[0]
77
78
  if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
78
79
  const configuredSpan =
@@ -88,7 +89,8 @@ export function resolveMinimumZoomSpan(
88
89
  : options.maxZoomScale !== null && configuredSpan === 0 && required === 0
89
90
  ? boundarySpan / ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
90
91
  : 0
91
- return Math.max(configuredSpan, pointSpan, scaleSpan)
92
+ const integerSpan = options.integerZoom ? (options.timeUnit === 's' ? 1 : 0.001) : 0
93
+ return Math.max(configuredSpan, pointSpan, scaleSpan, integerSpan)
92
94
  }
93
95
 
94
96
  function expandToMinimumPoints(
@@ -139,7 +141,7 @@ export function constrainZoomDomain(
139
141
  groups: readonly ZoomSeriesGroup[],
140
142
  options: ZoomConstraintOptions,
141
143
  ): [number, number] {
142
- const normalized = normalizedBoundary(boundary)
144
+ const normalized = alignIntegerZoomDomain(normalizedBoundary(boundary), options)
143
145
  const boundarySpan = normalized[1] - normalized[0]
144
146
  if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalized
145
147
  if (Math.abs(domain[1] - domain[0]) >= boundarySpan * (1 - 1e-12)) return normalized
@@ -157,7 +159,7 @@ export function constrainZoomDomain(
157
159
  const span = Math.max(minimumSpan, Math.min(boundarySpan, expanded[1] - expanded[0]))
158
160
  const center = (expanded[0] + expanded[1]) / 2
159
161
  const start = Math.max(normalized[0], Math.min(center - span / 2, normalized[1] - span))
160
- return [start, start + span]
162
+ return alignIntegerZoomDomain([start, start + span], options)
161
163
  }
162
164
 
163
165
  export function transformForDomain(
@@ -6,6 +6,7 @@ import type { WaveformAxesOptions, WaveformFrameStyle, WaveformZeroLineOptions }
6
6
  import type { TrackLayout } from '../core/types'
7
7
  import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types'
8
8
  import WaveformSeriesLayer from './WaveformSeriesLayer.vue'
9
+ import WaveformZeroLines from './WaveformZeroLines.vue'
9
10
  import WaveformTrackAxes from './WaveformTrackAxes.vue'
10
11
  import WaveformTrackBackdrop from './WaveformTrackBackdrop.vue'
11
12
 
@@ -23,9 +24,7 @@ interface Props {
23
24
  timeUnit: 's' | 'ms'
24
25
  yLabel?: string
25
26
  cleanView?: boolean
26
- zeroLine?: Required<Pick<WaveformZeroLineOptions, 'color' | 'width' | 'dash'>> & {
27
- visible: boolean
28
- }
27
+ zeroLine?: Required<WaveformZeroLineOptions>
29
28
  }
30
29
 
31
30
  interface Emits {
@@ -47,6 +46,8 @@ const props = withDefaults(defineProps<Props>(), {
47
46
  color: ZERO_LINE_DEFAULTS.COLOR,
48
47
  width: ZERO_LINE_DEFAULTS.WIDTH,
49
48
  dash: ZERO_LINE_DEFAULTS.DASH,
49
+ opacity: ZERO_LINE_DEFAULTS.OPACITY,
50
+ boundaryThreshold: ZERO_LINE_DEFAULTS.BOUNDARY_THRESHOLD,
50
51
  }),
51
52
  })
52
53
  const emit = defineEmits<Emits>()
@@ -89,7 +90,6 @@ const resolvedFrameStyle = computed(() => {
89
90
  :clean-view="cleanView"
90
91
  :frame-number="frameNumber"
91
92
  :frame-style="resolvedFrameStyle"
92
- :zero-line="zeroLine"
93
93
  />
94
94
  <WaveformTrackAxes
95
95
  :track="track"
@@ -120,6 +120,13 @@ const resolvedFrameStyle = computed(() => {
120
120
  />
121
121
 
122
122
  <WaveformSeriesLayer :track="track" :clip-path-id="clipPathId" />
123
+ <WaveformZeroLines
124
+ :track="track"
125
+ :clip-path-id="clipPathId"
126
+ :inner-width="innerWidth"
127
+ :clean-view="cleanView"
128
+ :zero-line="zeroLine"
129
+ />
123
130
 
124
131
  <rect
125
132
  v-if="!track.isEmpty && track.hasVisibleSeries && displayMode === 'independent'"
@@ -83,7 +83,7 @@ function renderAxes() {
83
83
  props.track.xScale.domain() as [number, number],
84
84
  props.timeUnit,
85
85
  'tick',
86
- props.axes?.x?.labelFormatter,
86
+ props.axes?.x?.labelFormatter ?? props.track.xAxisLabelFormatter,
87
87
  ),
88
88
  )
89
89
  .tickSize(-4)
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { WaveformZeroLineOptions } from '../../types'
3
- import type { TrackLayout, WaveformYAxisLayout } from '../core/types'
2
+ import type { TrackLayout } from '../core/types'
4
3
 
5
4
  interface Props {
6
5
  track: TrackLayout
@@ -11,18 +10,9 @@ interface Props {
11
10
  frameStyle: {
12
11
  backgroundColor: string
13
12
  }
14
- zeroLine: Required<Pick<WaveformZeroLineOptions, 'color' | 'width' | 'dash'>> & {
15
- visible: boolean
16
- }
17
13
  }
18
14
 
19
- const props = defineProps<Props>()
20
-
21
- function zeroLineY(axis: WaveformYAxisLayout): number | null {
22
- const [minimum, maximum] = axis.scale.domain()
23
- if (!props.zeroLine.visible || minimum > 0 || maximum < 0) return null
24
- return axis.scale(0)
25
- }
15
+ defineProps<Props>()
26
16
  </script>
27
17
 
28
18
  <template>
@@ -98,28 +88,6 @@ function zeroLineY(axis: WaveformYAxisLayout): number | null {
98
88
  </g>
99
89
  </g>
100
90
 
101
- <g
102
- v-if="!track.isEmpty && track.hasVisibleSeries && zeroLine.visible && !cleanView"
103
- class="waveform-track__zero-lines waveform-chart__zero-lines"
104
- :clip-path="`url(#${clipPathId}-${track.index})`"
105
- aria-hidden="true"
106
- >
107
- <template v-for="axis in track.yAxes" :key="`zero-line-${track.index}-${axis.index}`">
108
- <line
109
- v-if="zeroLineY(axis) !== null"
110
- class="waveform-track__zero-line waveform-chart__zero-line"
111
- :data-y-axis-index="axis.index"
112
- x1="0"
113
- :x2="track.width ?? innerWidth"
114
- :y1="zeroLineY(axis) ?? 0"
115
- :y2="zeroLineY(axis) ?? 0"
116
- :stroke="zeroLine.color"
117
- :stroke-width="zeroLine.width"
118
- :stroke-dasharray="zeroLine.dash || undefined"
119
- />
120
- </template>
121
- </g>
122
-
123
91
  <text
124
92
  v-if="!track.isEmpty && track.hasVisibleSeries && frameNumber !== undefined && !cleanView"
125
93
  class="waveform-track__watermark waveform-chart__watermark"
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ import type { WaveformZeroLineOptions } from '../../types'
3
+ import type { TrackLayout, WaveformYAxisLayout } from '../core/types'
4
+
5
+ const props = defineProps<{
6
+ track: TrackLayout
7
+ clipPathId: string
8
+ innerWidth: number
9
+ cleanView: boolean
10
+ zeroLine: Required<WaveformZeroLineOptions>
11
+ }>()
12
+
13
+ function zeroLineY(axis: WaveformYAxisLayout): number | null {
14
+ const [minimum, maximum] = axis.scale.domain()
15
+ const tickInterval = Math.abs(axis.tickValues[1] - axis.tickValues[0])
16
+ const margin = tickInterval * props.zeroLine.boundaryThreshold
17
+ if (
18
+ !props.zeroLine.visible ||
19
+ minimum >= 0 ||
20
+ maximum <= 0 ||
21
+ -minimum < margin ||
22
+ maximum < margin
23
+ )
24
+ return null
25
+ return axis.scale(0)
26
+ }
27
+ </script>
28
+
29
+ <template>
30
+ <g
31
+ v-if="!track.isEmpty && track.hasVisibleSeries && zeroLine.visible && !cleanView"
32
+ class="waveform-track__zero-lines waveform-chart__zero-lines"
33
+ :clip-path="`url(#${clipPathId}-${track.index})`"
34
+ aria-hidden="true"
35
+ pointer-events="none"
36
+ >
37
+ <template v-for="axis in track.yAxes" :key="`zero-line-${track.index}-${axis.index}`">
38
+ <line
39
+ v-if="zeroLineY(axis) !== null"
40
+ class="waveform-track__zero-line waveform-chart__zero-line"
41
+ :data-y-axis-index="axis.index"
42
+ x1="0"
43
+ :x2="track.width ?? innerWidth"
44
+ :y1="zeroLineY(axis) ?? 0"
45
+ :y2="zeroLineY(axis) ?? 0"
46
+ :stroke="zeroLine.color"
47
+ :stroke-opacity="zeroLine.opacity"
48
+ :stroke-width="zeroLine.width"
49
+ :stroke-dasharray="zeroLine.dash || undefined"
50
+ />
51
+ </template>
52
+ </g>
53
+ </template>
@@ -23,6 +23,7 @@ defineExpose({ resetViewport })
23
23
  v-model:hidden-series-ids="model.hiddenSeriesIds"
24
24
  :data="model.data"
25
25
  :min-visible-points="2"
26
+ :integer-zoom="model.integerZoom"
26
27
  :initial-x-domain="model.initialXDomain"
27
28
  :x-domain-strategy="{ type: 'integer-ms', includeExplicit: true }"
28
29
  :display-mode="model.displayMode"
@@ -66,6 +66,18 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
66
66
  aria-label="零值参考线线宽"
67
67
  />
68
68
  </label>
69
+ <label class="frame-style-control">
70
+ <span>透明度</span>
71
+ <InputNumber
72
+ v-model:value="model.zeroLineOpacity"
73
+ :min="0"
74
+ :max="1"
75
+ :step="0.05"
76
+ :precision="2"
77
+ size="small"
78
+ aria-label="零值参考线透明度"
79
+ />
80
+ </label>
69
81
  <label class="frame-style-control">
70
82
  <span>线型</span>
71
83
  <Select
@@ -38,6 +38,10 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
38
38
  <h2>视图</h2>
39
39
  <Button block aria-label="重置波形视图" @click="model.resetWaveformViewport"> 重置视图 </Button>
40
40
  <div class="auxiliary-style-controls" style="margin-top: 10px">
41
+ <label class="frame-style-control frame-style-control--switch">
42
+ <span>整数缩放</span>
43
+ <Switch v-model:checked="model.integerZoom" size="small" aria-label="整数缩放" />
44
+ </label>
41
45
  <label class="frame-style-control frame-style-control--switch">
42
46
  <span>数值 Tooltip</span>
43
47
  <Switch v-model:checked="model.showTooltip" size="small" aria-label="显示数值 Tooltip" />
package/src/demo/types.ts CHANGED
@@ -27,6 +27,7 @@ export interface DemoControlPanelModel {
27
27
  displayMode: WaveformDisplayMode
28
28
  overlayMode: WaveformOverlayMode
29
29
  showTooltip: boolean
30
+ integerZoom: boolean
30
31
  plotMarginTop: number
31
32
  plotMarginBottom: number
32
33
  cleanView: boolean
@@ -38,6 +39,7 @@ export interface DemoControlPanelModel {
38
39
  zeroLineVisible: boolean
39
40
  zeroLineColor: string
40
41
  zeroLineWidth: number
42
+ zeroLineOpacity: number
41
43
  zeroLineDash: string
42
44
  zeroLineDashOptions: Array<SelectOption<string>>
43
45
  rowCount: number
@@ -101,6 +103,7 @@ export interface DemoChartModel {
101
103
  cleanView: boolean
102
104
  presentationMode: boolean
103
105
  showTooltip: boolean
106
+ integerZoom: boolean
104
107
  plotMargin: WaveformPlotMargin
105
108
  zeroLine: WaveformZeroLineOptions
106
109
  frameWatermarkVisible: boolean
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  // Vue 组件
7
7
  export { default as WaveformChart } from './components/WaveformChart.vue'
8
+ export type { WaveformChartProps } from './components/core/waveformChartTypes'
8
9
 
9
10
  // 类型定义
10
11
  export type {
package/src/styles.css CHANGED
@@ -329,7 +329,7 @@ body {
329
329
 
330
330
  .mobile-control-toggle {
331
331
  position: fixed;
332
- top: 10px;
332
+ top: 58px;
333
333
  left: 10px;
334
334
  z-index: 20;
335
335
  display: inline-flex;
@@ -339,7 +339,7 @@ body {
339
339
  .control-backdrop {
340
340
  position: fixed;
341
341
  inset: 0 0 0 min(300px, calc(100vw - 48px));
342
- z-index: 24;
342
+ z-index: 44;
343
343
  display: block;
344
344
  width: auto;
345
345
  height: 100%;
@@ -351,7 +351,7 @@ body {
351
351
  .control-panel {
352
352
  position: fixed;
353
353
  inset: 0 auto 0 0;
354
- z-index: 30;
354
+ z-index: 50;
355
355
  width: min(300px, calc(100vw - 48px));
356
356
  visibility: hidden;
357
357
  border-radius: 0 6px 6px 0;
@@ -266,4 +266,8 @@ export interface WaveformZeroLineOptions {
266
266
  width?: number
267
267
  /** SVG stroke-dasharray. Defaults to '6 4'; use '' for a solid line. */
268
268
  dash?: string
269
+ /** Stroke opacity from 0 to 1. Defaults to 0.5; multiplies the color's alpha. */
270
+ opacity?: number
271
+ /** Hide within this fraction of a Y-axis tick interval from either boundary. Defaults to 0.02. */
272
+ boundaryThreshold?: number
269
273
  }