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.
- package/README.md +23 -3
- package/dist/index.cjs +1 -1
- package/dist/index.js +705 -617
- package/dist/types/components/WaveformChart.vue.d.ts +3 -2
- package/dist/types/components/core/constants.d.ts +2 -0
- package/dist/types/components/core/trackLayoutBuilder.d.ts +1 -0
- package/dist/types/components/core/types.d.ts +2 -1
- package/dist/types/components/core/useWaveformChartController.d.ts +3 -0
- package/dist/types/components/core/useWaveformPresentation.d.ts +2 -0
- package/dist/types/components/core/waveformChartTypes.d.ts +3 -1
- package/dist/types/components/core/yAxisTitleLayout.d.ts +4 -0
- package/dist/types/components/interaction/integerZoom.d.ts +12 -0
- package/dist/types/components/interaction/viewportDomain.d.ts +1 -0
- package/dist/types/components/interaction/zoomConstraints.d.ts +2 -1
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -6
- package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +0 -4
- package/dist/types/components/rendering/WaveformZeroLines.vue.d.ts +12 -0
- package/dist/types/demo/types.d.ts +3 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/types/chart.d.ts +4 -0
- package/package.json +1 -1
- package/src/App.vue +6 -0
- package/src/components/WaveformChart.vue +1 -0
- package/src/components/core/constants.ts +2 -0
- package/src/components/core/trackLayoutBuilder.ts +26 -56
- package/src/components/core/types.ts +2 -0
- package/src/components/core/useWaveformChartLifecycle.ts +1 -1
- package/src/components/core/useWaveformLayout.ts +32 -16
- package/src/components/core/useWaveformPresentation.ts +10 -0
- package/src/components/core/waveformChartTypes.ts +3 -0
- package/src/components/core/yAxisTitleLayout.ts +78 -0
- package/src/components/interaction/integerZoom.ts +64 -0
- package/src/components/interaction/useWaveformViewport.ts +8 -12
- package/src/components/interaction/useWaveformZoom.ts +6 -4
- package/src/components/interaction/viewportDomain.ts +12 -0
- package/src/components/interaction/zoomConstraints.ts +7 -5
- package/src/components/rendering/WaveformTrack.vue +11 -4
- package/src/components/rendering/WaveformTrackAxes.vue +1 -1
- package/src/components/rendering/WaveformTrackBackdrop.vue +2 -34
- package/src/components/rendering/WaveformZeroLines.vue +53 -0
- package/src/demo/DemoChartHost.vue +1 -0
- package/src/demo/DemoGridFrameControls.vue +12 -0
- package/src/demo/DemoViewControls.vue +4 -0
- package/src/demo/types.ts +3 -0
- package/src/index.ts +1 -0
- package/src/styles.css +3 -3
- 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 =
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
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'"
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type {
|
|
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
|
-
|
|
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
package/src/styles.css
CHANGED
|
@@ -329,7 +329,7 @@ body {
|
|
|
329
329
|
|
|
330
330
|
.mobile-control-toggle {
|
|
331
331
|
position: fixed;
|
|
332
|
-
top:
|
|
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:
|
|
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:
|
|
354
|
+
z-index: 50;
|
|
355
355
|
width: min(300px, calc(100vw - 48px));
|
|
356
356
|
visibility: hidden;
|
|
357
357
|
border-radius: 0 6px 6px 0;
|
package/src/types/chart.ts
CHANGED
|
@@ -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
|
}
|