waveform-analysis 0.1.59 → 0.1.61

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.
@@ -0,0 +1,75 @@
1
+ import type { WaveformOverlayMode } from '../data/types'
2
+ import {
3
+ Y_AXIS_CHARACTER_WIDTH,
4
+ Y_AXIS_LABEL_BAND_WIDTH,
5
+ Y_AXIS_LABEL_GAP,
6
+ Y_AXIS_OUTER_PADDING,
7
+ Y_AXIS_TICK_PADDING,
8
+ } from './constants'
9
+ import { axisTextMetrics, resolveRenderedYAxisSeriesGroups } from './layout'
10
+ import type { DisplayTrack } from './types'
11
+ import type { WaveformYDomain } from './yDomain'
12
+
13
+ export interface YAxisLayoutMetrics {
14
+ tickClearance: number
15
+ fullClearance: number
16
+ labelCenterX: number
17
+ }
18
+
19
+ export function resolveViewportYDomains(
20
+ displayMode: 'independent' | 'separated' | 'compact',
21
+ chartTracks: DisplayTrack[],
22
+ sharedYDomains: Record<string, [number, number]>,
23
+ independentYDomains: Record<number, [number, number]>,
24
+ ): Record<string, [number, number]> {
25
+ if (displayMode !== 'independent') return sharedYDomains
26
+ return Object.fromEntries(
27
+ chartTracks.flatMap((track, index) => {
28
+ const domain = independentYDomains[index]
29
+ return domain ? [[track.id, domain]] : []
30
+ }),
31
+ )
32
+ }
33
+
34
+ export function resolveYAxisLayoutMetrics(
35
+ chartTracks: DisplayTrack[],
36
+ overlayMode: WaveformOverlayMode,
37
+ yDomain: WaveformYDomain | undefined,
38
+ yDomains: Record<string, WaveformYDomain> | undefined,
39
+ viewportYDomains: Record<string, [number, number]>,
40
+ nice: boolean,
41
+ tickCount: number,
42
+ compact: boolean,
43
+ ): YAxisLayoutMetrics {
44
+ const axisText = chartTracks
45
+ .filter((track) => track.visibleSeries.length > 0)
46
+ .flatMap((track) =>
47
+ resolveRenderedYAxisSeriesGroups(track, overlayMode, yDomain, yDomains, viewportYDomains),
48
+ )
49
+ .map(
50
+ (group) =>
51
+ axisTextMetrics(
52
+ group.domain,
53
+ nice,
54
+ undefined,
55
+ group.seriesList[0]?.unit,
56
+ tickCount,
57
+ compact,
58
+ ).tickTextWidth,
59
+ )
60
+ const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
61
+ const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
62
+ const labelCenterX = -(
63
+ Y_AXIS_TICK_PADDING +
64
+ tickTextWidth +
65
+ Y_AXIS_LABEL_GAP +
66
+ Y_AXIS_LABEL_BAND_WIDTH / 2
67
+ )
68
+ const fullClearance =
69
+ tickTextWidth +
70
+ Y_AXIS_TICK_PADDING +
71
+ Y_AXIS_LABEL_GAP +
72
+ Y_AXIS_LABEL_BAND_WIDTH +
73
+ Y_AXIS_OUTER_PADDING
74
+ return { tickClearance, fullClearance, labelCenterX }
75
+ }
@@ -2,7 +2,7 @@ import { zoomIdentity } from 'd3'
2
2
  import { describe, expect, it } from 'vitest'
3
3
 
4
4
  import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core'
5
- import { buildTrackLayouts, buildYAxisSlots } from './layout'
5
+ import { buildTrackLayouts, buildYAxisSlots, resolveRenderedYAxisSeriesGroups } from './layout'
6
6
  import type { DisplaySeries, DisplayTrack } from './types'
7
7
  import { Y_AXIS_RIGHT_LABEL_OFFSET } from './yAxisConstants'
8
8
 
@@ -126,4 +126,40 @@ describe('Y-axis slots', () => {
126
126
  expect(fourAxisSlots.slots[1]!.axisOffset).toBeLessThan(0)
127
127
  expect(fourAxisSlots.slots[3]!.axisOffset).toBeGreaterThan(0)
128
128
  })
129
+
130
+ it('uses dynamic Y domains when measuring multi-axis slots', () => {
131
+ const negativeVoltage = series('negative-voltage', 0, 1)
132
+ negativeVoltage.unit = 'V'
133
+ const longUnit = series('long-unit', 0, 1)
134
+ longUnit.unit = 'volt-amperes-per-phase'
135
+ const sourceTrack = track([negativeVoltage, longUnit])
136
+ const viewportYDomains = { [sourceTrack.id]: [-0.7434, -0.3434] as [number, number] }
137
+
138
+ expect(
139
+ resolveRenderedYAxisSeriesGroups(
140
+ sourceTrack,
141
+ 'multi-axis',
142
+ undefined,
143
+ undefined,
144
+ viewportYDomains,
145
+ ).map((group) => group.domain),
146
+ ).toEqual([
147
+ [-0.7434, -0.3434],
148
+ [-0.7434, -0.3434],
149
+ ])
150
+
151
+ const slots = buildYAxisSlots(
152
+ [sourceTrack],
153
+ 'multi-axis',
154
+ undefined,
155
+ undefined,
156
+ 5,
157
+ false,
158
+ false,
159
+ viewportYDomains,
160
+ )
161
+
162
+ expect(slots.clearance.left).toBe(100)
163
+ expect(slots.clearance.right).toBeGreaterThan(180)
164
+ })
129
165
  })
@@ -1,10 +1,28 @@
1
1
  import { mount } from '@vue/test-utils'
2
- import { describe, expect, it } from 'vitest'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { nextTick } from 'vue'
4
+ import { resizeObservers } from '../../test/setup'
3
5
 
4
6
  import WaveformTooltip from './WaveformTooltip.vue'
5
7
 
6
8
  describe('WaveformTooltip', () => {
7
9
  const point = { x: 1, y: 12 }
10
+ let measuredWidth = 238
11
+ let measuredHeight = 40
12
+
13
+ beforeEach(() => {
14
+ measuredWidth = 238
15
+ measuredHeight = 40
16
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
17
+ this: HTMLElement,
18
+ ) {
19
+ return {
20
+ width: Math.min(measuredWidth, Number.parseFloat(this.style.maxWidth) || measuredWidth),
21
+ height: measuredHeight,
22
+ } as DOMRect
23
+ })
24
+ })
25
+ afterEach(() => vi.restoreAllMocks())
8
26
 
9
27
  function mountTooltip(positionX: number, containerWidth = 400) {
10
28
  return mount(WaveformTooltip, {
@@ -20,33 +38,41 @@ describe('WaveformTooltip', () => {
20
38
  })
21
39
  }
22
40
 
23
- it('positions the tooltip to the right when enough space remains', () => {
24
- const tooltip = mountTooltip(100).get('.waveform-tooltip')
41
+ it('positions the tooltip to the right when enough space remains', async () => {
42
+ const wrapper = mountTooltip(100)
43
+ await nextTick()
44
+ const tooltip = wrapper.get('.waveform-tooltip')
25
45
 
26
46
  expect(tooltip.attributes('style')).toContain('left: 112px')
27
47
  expect(tooltip.attributes('style')).not.toContain('right:')
28
48
  })
29
49
 
30
- it('flips the tooltip to the left near the right boundary', () => {
31
- const tooltip = mountTooltip(370).get('.waveform-tooltip')
50
+ it('flips the tooltip to the left near the right boundary', async () => {
51
+ const wrapper = mountTooltip(370)
52
+ await nextTick()
53
+ const tooltip = wrapper.get('.waveform-tooltip')
32
54
 
33
55
  expect(tooltip.attributes('style')).toContain('right: 42px')
34
56
  expect(tooltip.attributes('style')).not.toContain('left:')
35
57
  })
36
58
 
37
- it('keeps the tooltip inside the left boundary when neither side has enough space', () => {
38
- const tooltip = mountTooltip(100, 200).get('.waveform-tooltip')
59
+ it('keeps the tooltip inside the left boundary when neither side has enough space', async () => {
60
+ const wrapper = mountTooltip(100, 200)
61
+ await nextTick()
62
+ const tooltip = wrapper.get('.waveform-tooltip')
39
63
 
40
64
  expect(tooltip.attributes('style')).toContain('left: 8px')
41
65
  expect(tooltip.attributes('style')).toContain('max-width: 184px')
42
66
  expect(tooltip.attributes('style')).not.toContain('right:')
43
67
  })
44
68
 
45
- it('keeps short content content-sized while exposing the available width cap', () => {
46
- const tooltip = mountTooltip(100).get('.waveform-tooltip')
69
+ it('keeps short content content-sized while exposing the available width cap', async () => {
70
+ const wrapper = mountTooltip(100)
71
+ await nextTick()
72
+ const tooltip = wrapper.get('.waveform-tooltip')
47
73
 
48
74
  expect(tooltip.attributes('style')).toContain('left: 112px')
49
- expect(tooltip.attributes('style')).toContain('max-width: 280px')
75
+ expect(tooltip.attributes('style')).toContain('max-width: 384px')
50
76
  })
51
77
 
52
78
  it('keeps long series content in a wrapping content container', () => {
@@ -77,7 +103,63 @@ describe('WaveformTooltip', () => {
77
103
  expect(tooltip.get('.waveform-tooltip__series-content').classes()).toContain(
78
104
  'waveform-tooltip__series-content',
79
105
  )
80
- expect(tooltip.attributes('style')).toContain('max-width: 280px')
106
+ expect(tooltip.attributes('style')).toContain('max-width: 384px')
107
+ })
108
+
109
+ it('flips before long content is squeezed and restores the right side when moving back', async () => {
110
+ measuredWidth = 420
111
+ const wrapper = mountTooltip(500, 800)
112
+ await nextTick()
113
+ const tooltip = wrapper.get('.waveform-tooltip')
114
+ expect(tooltip.attributes('style')).toContain('right: 312px')
115
+ expect(tooltip.attributes('style')).toContain('max-width: 560px')
116
+ await wrapper.setProps({ position: { x: 100, y: 100 } })
117
+ expect(tooltip.attributes('style')).toContain('left: 112px')
118
+ expect(tooltip.attributes('style')).toContain('max-width: 560px')
119
+ })
120
+
121
+ it('remeasures changing series, formatted values, container size and visibility', async () => {
122
+ const wrapper = mountTooltip(500, 800)
123
+ await nextTick()
124
+ expect(wrapper.get('.waveform-tooltip').attributes('style')).toContain('left: 512px')
125
+ measuredWidth = 450
126
+ measuredHeight = 90
127
+ await wrapper.setProps({
128
+ timeUnit: 'ms',
129
+ position: { x: 500, y: 290 },
130
+ seriesPoints: [
131
+ { trackIndex: 0, name: 'very-long-channel-name', color: '#f00', point },
132
+ { trackIndex: 1, name: 'second-channel', color: '#0f0', point: { x: 100, y: 123456 } },
133
+ ],
134
+ })
135
+ await nextTick()
136
+ expect(wrapper.get('.waveform-tooltip').attributes('style')).toContain('right: 312px')
137
+ expect(wrapper.get('.waveform-tooltip').attributes('style')).toContain('top: 202px')
138
+ await wrapper.setProps({ containerWidth: 200, position: { x: 100, y: 290 } })
139
+ await nextTick()
140
+ expect(wrapper.get('.waveform-tooltip').attributes('style')).toContain('left: 8px')
141
+ expect(wrapper.get('.waveform-tooltip').attributes('style')).toContain('max-width: 184px')
142
+ const observer = resizeObservers.at(-1)!
143
+ await wrapper.setProps({ visible: false })
144
+ expect(observer.disconnect).toHaveBeenCalled()
145
+ measuredWidth = 100
146
+ await wrapper.setProps({ visible: true, containerWidth: 800 })
147
+ await nextTick()
148
+ expect(wrapper.get('.waveform-tooltip').attributes('style')).toContain('left: 112px')
149
+ wrapper.unmount()
150
+ expect(resizeObservers.at(-1)!.disconnect).toHaveBeenCalled()
151
+ })
152
+
153
+ it('updates placement when rendered dimensions change without a prop update', async () => {
154
+ const wrapper = mountTooltip(500, 800)
155
+ await nextTick()
156
+ measuredWidth = 420
157
+ measuredHeight = 250
158
+ resizeObservers.at(-1)!.resize(420, 250)
159
+ await nextTick()
160
+ expect(wrapper.get('.waveform-tooltip').attributes('style')).toContain('right: 312px')
161
+ expect(wrapper.get('.waveform-tooltip').attributes('style')).toContain('top: 42px')
162
+ wrapper.unmount()
81
163
  })
82
164
 
83
165
  it('omits units and errors from the strict x/y value format', () => {
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, onUpdated, ref, watch } from 'vue'
3
3
  import { formatTooltipNumber, formatTooltipTime } from '../../utils'
4
4
  import type { WaveformPoint } from '../data/types'
5
5
 
@@ -33,64 +33,60 @@ const props = defineProps<Props>()
33
33
 
34
34
  const tooltipGap = 12
35
35
  const containerPadding = 8
36
- const tooltipPlacementWidth = 238
37
36
  const tooltipMaxWidth = 560
38
- const tooltipHorizontalPadding = 20
39
- const tooltipLineHeight = 20
40
-
41
- function estimateLineCount(text: string, width: number): number {
42
- const contentWidth = Math.max(1, width - tooltipHorizontalPadding - 14)
43
- const charactersPerLine = Math.max(1, Math.floor(contentWidth / 7.2))
44
- return Math.max(1, Math.ceil([...text].length / charactersPerLine))
45
- }
46
-
47
- function formatSeriesText(seriesPoint: SeriesPoint): string {
48
- const valueText = seriesPoint.point
49
- ? `(x:${formatTooltipTime(seriesPoint.point.x, props.timeUnit)} y:${formatTooltipNumber(seriesPoint.point.y)})`
50
- : '无数据'
51
- return `${seriesPoint.shotNo?.trim() || '未配置炮号'}: ${seriesPoint.name}${
52
- seriesPoint.unit ? `(${seriesPoint.unit})` : ''
53
- } ${valueText}`
37
+ const tooltipElement = ref<HTMLElement | null>(null)
38
+ const tooltipSize = ref({ width: 0, height: 0 })
39
+
40
+ function measureTooltip() {
41
+ const bounds = tooltipElement.value?.getBoundingClientRect()
42
+ const width = bounds?.width ?? 0
43
+ const height = bounds?.height ?? 0
44
+ if (width !== tooltipSize.value.width || height !== tooltipSize.value.height) {
45
+ tooltipSize.value = { width, height }
46
+ }
54
47
  }
55
48
 
56
- function estimateTooltipHeight(width: number): number {
57
- const seriesLines = props.seriesPoints.reduce(
58
- (total, seriesPoint) => total + estimateLineCount(formatSeriesText(seriesPoint), width),
59
- 0,
60
- )
61
- return 16 + tooltipLineHeight * seriesLines + 5
62
- }
49
+ // The width cap depends only on the container, never on the cursor's side.
50
+ // Measuring the rendered box therefore cannot cause a shrink/flip feedback loop.
51
+ watch(
52
+ tooltipElement,
53
+ (element, _, onCleanup) => {
54
+ measureTooltip()
55
+ if (!element) return
56
+ const observer = new ResizeObserver(measureTooltip)
57
+ observer.observe(element)
58
+ onCleanup(() => observer.disconnect())
59
+ },
60
+ { flush: 'post' },
61
+ )
62
+ onUpdated(measureTooltip)
63
63
 
64
64
  const tooltipStyle = computed(() => {
65
65
  if (!props.visible || !props.hoveredPoint) return { display: 'none' }
66
66
 
67
67
  const rightPlacement = props.position.x + tooltipGap
68
- const leftPlacement = props.position.x - tooltipGap - tooltipPlacementWidth
69
- const rightAvailableWidth = props.containerWidth - containerPadding - rightPlacement
70
- const leftAvailableWidth = props.position.x - tooltipGap - containerPadding
68
+ const leftPlacement = props.position.x - tooltipGap - tooltipSize.value.width
71
69
  const availableWidth = Math.max(
72
70
  1,
73
71
  Math.min(tooltipMaxWidth, props.containerWidth - containerPadding * 2),
74
72
  )
75
73
  const horizontalStyle =
76
- rightPlacement + tooltipPlacementWidth <= props.containerWidth - containerPadding
77
- ? {
78
- left: `${rightPlacement}px`,
79
- maxWidth: `${Math.min(tooltipMaxWidth, rightAvailableWidth)}px`,
80
- }
74
+ rightPlacement + tooltipSize.value.width <= props.containerWidth - containerPadding
75
+ ? { left: `${rightPlacement}px` }
81
76
  : leftPlacement >= containerPadding
82
- ? {
83
- right: `${props.containerWidth - props.position.x + tooltipGap}px`,
84
- maxWidth: `${Math.min(tooltipMaxWidth, leftAvailableWidth)}px`,
85
- }
86
- : { left: `${containerPadding}px`, maxWidth: `${availableWidth}px` }
77
+ ? { right: `${props.containerWidth - props.position.x + tooltipGap}px` }
78
+ : { left: `${containerPadding}px` }
87
79
 
88
- const maxWidth = Number.parseFloat(horizontalStyle.maxWidth)
89
80
  return {
90
81
  ...horizontalStyle,
82
+ maxWidth: `${availableWidth}px`,
83
+ visibility: tooltipSize.value.width > 0 ? ('visible' as const) : ('hidden' as const),
91
84
  top: `${Math.max(
92
- 8,
93
- Math.min(props.position.y - 18, props.containerHeight - estimateTooltipHeight(maxWidth) - 8),
85
+ containerPadding,
86
+ Math.min(
87
+ props.position.y - 18,
88
+ props.containerHeight - tooltipSize.value.height - containerPadding,
89
+ ),
94
90
  )}px`,
95
91
  }
96
92
  })
@@ -99,6 +95,7 @@ const tooltipStyle = computed(() => {
99
95
  <template>
100
96
  <div
101
97
  v-if="visible && hoveredPoint"
98
+ ref="tooltipElement"
102
99
  class="waveform-tooltip waveform-chart__tooltip"
103
100
  :style="tooltipStyle"
104
101
  >
@@ -162,7 +159,7 @@ const tooltipStyle = computed(() => {
162
159
 
163
160
  .waveform-tooltip__series-content {
164
161
  display: flex;
165
- flex-wrap: nowrap;
162
+ flex-wrap: wrap;
166
163
  gap: 12px;
167
164
  align-items: baseline;
168
165
  min-width: 0;
@@ -171,14 +168,13 @@ const tooltipStyle = computed(() => {
171
168
  .waveform-tooltip__series-label {
172
169
  flex: 1 1 auto;
173
170
  min-width: 0;
174
- overflow: hidden;
175
- text-overflow: ellipsis;
176
- white-space: nowrap;
171
+ overflow-wrap: anywhere;
177
172
  }
178
173
 
179
174
  .waveform-tooltip__value {
180
- flex: 0 0 auto;
181
- white-space: nowrap;
175
+ flex: 0 1 auto;
176
+ min-width: 0;
177
+ overflow-wrap: anywhere;
182
178
  }
183
179
 
184
180
  .waveform-tooltip__series strong {
@@ -3,7 +3,8 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
3
3
  import { nextTick, onMounted, ref, watch } from 'vue'
4
4
 
5
5
  import type { WaveformAxesOptions } from '../../types'
6
- import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
6
+ import { formatXAxisLabel } from '../../utils'
7
+ import { formatYAxisTickLabel } from '../core/layout'
7
8
  import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
8
9
  import { Y_AXIS_LABEL_BAND_WIDTH } from '../core/yAxisConstants'
9
10
 
@@ -43,12 +44,15 @@ function renderAxes() {
43
44
  props.track.yAxes.forEach((axis, index) => {
44
45
  const element = yAxisElements.value[index]
45
46
  if (!element) return
46
- const [axisMin, axisMax] = axis.scale.domain()
47
- const topTickValue = Math.max(...axis.tickValues)
48
47
  const unit = axis.seriesList[0]?.unit
49
48
  const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
50
49
  .tickFormat((value) =>
51
- formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue, unit }),
50
+ formatYAxisTickLabel(
51
+ Number(value),
52
+ axis.scale.domain() as [number, number],
53
+ axis.tickValues,
54
+ unit,
55
+ ),
52
56
  )
53
57
  .tickSize(-4)
54
58
  .tickPadding(7)
@@ -6,26 +6,6 @@ import { resizeObservers } from '../../test/setup'
6
6
  import { gridSeries, mountSizedChart } from '../../test/waveformChart'
7
7
 
8
8
  describe('WaveformChart', () => {
9
- it('applies the configured Y-axis split number', async () => {
10
- const wrapper = await mountSizedChart(
11
- {
12
- kind: 'points',
13
- points: [
14
- { x: 0, y: 3 },
15
- { x: 1, y: 97 },
16
- ],
17
- },
18
- { axes: { y: { splitNumber: 5 } } },
19
- )
20
-
21
- expect(
22
- wrapper
23
- .get('.waveform-chart__axis--y')
24
- .findAll('.tick text')
25
- .map((tick) => tick.text()),
26
- ).toEqual(['0', '25', '50', '75', '100'])
27
- })
28
-
29
9
  it('expands the Y-axis label gutter for signed values and long exponents', async () => {
30
10
  const wrapper = await mountSizedChart(
31
11
  {
@@ -52,10 +32,13 @@ describe('WaveformChart', () => {
52
32
  const labelBackgroundX = Number(
53
33
  tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
54
34
  )
35
+ const longestTickLabel = Math.max(
36
+ ...tracks[0].findAll('.waveform-chart__axis--y .tick text').map((tick) => tick.text().length),
37
+ )
55
38
 
56
- expect(labelX).toBe(-62)
39
+ expect(Math.abs(labelX)).toBeGreaterThanOrEqual(7 + longestTickLabel * 7 + 6)
57
40
  expect(labelBackgroundX).toBe(labelX - 6)
58
- expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(80)
41
+ expect(Number(wrapper.attributes('data-chart-left-margin'))).toBeGreaterThanOrEqual(80)
59
42
  expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(72)
60
43
  })
61
44
 
@@ -118,12 +101,28 @@ describe('WaveformChart', () => {
118
101
  { grid: { rowCount: 1, columnCount: 1 } },
119
102
  )
120
103
  const initialMargin = wrapper.attributes('data-chart-left-margin')
121
- expect(wrapper.get('.waveform-chart__track').attributes('data-y-axis-label-x')).toBe('-34')
104
+ const initialTrack = wrapper.get('.waveform-chart__track')
105
+ const initialLongestTickLabel = Math.max(
106
+ ...initialTrack
107
+ .findAll('.waveform-chart__axis--y .tick text')
108
+ .map((tick) => tick.text().length),
109
+ )
110
+ expect(initialTrack.attributes('data-y-axis-label-x')).toBe(
111
+ String(-(7 + initialLongestTickLabel * 7 + 6)),
112
+ )
122
113
 
123
114
  await wrapper.get('.ant-pagination-next button').trigger('click')
124
115
 
125
116
  expect(wrapper.attributes('data-chart-left-margin')).toBe(initialMargin)
126
- expect(wrapper.get('.waveform-chart__track').attributes('data-y-axis-label-x')).toBe('-62')
117
+ const pagedTrack = wrapper.get('.waveform-chart__track')
118
+ const pagedLongestTickLabel = Math.max(
119
+ ...pagedTrack
120
+ .findAll('.waveform-chart__axis--y .tick text')
121
+ .map((tick) => tick.text().length),
122
+ )
123
+ expect(pagedTrack.attributes('data-y-axis-label-x')).toBe(
124
+ String(-(7 + pagedLongestTickLabel * 7 + 6)),
125
+ )
127
126
  })
128
127
 
129
128
  it('hides only secondary-column Y-axis labels when the grid is too narrow', async () => {
@@ -0,0 +1,103 @@
1
+ import { flushPromises } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { resizeObservers } from '../../test/setup'
5
+
6
+ import { mountSizedChart } from '../../test/waveformChart'
7
+
8
+ describe('WaveformChart Y-axis layout', () => {
9
+ it('applies the configured Y-axis split number', async () => {
10
+ const wrapper = await mountSizedChart(
11
+ {
12
+ kind: 'points',
13
+ points: [
14
+ { x: 0, y: 3 },
15
+ { x: 1, y: 97 },
16
+ ],
17
+ },
18
+ { axes: { y: { splitNumber: 5 } } },
19
+ )
20
+
21
+ expect(
22
+ wrapper
23
+ .get('.waveform-chart__axis--y')
24
+ .findAll('.tick text')
25
+ .map((tick) => tick.text()),
26
+ ).toEqual(['0', '25', '50', '75', '100'])
27
+ })
28
+
29
+ it('reserves the rendered endpoint label width across tracks, pages, and resizes', async () => {
30
+ const wrapper = await mountSizedChart(
31
+ {
32
+ kind: 'series',
33
+ series: ['WE_2M', 'IP_VV_2M', 'SX2_7_07', 'BT1_2M'].map((name, index) => ({
34
+ id: name,
35
+ name,
36
+ unit: 'V',
37
+ data: {
38
+ kind: 'points' as const,
39
+ points: [
40
+ { x: 0, y: -0.7434 - index },
41
+ { x: 1, y: -0.3434 - index },
42
+ ],
43
+ },
44
+ })),
45
+ },
46
+ {
47
+ axes: { y: { lineVisible: false, nice: false } },
48
+ grid: { rowCount: 1, columnCount: 1 },
49
+ },
50
+ )
51
+ const firstTrack = wrapper.get('.waveform-chart__track')
52
+
53
+ expect(wrapper.attributes('data-chart-left-margin')).toBe('100')
54
+ expect(firstTrack.attributes('data-y-axis-label-x')).toBe('-90')
55
+ expect(firstTrack.findAll('.waveform-chart__axis--y .tick text').at(-1)?.text()).toBe(
56
+ '(V) -0.3434',
57
+ )
58
+
59
+ resizeObservers.at(-1)?.resize(520, 280)
60
+ await flushPromises()
61
+ expect(wrapper.attributes('data-chart-left-margin')).toBe('100')
62
+ expect(wrapper.get('.waveform-chart__track').attributes('data-y-axis-label-x')).toBe('-90')
63
+
64
+ await wrapper.get('.ant-pagination-next button').trigger('click')
65
+ expect(wrapper.attributes('data-chart-left-margin')).toBe('100')
66
+ expect(wrapper.get('.waveform-chart__track').attributes('data-y-axis-label-x')).toBe('-90')
67
+ expect(wrapper.get('.waveform-chart__axis--y').findAll('.tick text').at(-1)?.text()).toBe(
68
+ '(V) -1.3434',
69
+ )
70
+ })
71
+
72
+ it('measures the compact-row unit label after omitting the top endpoint', async () => {
73
+ const wrapper = await mountSizedChart(
74
+ {
75
+ kind: 'series',
76
+ series: ['first', 'second'].map((id) => ({
77
+ id,
78
+ name: id,
79
+ unit: 'V',
80
+ data: {
81
+ kind: 'points' as const,
82
+ points: [
83
+ { x: 0, y: 0.12345 },
84
+ { x: 1, y: 100 },
85
+ ],
86
+ },
87
+ })),
88
+ },
89
+ {
90
+ displayMode: 'compact',
91
+ axes: { y: { nice: false } },
92
+ grid: { rowCount: 2, columnCount: 1 },
93
+ },
94
+ )
95
+ const tracks = wrapper.findAll('.waveform-chart__track')
96
+
97
+ expect(wrapper.attributes('data-chart-left-margin')).toBe('93')
98
+ expect(tracks[1]?.attributes('data-y-axis-label-x')).toBe('-83')
99
+ expect(tracks[1]?.findAll('.waveform-chart__axis--y .tick text').at(-1)?.text()).toBe(
100
+ '(V) 75.031',
101
+ )
102
+ })
103
+ })