waveform-analysis 0.1.69 → 0.1.70
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 +82 -49
- package/dist/index.cjs +1 -1
- package/dist/index.js +1256 -1202
- package/dist/style.css +1 -1
- package/dist/types/components/core/layout.d.ts +3 -1
- package/dist/types/components/core/usePlotLabelLayout.d.ts +6 -0
- package/dist/types/components/core/useWaveformChartController.d.ts +4 -2
- package/dist/types/components/core/useWaveformLayout.d.ts +1 -0
- package/dist/types/components/core/yAxisTitleLayout.d.ts +6 -1
- package/dist/types/components/rendering/WaveformLegend.vue.d.ts +1 -1
- package/dist/types/components/rendering/WaveformTrack.vue.d.ts +3 -0
- package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +2 -0
- package/dist/types/components/rendering/channelLabel.d.ts +2 -0
- package/dist/types/types/chart.d.ts +3 -1
- package/package.json +1 -1
- package/src/components/WaveformChartView.vue +23 -5
- package/src/components/core/layout.ts +32 -6
- package/src/components/core/trackLayoutBuilder.ts +11 -9
- package/src/components/core/usePlotLabelLayout.ts +23 -0
- package/src/components/core/useWaveformChartController.ts +9 -0
- package/src/components/core/useWaveformLayout.ts +19 -6
- package/src/components/core/useWaveformPresentation.ts +7 -4
- package/src/components/core/useWaveformYAxisLayout.ts +15 -11
- package/src/components/core/yAxisLayoutMetrics.ts +1 -1
- package/src/components/core/yAxisTitleLayout.ts +38 -17
- package/src/components/interaction/WaveformTooltip.vue +2 -2
- package/src/components/interaction/useWaveformZoom.ts +24 -7
- package/src/components/rendering/WaveformLegend.vue +5 -3
- package/src/components/rendering/WaveformTrack.css +1 -1
- package/src/components/rendering/WaveformTrack.vue +15 -2
- package/src/components/rendering/WaveformTrackAxes.vue +11 -10
- package/src/components/rendering/WaveformTrackBackdrop.vue +1 -1
- package/src/components/rendering/channelLabel.ts +5 -0
- package/src/types/chart.ts +5 -1
- package/src/utils/formatters.ts +4 -3
|
@@ -77,7 +77,8 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
77
77
|
const visibleSeries = series.filter((item) => !hiddenSeriesIdSet.value.has(item.id))
|
|
78
78
|
const xDomainValues: number[] = []
|
|
79
79
|
const yDomainValues: number[] = []
|
|
80
|
-
visibleSeries.
|
|
80
|
+
const domainSeries = visibleSeries.length ? visibleSeries : series
|
|
81
|
+
domainSeries.forEach((item) => {
|
|
81
82
|
xDomainValues.push(item.xDomain[0], item.xDomain[1])
|
|
82
83
|
yDomainValues.push(item.yDomain[0], item.yDomain[1])
|
|
83
84
|
})
|
|
@@ -92,8 +93,9 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
92
93
|
})
|
|
93
94
|
const showLegendUnits = computed(
|
|
94
95
|
() =>
|
|
95
|
-
props.unitDisplayMode === '
|
|
96
|
-
|
|
96
|
+
props.unitDisplayMode === 'channel-label-or-legend' ||
|
|
97
|
+
(props.unitDisplayMode === 'legend-single-series' &&
|
|
98
|
+
!chartTracks.value.some((track) => track.series.length > 1)),
|
|
97
99
|
)
|
|
98
100
|
const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
|
|
99
101
|
const pageableTracks = computed(() =>
|
|
@@ -103,6 +105,11 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
103
105
|
const pagedTracks = computed(() =>
|
|
104
106
|
paginateSeries(pageableTracks.value, currentPage.value, gridOptions.value),
|
|
105
107
|
)
|
|
108
|
+
const hideYAxisTitles = computed(
|
|
109
|
+
() =>
|
|
110
|
+
props.unitDisplayMode === 'channel-label-or-legend' &&
|
|
111
|
+
pagedTracks.value.some((track) => track.series.length > 1),
|
|
112
|
+
)
|
|
106
113
|
const layoutTracks = computed(() => {
|
|
107
114
|
const tracksWithSeries = pagedTracks.value.filter((track) => track.series.length > 0)
|
|
108
115
|
return gridOptions.value.fillIncompleteLastRow && tracksWithSeries.length > 0
|
|
@@ -143,8 +150,11 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
143
150
|
const isZoomMode = computed(() => activeInteractionMode.value !== 'annotation')
|
|
144
151
|
const sharedXDomain = computed(() => {
|
|
145
152
|
const values: number[] = []
|
|
146
|
-
chartTracks.value.
|
|
147
|
-
|
|
153
|
+
const tracks = chartTracks.value.some((track) => track.visibleSeries.length)
|
|
154
|
+
? chartTracks.value.filter((track) => track.visibleSeries.length)
|
|
155
|
+
: chartTracks.value.filter((track) => track.series.length)
|
|
156
|
+
tracks.forEach((track) => {
|
|
157
|
+
values.push(track.xDomain[0], track.xDomain[1])
|
|
148
158
|
})
|
|
149
159
|
return paddedDomain(values)
|
|
150
160
|
})
|
|
@@ -229,7 +239,9 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
229
239
|
grid: gridOptions.value,
|
|
230
240
|
useNonEmptyBottomTracks: props.layoutPreset === 'edge-compact',
|
|
231
241
|
compactYAxisLayout: props.layoutPreset === 'edge-compact',
|
|
232
|
-
showAxisUnits:
|
|
242
|
+
showAxisUnits:
|
|
243
|
+
props.unitDisplayMode !== 'channel-label-or-legend' &&
|
|
244
|
+
(props.layoutPreset !== 'edge-compact' || props.unitDisplayMode === 'axis'),
|
|
233
245
|
displayMode: props.displayMode,
|
|
234
246
|
overlayMode: props.overlayMode,
|
|
235
247
|
independentTransforms: independentTransforms.value,
|
|
@@ -302,6 +314,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
|
|
302
314
|
chartSeries,
|
|
303
315
|
chartTracks,
|
|
304
316
|
showLegendUnits,
|
|
317
|
+
hideYAxisTitles,
|
|
305
318
|
pageableTracks,
|
|
306
319
|
renderingOptions,
|
|
307
320
|
gridOptions,
|
|
@@ -73,9 +73,7 @@ export function useWaveformPresentation(context: PresentationContext) {
|
|
|
73
73
|
: ZERO_LINE_DEFAULTS.BOUNDARY_THRESHOLD,
|
|
74
74
|
}
|
|
75
75
|
})
|
|
76
|
-
const legendBackgroundColor = computed(
|
|
77
|
-
() => props.legend.backgroundColor || 'rgba(255, 255, 255, 0.7)',
|
|
78
|
-
)
|
|
76
|
+
const legendBackgroundColor = computed(() => props.legend.backgroundColor || 'transparent')
|
|
79
77
|
const legendInteractive = computed(() => props.legend.interactive === true)
|
|
80
78
|
const hiddenSeriesIdSet = computed(() =>
|
|
81
79
|
props.hiddenSeriesIds === undefined
|
|
@@ -118,7 +116,12 @@ export function useWaveformPresentation(context: PresentationContext) {
|
|
|
118
116
|
color: props.title?.textStyle?.color ?? '#1f2937',
|
|
119
117
|
fontSize: `${titleFontSize.value}px`,
|
|
120
118
|
fontFamily: props.title?.textStyle?.fontFamily || '"Microsoft YaHei", "微软雅黑", sans-serif',
|
|
121
|
-
fontWeight:
|
|
119
|
+
fontWeight:
|
|
120
|
+
isEdgeCompact.value &&
|
|
121
|
+
titleFontSize.value === 18 &&
|
|
122
|
+
props.title?.textStyle?.fontWeight === 700
|
|
123
|
+
? 600
|
|
124
|
+
: (props.title?.textStyle?.fontWeight ?? 400),
|
|
122
125
|
fontStyle: props.title?.textStyle?.fontStyle ?? 'normal',
|
|
123
126
|
textDecoration: props.title?.textStyle?.textDecoration ?? 'none',
|
|
124
127
|
letterSpacing: props.title?.textStyle?.letterSpacing ?? 'normal',
|
|
@@ -30,7 +30,11 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
|
|
|
30
30
|
yAxisTickCount,
|
|
31
31
|
} = context
|
|
32
32
|
const isEdgeCompact = computed(() => props.layoutPreset === 'edge-compact')
|
|
33
|
-
const showUnits = computed(
|
|
33
|
+
const showUnits = computed(
|
|
34
|
+
() =>
|
|
35
|
+
props.unitDisplayMode !== 'channel-label-or-legend' &&
|
|
36
|
+
(!isEdgeCompact.value || props.unitDisplayMode === 'axis'),
|
|
37
|
+
)
|
|
34
38
|
const yAxisMetrics = computed(() =>
|
|
35
39
|
resolveYAxisLayoutMetrics(
|
|
36
40
|
chartTracks.value,
|
|
@@ -47,28 +51,28 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
|
|
|
47
51
|
),
|
|
48
52
|
)
|
|
49
53
|
const hasYAxisLabels = computed(() =>
|
|
50
|
-
chartTracks.value.some(
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
),
|
|
54
|
+
chartTracks.value.some((track) => {
|
|
55
|
+
const series = track.visibleSeries.length ? track.visibleSeries : track.series
|
|
56
|
+
return series.length === 1 && Boolean(series[0]?.name.trim() || props.yLabel)
|
|
57
|
+
}),
|
|
55
58
|
)
|
|
56
59
|
const hasVisibleWaveformData = computed(() =>
|
|
57
60
|
chartTracks.value.some((track) => track.visibleSeries.length > 0),
|
|
58
61
|
)
|
|
62
|
+
const hasAxisData = computed(() => chartTracks.value.some((track) => track.series.length > 0))
|
|
59
63
|
const chartLeftMargin = computed(() =>
|
|
60
64
|
Math.max(
|
|
61
65
|
margin.left,
|
|
62
66
|
hasYAxisLabels.value
|
|
63
67
|
? yAxisMetrics.value.fullClearance
|
|
64
|
-
:
|
|
68
|
+
: hasAxisData.value
|
|
65
69
|
? yAxisMetrics.value.tickClearance
|
|
66
70
|
: 0,
|
|
67
71
|
),
|
|
68
72
|
)
|
|
69
73
|
const yAxisSlots = computed(() =>
|
|
70
74
|
buildYAxisSlots(
|
|
71
|
-
layoutTracks.value.filter((track) => track.
|
|
75
|
+
layoutTracks.value.filter((track) => track.series.length > 0),
|
|
72
76
|
props.overlayMode,
|
|
73
77
|
props.yDomain,
|
|
74
78
|
props.yDomains,
|
|
@@ -91,7 +95,7 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
|
|
|
91
95
|
// The same slot places the title and reserves its entire 20px band. The outer
|
|
92
96
|
// padding belongs to the chart edge, rather than the old fixed 48px minimum.
|
|
93
97
|
const compactLeftClearance = computed(() =>
|
|
94
|
-
!
|
|
98
|
+
!hasAxisData.value
|
|
95
99
|
? 0
|
|
96
100
|
: props.overlayMode === 'multi-axis' || hasYAxisLabels.value
|
|
97
101
|
? yAxisSlots.value.clearance.left
|
|
@@ -118,9 +122,9 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
|
|
|
118
122
|
const canReserveLabelClearance = plotWidth >= MINIMUM_PLOT_WIDTH
|
|
119
123
|
return {
|
|
120
124
|
horizontalGap:
|
|
121
|
-
props.overlayMode === 'multi-axis' && hasMultipleColumns &&
|
|
125
|
+
props.overlayMode === 'multi-axis' && hasMultipleColumns && hasAxisData.value
|
|
122
126
|
? Math.max(baseGap, yAxisSlots.value.clearance.left + yAxisSlots.value.clearance.right)
|
|
123
|
-
: hasMultipleColumns &&
|
|
127
|
+
: hasMultipleColumns && hasAxisData.value
|
|
124
128
|
? hasYAxisLabels.value && canReserveLabelClearance
|
|
125
129
|
? fullGap
|
|
126
130
|
: tickGap
|
|
@@ -45,7 +45,7 @@ export function resolveYAxisLayoutMetrics(
|
|
|
45
45
|
measureTextWidth?: (text: string) => number,
|
|
46
46
|
): YAxisLayoutMetrics {
|
|
47
47
|
const axisText = chartTracks
|
|
48
|
-
.filter((track) => track.
|
|
48
|
+
.filter((track) => track.series.length > 0)
|
|
49
49
|
.flatMap((track) =>
|
|
50
50
|
resolveRenderedYAxisSeriesGroups(
|
|
51
51
|
track,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { formatYAxisTickLayoutLabel } from './layout'
|
|
2
2
|
import type { TrackLayout, WaveformYAxisLayout } from './types'
|
|
3
3
|
import {
|
|
4
4
|
Y_AXIS_CHARACTER_WIDTH,
|
|
@@ -6,11 +6,17 @@ import {
|
|
|
6
6
|
Y_AXIS_TICK_PADDING,
|
|
7
7
|
} from './yAxisConstants'
|
|
8
8
|
|
|
9
|
-
// Match the
|
|
10
|
-
const TITLE_FONT_SIZE =
|
|
9
|
+
// Match the 12px channel names and 11px ticks in WaveformTrack.css.
|
|
10
|
+
const TITLE_FONT_SIZE = 12
|
|
11
11
|
const TICK_FONT_SIZE = 11
|
|
12
12
|
const TEXT_GAP = 2
|
|
13
13
|
|
|
14
|
+
interface TitleLayoutOptions {
|
|
15
|
+
showUnits?: boolean
|
|
16
|
+
compact?: boolean
|
|
17
|
+
measureTextWidth?: (text: string) => number
|
|
18
|
+
}
|
|
19
|
+
|
|
14
20
|
function estimateTitleWidth(title: string): number {
|
|
15
21
|
return Array.from(title).reduce((width, character) => {
|
|
16
22
|
// Latin labels average well below one em; full-width characters occupy one em.
|
|
@@ -27,30 +33,37 @@ function estimateTitleWidth(title: string): number {
|
|
|
27
33
|
|
|
28
34
|
type TitleTrack = Pick<TrackLayout, 'height' | 'column' | 'yAxes'>
|
|
29
35
|
|
|
30
|
-
function titleDistance(
|
|
36
|
+
function titleDistance(
|
|
37
|
+
track: TitleTrack,
|
|
38
|
+
axis: WaveformYAxisLayout,
|
|
39
|
+
yLabel: string | undefined,
|
|
40
|
+
options: TitleLayoutOptions,
|
|
41
|
+
): number {
|
|
31
42
|
const series = axis.seriesList[0]
|
|
32
43
|
const title = series?.name.trim() || yLabel || ''
|
|
33
44
|
const halfTitleHeight = estimateTitleWidth(title) / 2
|
|
34
45
|
const titleTop = track.height / 2 - halfTitleHeight - TEXT_GAP
|
|
35
46
|
const titleBottom = track.height / 2 + halfTitleHeight + TEXT_GAP
|
|
36
47
|
const lowestTick = Math.min(...axis.tickValues)
|
|
48
|
+
const highestTick = Math.max(...axis.tickValues)
|
|
37
49
|
const tickWidths = axis.tickValues.flatMap((value) => {
|
|
38
50
|
// D3 places ticks at scale(value) + 0.5. The lowest label is bottom-aligned;
|
|
39
51
|
// all other labels are vertically centered using dy="0.32em".
|
|
40
52
|
const y = axis.scale(value) + 0.5
|
|
41
|
-
const
|
|
42
|
-
const
|
|
53
|
+
const isTopAligned = options.compact && value === highestTick
|
|
54
|
+
const top = y - (value === lowestTick ? TICK_FONT_SIZE : isTopAligned ? 0 : TICK_FONT_SIZE / 2)
|
|
55
|
+
const bottom =
|
|
56
|
+
value === lowestTick ? y : y + (isTopAligned ? TICK_FONT_SIZE : TICK_FONT_SIZE / 2)
|
|
43
57
|
if (bottom < titleTop || top > titleBottom) return []
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
]
|
|
58
|
+
const text = formatYAxisTickLayoutLabel(
|
|
59
|
+
value,
|
|
60
|
+
axis.scale.domain() as [number, number],
|
|
61
|
+
axis.tickValues,
|
|
62
|
+
options.showUnits === false ? undefined : series?.unit,
|
|
63
|
+
)
|
|
64
|
+
return [options.measureTextWidth?.(text) ?? text.length * Y_AXIS_CHARACTER_WIDTH]
|
|
52
65
|
})
|
|
53
|
-
// The 20px title band
|
|
66
|
+
// The 20px title band leaves 4px beside a 12px glyph.
|
|
54
67
|
return (
|
|
55
68
|
Math.max(Y_AXIS_CHARACTER_WIDTH, ...tickWidths) +
|
|
56
69
|
Y_AXIS_TICK_PADDING +
|
|
@@ -58,14 +71,22 @@ function titleDistance(track: TitleTrack, axis: WaveformYAxisLayout, yLabel?: st
|
|
|
58
71
|
)
|
|
59
72
|
}
|
|
60
73
|
|
|
61
|
-
export function alignLeftYAxisTitles(
|
|
74
|
+
export function alignLeftYAxisTitles(
|
|
75
|
+
layouts: TitleTrack[],
|
|
76
|
+
yLabel?: string,
|
|
77
|
+
options: TitleLayoutOptions = {},
|
|
78
|
+
): void {
|
|
62
79
|
const distances = new Map<string, number>()
|
|
63
80
|
for (const track of layouts) {
|
|
64
81
|
let leftIndex = 0
|
|
65
82
|
for (const axis of track.yAxes) {
|
|
66
83
|
if (axis.side !== 'left') continue
|
|
67
84
|
const key = `${track.column}:${leftIndex++}`
|
|
68
|
-
|
|
85
|
+
const distance = titleDistance(track, axis, yLabel, options)
|
|
86
|
+
// Compact slots already reserve every tick. Only move names inward, so
|
|
87
|
+
// alignment can never push them beyond the space reserved at the chart edge.
|
|
88
|
+
const boundedDistance = options.compact ? Math.min(axis.x - axis.labelX, distance) : distance
|
|
89
|
+
distances.set(key, Math.max(distances.get(key) ?? 0, boundedDistance))
|
|
69
90
|
}
|
|
70
91
|
}
|
|
71
92
|
for (const track of layouts) {
|
|
@@ -133,8 +133,8 @@ const tooltipStyle = computed(() => {
|
|
|
133
133
|
padding: 9px 12px;
|
|
134
134
|
color: #505050;
|
|
135
135
|
font:
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
11px/1.35 'Times New Roman',
|
|
137
|
+
serif;
|
|
138
138
|
pointer-events: none;
|
|
139
139
|
background: #fff;
|
|
140
140
|
border: 1px solid #e3e7eb;
|
|
@@ -15,7 +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 { constrainViewportZoom } from './remoteViewport'
|
|
18
|
+
import { constrainViewportZoom, remotePanBoundary } from './remoteViewport'
|
|
19
19
|
import { normalizeIntegerZoomDomain } from './integerZoom'
|
|
20
20
|
import { resolveMinimumZoomSpan, transformForDomain, type ZoomSeriesGroup } from './zoomConstraints'
|
|
21
21
|
import {
|
|
@@ -254,13 +254,28 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
254
254
|
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
|
255
255
|
return tracks.length > 0 && tracks.every(canZoomTrack)
|
|
256
256
|
}
|
|
257
|
-
const
|
|
257
|
+
const resolveMinimumZoomScale = (domain: [number, number]): number => {
|
|
258
|
+
const boundary = remotePanBoundary(props)
|
|
259
|
+
return boundary
|
|
260
|
+
? Math.min(1, (domain[1] - domain[0]) / (boundary[1] - boundary[0]))
|
|
261
|
+
: ZOOM_CONSTRAINTS.MIN_SCALE
|
|
262
|
+
}
|
|
263
|
+
const canHandleWheelZoom = (event: Event, canZoomIn: boolean, minimumScale: number): boolean => {
|
|
258
264
|
if (event.type !== 'wheel') return false
|
|
259
265
|
const deltaY = (event as WheelEvent).deltaY
|
|
266
|
+
const currentScale = zoomTransform(event.currentTarget as Element).k
|
|
267
|
+
|
|
268
|
+
// 放大操作:需要检查是否还有足够的数据点支持继续放大
|
|
260
269
|
if (deltaY < 0) return canZoomIn
|
|
270
|
+
|
|
271
|
+
// 缩小操作:只要当前缩放级别大于最小比例就允许
|
|
272
|
+
// 即使已达到最小可见点数限制,用户也应该能够缩小视图
|
|
261
273
|
if (deltaY > 0) {
|
|
262
|
-
|
|
274
|
+
// 使用小的容差避免浮点精度问题导致无法缩小到边界
|
|
275
|
+
const scaleTolerance = 1e-6
|
|
276
|
+
return currentScale > minimumScale * (1 + scaleTolerance)
|
|
263
277
|
}
|
|
278
|
+
|
|
264
279
|
return false
|
|
265
280
|
}
|
|
266
281
|
const configureZoom = () => {
|
|
@@ -284,13 +299,14 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
284
299
|
if (!overlay) return
|
|
285
300
|
const dataDomain = resolveInitialTrackDomain(track)
|
|
286
301
|
const groups = [track.seriesList]
|
|
302
|
+
const minimumScale = resolveMinimumZoomScale(dataDomain)
|
|
287
303
|
const behavior = zoom<SVGRectElement, unknown>()
|
|
288
304
|
.filter((event) => {
|
|
289
305
|
const currentTrack =
|
|
290
306
|
trackLayouts.value.find((item) => item.index === track.index) ?? track
|
|
291
|
-
return canHandleWheelZoom(event, canZoomTrack(currentTrack))
|
|
307
|
+
return canHandleWheelZoom(event, canZoomTrack(currentTrack), minimumScale)
|
|
292
308
|
})
|
|
293
|
-
.scaleExtent([
|
|
309
|
+
.scaleExtent([minimumScale, resolveMaximumZoomScale(dataDomain, groups)])
|
|
294
310
|
.constrain((transform) => constrainTransform(transform, dataDomain, track.width, groups))
|
|
295
311
|
.extent([
|
|
296
312
|
[0, 0],
|
|
@@ -320,9 +336,10 @@ export function useWaveformZoom(context: ZoomContext) {
|
|
|
320
336
|
const groups = trackLayouts.value
|
|
321
337
|
.filter((track) => track.hasVisibleSeries)
|
|
322
338
|
.map((track) => track.seriesList)
|
|
339
|
+
const minimumScale = resolveMinimumZoomScale(initialXDomain.value)
|
|
323
340
|
const behavior = zoom<SVGRectElement, unknown>()
|
|
324
|
-
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
|
|
325
|
-
.scaleExtent([
|
|
341
|
+
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks(), minimumScale))
|
|
342
|
+
.scaleExtent([minimumScale, resolveMaximumZoomScale(initialXDomain.value, groups)])
|
|
326
343
|
.constrain((transform) =>
|
|
327
344
|
constrainTransform(transform, initialXDomain.value, innerWidth.value, groups),
|
|
328
345
|
)
|
|
@@ -3,6 +3,7 @@ import { computed } from 'vue'
|
|
|
3
3
|
|
|
4
4
|
import type { WaveformLegendPosition } from '../../types'
|
|
5
5
|
import type { DisplaySeries } from '../core/types'
|
|
6
|
+
import { formatChannelLabel } from './channelLabel'
|
|
6
7
|
import {
|
|
7
8
|
waveformLegendErrorBarPath,
|
|
8
9
|
waveformLegendLinePath,
|
|
@@ -33,8 +34,7 @@ const emit = defineEmits<{
|
|
|
33
34
|
const hiddenSeriesIdSet = computed(() => new Set(props.hiddenSeriesIds))
|
|
34
35
|
|
|
35
36
|
function legendLabel(series: DisplaySeries): string {
|
|
36
|
-
|
|
37
|
-
return props.showUnits && unit && unit !== '--' ? `${series.name} (${unit})` : series.name
|
|
37
|
+
return props.showUnits ? formatChannelLabel(series.name, series.unit, ' ') : series.name
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function isHidden(seriesId: string): boolean {
|
|
@@ -193,7 +193,9 @@ function toggleSeries(seriesId: string) {
|
|
|
193
193
|
padding: 5px 7px;
|
|
194
194
|
overflow: hidden;
|
|
195
195
|
color: #344054;
|
|
196
|
-
font:
|
|
196
|
+
font:
|
|
197
|
+
11px/1.35 'Times New Roman',
|
|
198
|
+
serif;
|
|
197
199
|
border: 1px solid rgb(208 213 221 / 90%);
|
|
198
200
|
border-radius: 4px;
|
|
199
201
|
}
|
|
@@ -24,8 +24,11 @@ interface Props {
|
|
|
24
24
|
timeUnit: 's' | 'ms'
|
|
25
25
|
yLabel?: string
|
|
26
26
|
showAxisUnits?: boolean
|
|
27
|
+
showLabelUnits?: boolean
|
|
28
|
+
hideYAxisTitles?: boolean
|
|
27
29
|
containYAxisEndpoints?: boolean
|
|
28
30
|
containFrameStroke?: boolean
|
|
31
|
+
omitFrameTop?: boolean
|
|
29
32
|
cleanView?: boolean
|
|
30
33
|
zeroLine?: Required<WaveformZeroLineOptions>
|
|
31
34
|
}
|
|
@@ -74,6 +77,12 @@ const resolvedFrameStyle = computed(() => {
|
|
|
74
77
|
const frameInset = computed(() =>
|
|
75
78
|
props.containFrameStroke ? resolvedFrameStyle.value.borderWidth / 2 : 0,
|
|
76
79
|
)
|
|
80
|
+
const openFramePath = computed(() => {
|
|
81
|
+
const inset = frameInset.value
|
|
82
|
+
const right = Math.max(inset, (props.track.width ?? props.innerWidth) - inset)
|
|
83
|
+
const bottom = Math.max(inset, props.track.height - inset)
|
|
84
|
+
return `M ${inset} ${inset} V ${bottom} H ${right} V ${inset}`
|
|
85
|
+
})
|
|
77
86
|
</script>
|
|
78
87
|
|
|
79
88
|
<template>
|
|
@@ -106,12 +115,16 @@ const frameInset = computed(() =>
|
|
|
106
115
|
:time-unit="timeUnit"
|
|
107
116
|
:y-label="yLabel"
|
|
108
117
|
:show-units="showAxisUnits"
|
|
109
|
-
:
|
|
118
|
+
:show-label-units="showLabelUnits"
|
|
119
|
+
:hide-y-axis-titles="hideYAxisTitles"
|
|
120
|
+
:contain-y-axis-endpoints="containYAxisEndpoints || displayMode === 'compact'"
|
|
110
121
|
/>
|
|
111
122
|
|
|
112
|
-
<
|
|
123
|
+
<component
|
|
124
|
+
:is="omitFrameTop ? 'path' : 'rect'"
|
|
113
125
|
v-if="!track.isEmpty"
|
|
114
126
|
class="waveform-track__plot-frame waveform-chart__plot-frame"
|
|
127
|
+
:d="omitFrameTop ? openFramePath : undefined"
|
|
115
128
|
:x="containFrameStroke ? frameInset : undefined"
|
|
116
129
|
:y="containFrameStroke ? frameInset : undefined"
|
|
117
130
|
:width="Math.max(0, (track.width ?? innerWidth) - frameInset * 2)"
|
|
@@ -7,6 +7,7 @@ import { formatXAxisLabel } from '../../utils'
|
|
|
7
7
|
import { formatYAxisTickLabel } from '../core/layout'
|
|
8
8
|
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
|
|
9
9
|
import { Y_AXIS_LABEL_BAND_WIDTH } from '../core/yAxisConstants'
|
|
10
|
+
import { formatChannelLabel } from './channelLabel'
|
|
10
11
|
|
|
11
12
|
interface Props {
|
|
12
13
|
track: TrackLayout
|
|
@@ -16,6 +17,8 @@ interface Props {
|
|
|
16
17
|
timeUnit: 's' | 'ms'
|
|
17
18
|
yLabel?: string
|
|
18
19
|
showUnits?: boolean
|
|
20
|
+
showLabelUnits?: boolean
|
|
21
|
+
hideYAxisTitles?: boolean
|
|
19
22
|
containYAxisEndpoints?: boolean
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -24,7 +27,8 @@ const xAxisElement = ref<SVGGElement>()
|
|
|
24
27
|
const yAxisElements = ref<SVGGElement[]>([])
|
|
25
28
|
|
|
26
29
|
function resolveYAxisLabel(series: DisplaySeries): string {
|
|
27
|
-
|
|
30
|
+
const name = series.name.trim() || props.yLabel || ''
|
|
31
|
+
return props.showLabelUnits ? formatChannelLabel(name, series.unit) : name
|
|
28
32
|
}
|
|
29
33
|
|
|
30
34
|
function hasYAxisTitle(axis: WaveformYAxisLayout): boolean {
|
|
@@ -36,12 +40,6 @@ function setYAxisElement(element: unknown, index: number) {
|
|
|
36
40
|
if (element) yAxisElements.value[index] = element as SVGGElement
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
function shouldShowYAxisLabel(trackHeight: number, trackIndex: number): boolean {
|
|
40
|
-
const minimumHeightForLabel = 80
|
|
41
|
-
if (trackHeight >= minimumHeightForLabel) return true
|
|
42
|
-
return trackIndex % Math.ceil(minimumHeightForLabel / trackHeight) === 0
|
|
43
|
-
}
|
|
44
|
-
|
|
45
43
|
function renderAxes() {
|
|
46
44
|
props.track.yAxes.forEach((axis, index) => {
|
|
47
45
|
const element = yAxisElements.value[index]
|
|
@@ -54,6 +52,7 @@ function renderAxes() {
|
|
|
54
52
|
axis.scale.domain() as [number, number],
|
|
55
53
|
axis.tickValues,
|
|
56
54
|
unit,
|
|
55
|
+
axis.side,
|
|
57
56
|
),
|
|
58
57
|
)
|
|
59
58
|
.tickSize(-4)
|
|
@@ -181,13 +180,13 @@ watch(
|
|
|
181
180
|
<g
|
|
182
181
|
v-if="
|
|
183
182
|
!cleanView &&
|
|
183
|
+
!hideYAxisTitles &&
|
|
184
184
|
!track.isEmpty &&
|
|
185
185
|
track.hasVisibleSeries &&
|
|
186
186
|
track.seriesList.length === 1 &&
|
|
187
187
|
track.showYAxisLabel &&
|
|
188
188
|
track.series &&
|
|
189
|
-
resolveYAxisLabel(track.series)
|
|
190
|
-
shouldShowYAxisLabel(track.height, track.index)
|
|
189
|
+
resolveYAxisLabel(track.series)
|
|
191
190
|
"
|
|
192
191
|
>
|
|
193
192
|
<rect
|
|
@@ -210,7 +209,9 @@ watch(
|
|
|
210
209
|
</g>
|
|
211
210
|
|
|
212
211
|
<g
|
|
213
|
-
v-for="axis in !cleanView && track.yAxes.length > 1
|
|
212
|
+
v-for="axis in !cleanView && !hideYAxisTitles && track.yAxes.length > 1
|
|
213
|
+
? track.yAxes.filter(hasYAxisTitle)
|
|
214
|
+
: []"
|
|
214
215
|
:key="`y-axis-title-${track.index}-${axis.index}`"
|
|
215
216
|
class="waveform-track__multi-axis-title"
|
|
216
217
|
:data-y-axis-title-index="axis.index"
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Format display text without changing the series metadata or identity. */
|
|
2
|
+
export function formatChannelLabel(name: string, unit?: string, separator = ''): string {
|
|
3
|
+
const normalizedUnit = unit?.trim()
|
|
4
|
+
return normalizedUnit && normalizedUnit !== '--' ? `${name}${separator}(${normalizedUnit})` : name
|
|
5
|
+
}
|
package/src/types/chart.ts
CHANGED
|
@@ -16,7 +16,11 @@ export interface WaveformPoint {
|
|
|
16
16
|
export type WaveformLayoutPreset = 'default' | 'edge-compact'
|
|
17
17
|
|
|
18
18
|
/** Controls unit placement without changing series metadata. */
|
|
19
|
-
export type WaveformUnitDisplayMode =
|
|
19
|
+
export type WaveformUnitDisplayMode =
|
|
20
|
+
| 'axis'
|
|
21
|
+
| 'legend-single-series'
|
|
22
|
+
/** Label and legend units; hide all channel titles on pages containing a multi-series track. */
|
|
23
|
+
| 'channel-label-or-legend'
|
|
20
24
|
|
|
21
25
|
/**
|
|
22
26
|
* 显示模式
|
package/src/utils/formatters.ts
CHANGED
|
@@ -33,10 +33,11 @@ function formatFixedNumber(value: number, precision: number): string {
|
|
|
33
33
|
|
|
34
34
|
/** Whether an axis magnitude should use one shared scientific exponent. */
|
|
35
35
|
export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean {
|
|
36
|
+
const magnitude = Math.abs(maxAbsoluteValue)
|
|
36
37
|
return (
|
|
37
|
-
Number.isFinite(
|
|
38
|
-
(
|
|
39
|
-
(
|
|
38
|
+
Number.isFinite(magnitude) &&
|
|
39
|
+
(magnitude >= SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE ||
|
|
40
|
+
(magnitude > 0 && magnitude <= SCIENTIFIC_MIN_ABSOLUTE_VALUE))
|
|
40
41
|
)
|
|
41
42
|
}
|
|
42
43
|
|