waveform-analysis 0.1.69 → 0.1.71

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 (39) hide show
  1. package/README.md +86 -56
  2. package/dist/assets/workerRuntime-o5An0M-W.js +1 -0
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.js +1350 -1299
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/core/layout.d.ts +3 -1
  7. package/dist/types/components/core/pagination.d.ts +2 -2
  8. package/dist/types/components/core/usePlotLabelLayout.d.ts +6 -0
  9. package/dist/types/components/core/useWaveformChartController.d.ts +4 -2
  10. package/dist/types/components/core/useWaveformLayout.d.ts +1 -0
  11. package/dist/types/components/core/yAxisTitleLayout.d.ts +6 -1
  12. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +1 -1
  13. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +3 -0
  14. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +2 -0
  15. package/dist/types/components/rendering/channelLabel.d.ts +2 -0
  16. package/dist/types/types/chart.d.ts +3 -1
  17. package/package.json +1 -1
  18. package/src/components/WaveformChartView.vue +23 -5
  19. package/src/components/core/layout.ts +32 -6
  20. package/src/components/core/pagination.ts +3 -16
  21. package/src/components/core/trackLayoutBuilder.ts +11 -9
  22. package/src/components/core/usePlotLabelLayout.ts +23 -0
  23. package/src/components/core/useWaveformChartController.ts +10 -7
  24. package/src/components/core/useWaveformLayout.ts +19 -6
  25. package/src/components/core/useWaveformPresentation.ts +7 -4
  26. package/src/components/core/useWaveformYAxisLayout.ts +15 -11
  27. package/src/components/core/yAxisLayoutMetrics.ts +1 -1
  28. package/src/components/core/yAxisTitleLayout.ts +38 -17
  29. package/src/components/interaction/WaveformTooltip.vue +2 -2
  30. package/src/components/interaction/useWaveformZoom.ts +24 -7
  31. package/src/components/rendering/WaveformLegend.vue +5 -3
  32. package/src/components/rendering/WaveformTrack.css +1 -1
  33. package/src/components/rendering/WaveformTrack.vue +15 -2
  34. package/src/components/rendering/WaveformTrackAxes.vue +11 -10
  35. package/src/components/rendering/WaveformTrackBackdrop.vue +1 -1
  36. package/src/components/rendering/channelLabel.ts +5 -0
  37. package/src/types/chart.ts +5 -1
  38. package/src/utils/formatters.ts +4 -3
  39. package/dist/assets/workerRuntime-BcYng9TR.js +0 -1
@@ -28,6 +28,7 @@ import { useWaveformChartLifecycle } from './useWaveformChartLifecycle'
28
28
  import { usePreparedWaveformSeries } from './useWaveformData'
29
29
  import { useWaveformLayout } from './useWaveformLayout'
30
30
  import { useWaveformPresentation } from './useWaveformPresentation'
31
+ import { usePlotLabelLayout } from './usePlotLabelLayout'
31
32
  import { useWaveformRenderSampling } from './useWaveformRenderSampling'
32
33
  import type {
33
34
  ResolvedWaveformChartProps,
@@ -156,6 +157,12 @@ export function useWaveformChartController(
156
157
  annotationLayoutsForTrack,
157
158
  resolveSeriesYScale,
158
159
  } = layout
160
+ const plotLabels = usePlotLabelLayout(
161
+ props,
162
+ layout.resolvedChartLeftMargin,
163
+ layout.innerWidth,
164
+ presentation.titleAreaStyle,
165
+ )
159
166
 
160
167
  useWaveformRenderSampling({
161
168
  props,
@@ -173,13 +180,7 @@ export function useWaveformChartController(
173
180
  watchEffect(() => {
174
181
  paginationBandHeight.value =
175
182
  props.layoutPreset === 'edge-compact'
176
- ? resolveCompactPaginationBand(
177
- paginationVisible.value,
178
- chartWidth.value,
179
- pageCount.value,
180
- resolvedChartLeftMargin.value + innerWidth.value / 2,
181
- layout.resolvedXLabel.value,
182
- )
183
+ ? resolveCompactPaginationBand(paginationVisible.value)
183
184
  : gridOptions.value.showPagination && pageCount.value > 1 && chartWidth.value <= 520
184
185
  ? 40
185
186
  : 0
@@ -321,6 +322,8 @@ export function useWaveformChartController(
321
322
  return reactive({
322
323
  ...toRefs(props),
323
324
  ...presentation,
325
+ plotCenterX: plotLabels.plotCenterX,
326
+ titleAreaStyle: plotLabels.plotTitleAreaStyle,
324
327
  paginationVisible,
325
328
  ...layout,
326
329
  ...annotations,
@@ -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.forEach((item) => {
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 === 'legend-single-series' &&
96
- !chartTracks.value.some((track) => track.series.length > 1),
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.forEach((track) => {
147
- if (track.visibleSeries.length) values.push(track.xDomain[0], track.xDomain[1])
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: props.layoutPreset !== 'edge-compact' || props.unitDisplayMode === 'axis',
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: props.title?.textStyle?.fontWeight ?? 400,
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(() => !isEdgeCompact.value || props.unitDisplayMode === 'axis')
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
- (track) =>
52
- track.visibleSeries.length === 1 &&
53
- Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel),
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
- : hasVisibleWaveformData.value
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.visibleSeries.length > 0),
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
- !hasVisibleWaveformData.value
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 && hasVisibleWaveformData.value
125
+ props.overlayMode === 'multi-axis' && hasMultipleColumns && hasAxisData.value
122
126
  ? Math.max(baseGap, yAxisSlots.value.clearance.left + yAxisSlots.value.clearance.right)
123
- : hasMultipleColumns && hasVisibleWaveformData.value
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.visibleSeries.length > 0)
48
+ .filter((track) => track.series.length > 0)
49
49
  .flatMap((track) =>
50
50
  resolveRenderedYAxisSeriesGroups(
51
51
  track,
@@ -1,4 +1,4 @@
1
- import { formatYAxisTickLabel } from './layout'
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 existing 14px channel names and 11px ticks in WaveformTrack.css.
10
- const TITLE_FONT_SIZE = 14
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(track: TitleTrack, axis: WaveformYAxisLayout, yLabel?: string): number {
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 top = y - (value === lowestTick ? TICK_FONT_SIZE : TICK_FONT_SIZE / 2)
42
- const bottom = value === lowestTick ? y : y + TICK_FONT_SIZE / 2
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
- 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
- ]
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 already leaves 3px beside a 14px glyph.
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(layouts: TitleTrack[], yLabel?: string): void {
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
- distances.set(key, Math.max(distances.get(key) ?? 0, titleDistance(track, axis, yLabel)))
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
- 14px/1.35 Arial,
137
- sans-serif;
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 canHandleWheelZoom = (event: Event, canZoomIn: boolean): boolean => {
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
- return zoomTransform(event.currentTarget as Element).k > ZOOM_CONSTRAINTS.MIN_SCALE
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([1, resolveMaximumZoomScale(dataDomain, groups)])
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([1, resolveMaximumZoomScale(initialXDomain.value, groups)])
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
- const unit = series.unit?.trim()
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: 12px/1.35 sans-serif;
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
  }
@@ -10,7 +10,7 @@
10
10
  }
11
11
 
12
12
  .waveform-track__y-axis-label {
13
- font-size: 14px;
13
+ font-size: 12px;
14
14
  font-weight: 600;
15
15
  pointer-events: none;
16
16
  }
@@ -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
- :contain-y-axis-endpoints="containYAxisEndpoints"
118
+ :show-label-units="showLabelUnits"
119
+ :hide-y-axis-titles="hideYAxisTitles"
120
+ :contain-y-axis-endpoints="containYAxisEndpoints || displayMode === 'compact'"
110
121
  />
111
122
 
112
- <rect
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
- return series.name.trim() || props.yLabel || ''
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 ? track.yAxes.filter(hasYAxisTitle) : []"
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"
@@ -26,7 +26,7 @@ defineProps<Props>()
26
26
  />
27
27
 
28
28
  <g
29
- v-if="!track.isEmpty && track.hasVisibleSeries && !cleanView"
29
+ v-if="!track.isEmpty && !cleanView"
30
30
  :clip-path="`url(#${clipPathId}-${track.index})`"
31
31
  aria-hidden="true"
32
32
  >
@@ -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
+ }
@@ -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 = 'axis' | 'legend-single-series'
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
  * 显示模式
@@ -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(maxAbsoluteValue) &&
38
- (maxAbsoluteValue >= SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE ||
39
- (maxAbsoluteValue > 0 && maxAbsoluteValue < SCIENTIFIC_MIN_ABSOLUTE_VALUE))
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