feat(chart): add multi-axis overlay controls
This commit is contained in:
@@ -14,6 +14,7 @@ describe('App workspace layout', () => {
|
||||
const frameControls = panel.get('.frame-style-controls')
|
||||
expect(panel.find('h1').exists()).toBe(false)
|
||||
expect(panel.find('[aria-label="波形展示方式"]').exists()).toBe(true)
|
||||
expect(panel.find('[aria-label="波形叠加方式"]').exists()).toBe(true)
|
||||
expect(panel.find('[aria-label="波形网格尺寸"]').exists()).toBe(true)
|
||||
expect(frameControls.findAllComponents(ColorPicker)).toHaveLength(2)
|
||||
expect(frameControls.text()).toContain('边框颜色')
|
||||
@@ -45,6 +46,43 @@ describe('App workspace layout', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('switches overlaid tracks between single-axis and multi-axis rendering', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
|
||||
const overlayControl = wrapper.get('[aria-label="波形叠加方式"]')
|
||||
expect(wrapper.get('.waveform-chart').attributes('data-overlay-mode')).toBe('single-axis')
|
||||
expect(overlayControl.text()).toContain('单值轴')
|
||||
expect(overlayControl.text()).toContain('多值轴')
|
||||
|
||||
await overlayControl.findAll('input[type="radio"]')[1]?.setValue(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.waveform-chart').attributes('data-overlay-mode')).toBe('multi-axis')
|
||||
expect(wrapper.findAll('.waveform-chart__axis--y').length).toBeGreaterThan(1)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('renders three additional sample series in the first frame', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
|
||||
const firstFrameLines = wrapper
|
||||
.get('.waveform-chart__track[data-track-index="0"]')
|
||||
.findAll('.waveform-chart__line')
|
||||
|
||||
expect(firstFrameLines.map((line) => line.attributes('data-series-name'))).toEqual([
|
||||
'BT2_2M',
|
||||
'TEST_CH_1',
|
||||
'TEST_CH_3',
|
||||
'TEST_CH_4',
|
||||
'TEST_CH_5',
|
||||
])
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('updates title content, text styles, and visibility', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
|
||||
43
src/App.vue
43
src/App.vue
@@ -13,6 +13,7 @@ import {
|
||||
type WaveformInteractionMode,
|
||||
type WaveformLegendOrientation,
|
||||
type WaveformLegendPosition,
|
||||
type WaveformOverlayMode,
|
||||
type WaveformSeries,
|
||||
type WaveformTitleOptions,
|
||||
} from './components'
|
||||
@@ -40,8 +41,29 @@ const testChannelRows: WaveformSourceRow[] = importedSourceRows.slice(0, 2).map(
|
||||
Math.sin(sampleIndex / (index === 0 ? 11 : 18)) * (index === 0 ? 0.015 : 0.01),
|
||||
),
|
||||
}))
|
||||
const sourceRows = [...importedSourceRows, ...testChannelRows]
|
||||
const additionalFrameOneRows: WaveformSourceRow[] = [
|
||||
importedSourceRows[0],
|
||||
importedSourceRows[1],
|
||||
importedSourceRows[0],
|
||||
].flatMap((row, index) =>
|
||||
row
|
||||
? [
|
||||
{
|
||||
...row,
|
||||
chnl: `TEST_CH_${index + 3}`,
|
||||
chnl_id: 9003 + index,
|
||||
data: row.data.map(
|
||||
(value, sampleIndex) =>
|
||||
value * [0.9, 1.35, 0.55][index]! +
|
||||
Math.sin(sampleIndex / [14, 22, 8][index]!) * [0.012, 0.008, 0.02][index]!,
|
||||
),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
const sourceRows = [...importedSourceRows, ...testChannelRows, ...additionalFrameOneRows]
|
||||
const displayMode = ref<WaveformDisplayMode>('independent')
|
||||
const overlayMode = ref<WaveformOverlayMode>('single-axis')
|
||||
const rowCount = ref(2)
|
||||
const columnCount = ref(1)
|
||||
const frameBorderColor = ref('#1f2937')
|
||||
@@ -112,7 +134,9 @@ const waveformSeries: WaveformSeries[] = sourceRows.map((row) => {
|
||||
return {
|
||||
id: String(row.chnl_id),
|
||||
trackId:
|
||||
row.chnl === 'TEST_CH_1' ? String(importedSourceRows[0]?.chnl_id ?? row.chnl_id) : undefined,
|
||||
row.chnl.startsWith('TEST_CH_') && row.chnl !== 'TEST_CH_2'
|
||||
? String(importedSourceRows[0]?.chnl_id ?? row.chnl_id)
|
||||
: undefined,
|
||||
name: row.chnl,
|
||||
unit: row.dat_unit,
|
||||
data: {
|
||||
@@ -204,6 +228,20 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
||||
</Radio.Group>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<h2>叠加方式</h2>
|
||||
<Radio.Group
|
||||
v-model:value="overlayMode"
|
||||
class="display-mode-control"
|
||||
button-style="solid"
|
||||
size="small"
|
||||
aria-label="波形叠加方式"
|
||||
>
|
||||
<Radio.Button value="single-axis">单值轴</Radio.Button>
|
||||
<Radio.Button value="multi-axis">多值轴</Radio.Button>
|
||||
</Radio.Group>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<h2>图框布局</h2>
|
||||
<div class="grid-size-control" aria-label="波形网格尺寸">
|
||||
@@ -423,6 +461,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
||||
<WaveformChart
|
||||
:data="chartData"
|
||||
:display-mode="displayMode"
|
||||
:overlay-mode="overlayMode"
|
||||
:grid="{ rowCount, columnCount, showPagination: true }"
|
||||
:title="titleOptions"
|
||||
:legend="{
|
||||
|
||||
@@ -96,6 +96,161 @@ describe('WaveformChart', () => {
|
||||
})),
|
||||
})
|
||||
|
||||
it('binds overlaid series to at most four value axes in multi-axis mode', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'series',
|
||||
series: Array.from({ length: 5 }, (_, index) => ({
|
||||
id: `overlaid-${index}`,
|
||||
trackId: 'shared-frame',
|
||||
name: `叠加通道 ${index + 1}`,
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: index * 100 },
|
||||
{ x: 1, y: index * 100 + 10 },
|
||||
],
|
||||
},
|
||||
})),
|
||||
}
|
||||
const wrapper = await mountSizedChart(data, { overlayMode: 'multi-axis' })
|
||||
|
||||
expect(wrapper.attributes('data-overlay-mode')).toBe('multi-axis')
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.waveform-chart__axis--y')
|
||||
.map((axis) => axis.attributes('data-y-axis-side')),
|
||||
).toEqual(['left', 'left', 'right', 'right'])
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-y-axis-index')),
|
||||
).toEqual(['0', '1', '2', '3', '3'])
|
||||
expect(wrapper.findAll('.waveform-track__multi-axis-title')).toHaveLength(4)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not render empty multi-axis title backgrounds', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'empty-title-a',
|
||||
trackId: 'shared-frame',
|
||||
name: '',
|
||||
data: { kind: 'samples', values: [0, 1], sampleRate: 1 },
|
||||
},
|
||||
{
|
||||
id: 'empty-title-b',
|
||||
trackId: 'shared-frame',
|
||||
name: ' ',
|
||||
data: { kind: 'samples', values: [10, 20], sampleRate: 1 },
|
||||
},
|
||||
],
|
||||
}
|
||||
const wrapper = await mountSizedChart(data, { overlayMode: 'multi-axis', yLabel: '' })
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.waveform-track__multi-axis-title')).toHaveLength(0)
|
||||
expect(wrapper.findAll('.waveform-chart__y-axis-label-bg')).toHaveLength(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('resolves a separate scientific multiplier for every Y axis', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
trackId: 'shared-frame',
|
||||
name: '普通量程',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
trackId: 'shared-frame',
|
||||
name: '大量程',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 254 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
trackId: 'shared-frame',
|
||||
name: '小量程',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 0.0002 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ overlayMode: 'multi-axis' },
|
||||
)
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.waveform-chart__axis-exponent--y')
|
||||
.map((label) => [label.attributes('data-y-axis-index'), label.text()]),
|
||||
).toEqual([
|
||||
['1', 'E+02'],
|
||||
['2', 'E-04'],
|
||||
])
|
||||
})
|
||||
|
||||
it('reprojects annotations with the Y axis assigned to their series', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'low',
|
||||
trackId: 'shared-frame',
|
||||
name: '低量程',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 10 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'high',
|
||||
trackId: 'shared-frame',
|
||||
name: '高量程',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 1000 },
|
||||
{ x: 1, y: 2000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const wrapper = await mountSizedChart(data, {
|
||||
annotations: [{ id: 'high-note', seriesId: 'high', x: 0.5, y: 1500, text: '高值' }],
|
||||
})
|
||||
const singleAxisY = wrapper.get('.waveform-annotation__arrow').attributes('y2')
|
||||
|
||||
await wrapper.setProps({ overlayMode: 'multi-axis' })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.waveform-annotation__arrow').attributes('y2')).not.toBe(singleAxisY)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('paginates channels into a row-major two by one grid', async () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(5), {
|
||||
grid: { rowCount: 2, columnCount: 1 },
|
||||
@@ -401,10 +556,10 @@ describe('WaveformChart', () => {
|
||||
tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
|
||||
)
|
||||
|
||||
expect(labelX).toBe(-95)
|
||||
expect(labelX).toBe(-99)
|
||||
expect(labelBackgroundX).toBe(labelX - 12)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(111)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(111)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(115)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(115)
|
||||
})
|
||||
|
||||
it('keeps a tick-only gutter when channel labels are empty', async () => {
|
||||
@@ -430,8 +585,8 @@ describe('WaveformChart', () => {
|
||||
const secondLeft = Number(tracks[1].attributes('data-track-left'))
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(81)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(81)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(85)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(85)
|
||||
})
|
||||
|
||||
it('keeps the Y-axis label gutter stable while paging between value ranges', async () => {
|
||||
@@ -797,26 +952,28 @@ describe('WaveformChart', () => {
|
||||
it.each([45, 90, -90, 180])(
|
||||
'scales a complete long title into the rotated title area at %s degrees',
|
||||
async (rotation) => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
|
||||
{
|
||||
title: {
|
||||
text: '这是一个用于验证旋转缩放行为的完整波形分析标题',
|
||||
textStyle: { rotation },
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
|
||||
{
|
||||
title: {
|
||||
text: '这是一个用于验证旋转缩放行为的完整波形分析标题',
|
||||
textStyle: { rotation },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
const titleHeight = Number(wrapper.attributes('data-title-area-height'))
|
||||
const title = wrapper.get('.waveform-chart__title-text')
|
||||
)
|
||||
const titleHeight = Number(wrapper.attributes('data-title-area-height'))
|
||||
const title = wrapper.get('.waveform-chart__title-text')
|
||||
|
||||
expect(titleHeight).toBeGreaterThanOrEqual(44)
|
||||
expect(titleHeight).toBeLessThanOrEqual(160)
|
||||
expect(Number(wrapper.get('.waveform-chart__svg').attributes('height'))).toBe(360 - titleHeight)
|
||||
expect(title.text()).toBe('这是一个用于验证旋转缩放行为的完整波形分析标题')
|
||||
expect(title.attributes('style')).toContain(`rotate(${rotation}deg)`)
|
||||
expect(title.attributes('style')).toContain('white-space: nowrap')
|
||||
expect(Number(title.attributes('data-title-scale'))).toBeLessThanOrEqual(1)
|
||||
expect(title.attributes('data-title-wrapped')).toBeUndefined()
|
||||
expect(titleHeight).toBeGreaterThanOrEqual(44)
|
||||
expect(titleHeight).toBeLessThanOrEqual(160)
|
||||
expect(Number(wrapper.get('.waveform-chart__svg').attributes('height'))).toBe(
|
||||
360 - titleHeight,
|
||||
)
|
||||
expect(title.text()).toBe('这是一个用于验证旋转缩放行为的完整波形分析标题')
|
||||
expect(title.attributes('style')).toContain(`rotate(${rotation}deg)`)
|
||||
expect(title.attributes('style')).toContain('white-space: nowrap')
|
||||
expect(Number(title.attributes('data-title-scale'))).toBeLessThanOrEqual(1)
|
||||
expect(title.attributes('data-title-wrapped')).toBeUndefined()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -906,16 +1063,20 @@ describe('WaveformChart', () => {
|
||||
expect(component.annotationInteraction.editorDraft.value?.anchor.y).toBe(162)
|
||||
})
|
||||
|
||||
it('captures and suppresses descendant context menus across the waveform svg', async () => {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
it('suppresses native context menus across the waveform component', async () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||
title: { text: '波形标题' },
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: true },
|
||||
showAnnotationToolbar: true,
|
||||
})
|
||||
|
||||
for (const selector of ['.waveform-chart__grid', '.waveform-chart__overlay']) {
|
||||
for (const selector of [
|
||||
'.waveform-chart__title-area',
|
||||
'.waveform-chart__grid',
|
||||
'.waveform-chart__overlay',
|
||||
'.waveform-chart__pagination',
|
||||
'.waveform-annotation-toolbar',
|
||||
]) {
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
const dispatched = wrapper.get(selector).element.dispatchEvent(event)
|
||||
|
||||
@@ -942,6 +1103,25 @@ describe('WaveformChart', () => {
|
||||
expect(sharedEvent.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves native context menus for editable controls', async () => {
|
||||
const wrapper = await mountSizedChart({ kind: 'samples', values: [0, 1], sampleRate: 1 })
|
||||
const editableElements = [
|
||||
document.createElement('input'),
|
||||
document.createElement('textarea'),
|
||||
document.createElement('div'),
|
||||
]
|
||||
editableElements[2]?.setAttribute('contenteditable', 'true')
|
||||
|
||||
for (const element of editableElements) {
|
||||
wrapper.element.appendChild(element)
|
||||
const event = new MouseEvent('contextmenu', { bubbles: true, cancelable: true })
|
||||
const dispatched = element.dispatchEvent(event)
|
||||
|
||||
expect(dispatched).toBe(true)
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('applies size fallbacks for minimum, negative, and non-finite values', async () => {
|
||||
const minimumWrapper = mount(WaveformChart, {
|
||||
props: {
|
||||
@@ -1126,7 +1306,8 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
|
||||
String(trackWidth),
|
||||
)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4,990')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4.99')
|
||||
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
})
|
||||
|
||||
it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => {
|
||||
@@ -1145,14 +1326,14 @@ describe('WaveformChart', () => {
|
||||
.get('.waveform-chart__axis--y')
|
||||
.findAll('.tick text')
|
||||
.map((tick) => tick.text())
|
||||
const exponentLabels = labels.filter((label) => label.startsWith('E'))
|
||||
const exponentLabel = wrapper.find('.waveform-chart__axis-exponent--y')
|
||||
|
||||
if (exponent === null) {
|
||||
expect(exponentLabels).toEqual([])
|
||||
expect(exponentLabel.exists()).toBe(false)
|
||||
} else {
|
||||
expect(exponentLabels).toHaveLength(1)
|
||||
expect(exponentLabels[0]).toMatch(new RegExp(`^${exponent.replace('+', '\\+')} `))
|
||||
expect(labels.at(-1)).toBe(exponentLabels[0])
|
||||
expect(exponentLabel.text()).toBe(exponent)
|
||||
expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
|
||||
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
|
||||
}
|
||||
|
||||
wrapper.unmount()
|
||||
@@ -1175,12 +1356,14 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(millisecondsChart.text()).toContain('时间(ms)')
|
||||
expect(millisecondTicks.length).toBeGreaterThan(0)
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1,000')
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false)
|
||||
|
||||
const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' })
|
||||
expect(secondsChart.text()).toContain('Elapsed time')
|
||||
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1')
|
||||
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(secondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('pins the exact visible range values to both x-axis endpoints', async () => {
|
||||
@@ -1198,14 +1381,15 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(start.attributes('x')).toBe('0')
|
||||
expect(start.attributes('text-anchor')).toBe('start')
|
||||
expect(start.text()).toBe('0')
|
||||
expect(start.text()).toBe('0.00')
|
||||
expect(end.attributes('x')).toBe(
|
||||
wrapper.get('.waveform-chart__track').attributes('data-track-width'),
|
||||
)
|
||||
expect(end.attributes('text-anchor')).toBe('end')
|
||||
expect(end.text()).toBe('1,999')
|
||||
expect(end.text()).toBe('2.00')
|
||||
expect(middleTickLabels.length).toBeGreaterThan(0)
|
||||
expect(middleTickLabels).not.toContain('0')
|
||||
expect(middleTickLabels).not.toContain('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(
|
||||
middleTickLabels.length,
|
||||
)
|
||||
@@ -1303,15 +1487,15 @@ describe('WaveformChart', () => {
|
||||
],
|
||||
}
|
||||
const wrapper = await mountSizedChart(firstData)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1,000')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
|
||||
firstData.points.push({ x: 2, y: 2 })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1,000')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
|
||||
await wrapper.setProps({ data: { ...firstData, points: [...firstData.points] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2,000')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
})
|
||||
|
||||
it('renders named multi-channel paths as independent tracks by default', async () => {
|
||||
@@ -1366,7 +1550,7 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0)
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text()),
|
||||
).toEqual(['1,000', '2,000'])
|
||||
).toEqual(['1.00', '2.00'])
|
||||
})
|
||||
|
||||
it('keeps the zero Y-axis label on upper compact tracks', async () => {
|
||||
@@ -1566,7 +1750,7 @@ describe('WaveformChart', () => {
|
||||
expect(endTicks[3]).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the shared exponent on the top visible tick in compact mode', async () => {
|
||||
it('keeps one separate shared exponent for every compact Y axis', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
@@ -1597,11 +1781,13 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
|
||||
const axes = wrapper.findAll('.waveform-chart__axis--y')
|
||||
const exponents = wrapper.findAll('.waveform-chart__axis-exponent--y')
|
||||
expect(axes).toHaveLength(2)
|
||||
expect(exponents.map((label) => label.text())).toEqual(['E+02', 'E-04'])
|
||||
axes.forEach((axis) => {
|
||||
const labels = axis.findAll('.tick text').map((tick) => tick.text())
|
||||
expect(labels.filter((label) => label.startsWith('E'))).toHaveLength(1)
|
||||
expect(labels.at(-1)).toMatch(/^E[+-]\d{2} /)
|
||||
expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
|
||||
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1821,7 +2007,7 @@ describe('WaveformChart', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2,000')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
})
|
||||
|
||||
it('updates rendering props and disables zoom interaction', async () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ZoomTransform,
|
||||
} from 'd3'
|
||||
import { resolveWaveformRenderingOptions } from '../core'
|
||||
import { formatScientificYAxisLabel, paddedDomain } from '../utils'
|
||||
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
type WaveformDisplayMode,
|
||||
type WaveformFrameStyle,
|
||||
type WaveformInteractionMode,
|
||||
type WaveformOverlayMode,
|
||||
type WaveformLegendOptions,
|
||||
type WaveformLegendOrientation,
|
||||
type WaveformLegendPosition,
|
||||
@@ -72,7 +73,7 @@ import {
|
||||
type WaveformGridOptions,
|
||||
} from './core/grid'
|
||||
import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types'
|
||||
import { buildTrackLayouts } from './core/layout'
|
||||
import { buildTrackLayouts, measureTrackYAxisClearance } from './core/layout'
|
||||
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './core/title'
|
||||
import { usePreparedWaveformSeries } from './core/useWaveformData'
|
||||
import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue'
|
||||
@@ -81,6 +82,7 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
data: WaveformData
|
||||
displayMode?: WaveformDisplayMode
|
||||
overlayMode?: WaveformOverlayMode
|
||||
width?: number
|
||||
height?: number
|
||||
xLabel?: string
|
||||
@@ -102,6 +104,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
displayMode: 'independent',
|
||||
overlayMode: 'single-axis',
|
||||
yLabel: '幅值',
|
||||
lineColor: '#0960bd',
|
||||
showTooltip: true,
|
||||
@@ -304,29 +307,42 @@ const yAxisTickPadding = 7
|
||||
const yAxisOuterPadding = 4
|
||||
const yAxisLabelGap = 6
|
||||
const yAxisLabelBandWidth = 24
|
||||
const yAxisExponentGap = 4
|
||||
const minimumPlotWidth = 120
|
||||
|
||||
const yAxisMetrics = computed(() => {
|
||||
const formattedTickLabels = chartTracks.value.flatMap((track) => {
|
||||
const axisText = chartTracks.value.map((track) => {
|
||||
const scale = scaleLinear(track.yDomain, [1, 0]).nice()
|
||||
const [axisMin, axisMax] = scale.domain()
|
||||
const values = scale.ticks(10)
|
||||
const topTickValue = values.reduce<number | undefined>((closestTick, tickValue) => {
|
||||
if (closestTick === undefined) return tickValue
|
||||
return Math.abs(tickValue - axisMax) < Math.abs(closestTick - axisMax)
|
||||
? tickValue
|
||||
: closestTick
|
||||
}, undefined)
|
||||
return values.map((value) =>
|
||||
formatScientificYAxisLabel(value, { axisMin, axisMax, topTickValue }),
|
||||
)
|
||||
return {
|
||||
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
|
||||
tickLabels: values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
|
||||
}
|
||||
})
|
||||
const formattedTickLabels = axisText.flatMap(({ tickLabels }) => tickLabels)
|
||||
const maximumCharacterCount = Math.max(1, ...formattedTickLabels.map((label) => label.length))
|
||||
const tickTextWidth = maximumCharacterCount * yAxisCharacterWidth
|
||||
const tickClearance = tickTextWidth + yAxisTickPadding + yAxisOuterPadding
|
||||
const labelCenterX = -(yAxisTickPadding + tickTextWidth + yAxisLabelGap + yAxisLabelBandWidth / 2)
|
||||
const maximumExponentWidth = Math.max(
|
||||
0,
|
||||
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * yAxisCharacterWidth),
|
||||
)
|
||||
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + yAxisExponentGap : 0
|
||||
const tickClearance = tickTextWidth + yAxisTickPadding + exponentClearance + yAxisOuterPadding
|
||||
const labelCenterX = -(
|
||||
yAxisTickPadding +
|
||||
tickTextWidth +
|
||||
exponentClearance +
|
||||
yAxisLabelGap +
|
||||
yAxisLabelBandWidth / 2
|
||||
)
|
||||
const fullClearance =
|
||||
tickTextWidth + yAxisTickPadding + yAxisLabelGap + yAxisLabelBandWidth + yAxisOuterPadding
|
||||
tickTextWidth +
|
||||
yAxisTickPadding +
|
||||
exponentClearance +
|
||||
yAxisLabelGap +
|
||||
yAxisLabelBandWidth +
|
||||
yAxisOuterPadding
|
||||
|
||||
return { tickClearance, fullClearance, labelCenterX }
|
||||
})
|
||||
@@ -345,8 +361,30 @@ const chartLeftMargin = computed(() =>
|
||||
: 0,
|
||||
),
|
||||
)
|
||||
const multiAxisClearance = computed(() =>
|
||||
chartTracks.value.reduce(
|
||||
(maximum, track) => {
|
||||
const clearance = measureTrackYAxisClearance(track, props.overlayMode)
|
||||
return {
|
||||
left: Math.max(maximum.left, clearance.left),
|
||||
right: Math.max(maximum.right, clearance.right),
|
||||
}
|
||||
},
|
||||
{ left: 0, right: 0 },
|
||||
),
|
||||
)
|
||||
const resolvedChartLeftMargin = computed(() =>
|
||||
props.overlayMode === 'multi-axis'
|
||||
? Math.max(chartLeftMargin.value, multiAxisClearance.value.left)
|
||||
: chartLeftMargin.value,
|
||||
)
|
||||
const chartRightMargin = computed(() =>
|
||||
props.overlayMode === 'multi-axis'
|
||||
? Math.max(margin.right, multiAxisClearance.value.right)
|
||||
: margin.right,
|
||||
)
|
||||
const innerWidth = computed(() =>
|
||||
Math.max(0, chartWidth.value - chartLeftMargin.value - margin.right),
|
||||
Math.max(0, chartWidth.value - resolvedChartLeftMargin.value - chartRightMargin.value),
|
||||
)
|
||||
const yAxisLayout = computed(() => {
|
||||
const baseGap = getGridGap(props.displayMode)
|
||||
@@ -359,12 +397,18 @@ const yAxisLayout = computed(() => {
|
||||
|
||||
return {
|
||||
horizontalGap:
|
||||
hasMultipleColumns && chartSeries.value.length
|
||||
? hasYAxisLabels.value && canReserveLabelClearance
|
||||
? fullGap
|
||||
: tickGap
|
||||
: baseGap,
|
||||
hideSecondaryLabels: hasMultipleColumns && hasYAxisLabels.value && !canReserveLabelClearance,
|
||||
props.overlayMode === 'multi-axis' && hasMultipleColumns && chartSeries.value.length
|
||||
? Math.max(baseGap, multiAxisClearance.value.left + multiAxisClearance.value.right)
|
||||
: hasMultipleColumns && chartSeries.value.length
|
||||
? hasYAxisLabels.value && canReserveLabelClearance
|
||||
? fullGap
|
||||
: tickGap
|
||||
: baseGap,
|
||||
hideSecondaryLabels:
|
||||
props.overlayMode !== 'multi-axis' &&
|
||||
hasMultipleColumns &&
|
||||
hasYAxisLabels.value &&
|
||||
!canReserveLabelClearance,
|
||||
}
|
||||
})
|
||||
const hasWaveformData = computed(() => chartSeries.value.length > 0)
|
||||
@@ -415,6 +459,7 @@ const trackLayouts = computed<TrackLayout[]>(() =>
|
||||
cells: gridCells.value,
|
||||
grid: gridOptions.value,
|
||||
displayMode: props.displayMode,
|
||||
overlayMode: props.overlayMode,
|
||||
independentTransforms: independentTransforms.value,
|
||||
sharedZoomDomain: sharedZoomDomain.value,
|
||||
timeUnit: props.timeUnit,
|
||||
@@ -426,7 +471,20 @@ const trackLayouts = computed<TrackLayout[]>(() =>
|
||||
)
|
||||
|
||||
function annotationLayoutsForTrack(track: TrackLayout): AnnotationTrackLayout[] {
|
||||
return track.seriesList.map((series) => ({ ...track, series }))
|
||||
return track.seriesList.map((series) => ({
|
||||
...track,
|
||||
series,
|
||||
yScale:
|
||||
track.seriesPaths.find((seriesPath) => seriesPath.series.id === series.id)?.yScale ??
|
||||
track.yScale,
|
||||
}))
|
||||
}
|
||||
|
||||
function resolveSeriesYScale(track: TrackLayout, seriesId: string) {
|
||||
return (
|
||||
track.seriesPaths.find((seriesPath) => seriesPath.series.id === seriesId)?.yScale ??
|
||||
track.yScale
|
||||
)
|
||||
}
|
||||
|
||||
const annotationTrackLayouts = computed<AnnotationTrackLayout[]>(() =>
|
||||
@@ -602,7 +660,7 @@ function resolvePointerEditorAnchor(
|
||||
const [pointerX, pointerY] = pointer(event, overlay)
|
||||
const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
|
||||
return {
|
||||
x: chartLeftMargin.value + (track ? track.left + pointerX : pointerX),
|
||||
x: resolvedChartLeftMargin.value + (track ? track.left + pointerX : pointerX),
|
||||
y: titleAreaHeight.value + margin.top + (track ? track.top + pointerY : pointerY),
|
||||
}
|
||||
}
|
||||
@@ -613,10 +671,13 @@ function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): Annotati
|
||||
)
|
||||
return {
|
||||
x: track
|
||||
? chartLeftMargin.value + track.left + track.xScale(annotation.x)
|
||||
? resolvedChartLeftMargin.value + track.left + track.xScale(annotation.x)
|
||||
: chartWidth.value / 2,
|
||||
y: track
|
||||
? titleAreaHeight.value + margin.top + track.top + track.yScale(annotation.y)
|
||||
? titleAreaHeight.value +
|
||||
margin.top +
|
||||
track.top +
|
||||
resolveSeriesYScale(track, annotation.seriesId)(annotation.y)
|
||||
: chartHeight.value / 2,
|
||||
}
|
||||
}
|
||||
@@ -743,6 +804,17 @@ function handleAnnotationClick(event: MouseEvent, trackIndex?: number) {
|
||||
beginCreate(nearby[0], context.editorAnchor, context.candidates)
|
||||
}
|
||||
|
||||
function handleNativeContextMenu(event: MouseEvent) {
|
||||
const target = event.target
|
||||
if (
|
||||
target instanceof Element &&
|
||||
target.closest('input, textarea, [contenteditable]:not([contenteditable="false"])')
|
||||
) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function handleAnnotationContextMenu(event: MouseEvent, trackIndex?: number) {
|
||||
if (!props.annotationsVisible) return
|
||||
event.preventDefault()
|
||||
@@ -787,7 +859,7 @@ function editContextAnnotation() {
|
||||
annotationLayoutsForTrack(track),
|
||||
annotation.x,
|
||||
track.xScale(annotation.x),
|
||||
track.top + track.yScale(annotation.y),
|
||||
track.top + resolveSeriesYScale(track, annotation.seriesId)(annotation.y),
|
||||
)
|
||||
: []
|
||||
annotationInteraction.openEdit(
|
||||
@@ -837,7 +909,7 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
|
||||
})
|
||||
hoveredTrackIndex.value = trackIndex
|
||||
hoverPosition.value = {
|
||||
x: chartLeftMargin.value + track.left + pointerX,
|
||||
x: resolvedChartLeftMargin.value + track.left + pointerX,
|
||||
y: titleAreaHeight.value + margin.top + track.top + pointerY,
|
||||
}
|
||||
emit('point-hover', hoveredSeriesPoints.value[0]?.point ?? null)
|
||||
@@ -858,7 +930,7 @@ function handleSharedPointerMove(event: PointerEvent) {
|
||||
)
|
||||
hoveredTrackIndex.value = null
|
||||
hoverPosition.value = {
|
||||
x: chartLeftMargin.value + pointerX,
|
||||
x: resolvedChartLeftMargin.value + pointerX,
|
||||
y: titleAreaHeight.value + margin.top + pointerY,
|
||||
}
|
||||
emit('point-hover', hoveredPoint.value)
|
||||
@@ -1015,8 +1087,10 @@ onBeforeUnmount(() => {
|
||||
:style="containerStyle"
|
||||
:data-display-mode="displayMode"
|
||||
:data-interaction-mode="activeInteractionMode"
|
||||
:data-chart-left-margin="chartLeftMargin"
|
||||
:data-overlay-mode="overlayMode"
|
||||
:data-chart-left-margin="resolvedChartLeftMargin"
|
||||
:data-title-area-height="titleAreaHeight"
|
||||
@contextmenu.capture="handleNativeContextMenu"
|
||||
>
|
||||
<div
|
||||
v-if="titleVisible"
|
||||
@@ -1052,7 +1126,6 @@ onBeforeUnmount(() => {
|
||||
:height="drawingHeight"
|
||||
role="img"
|
||||
:aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'"
|
||||
@contextmenu.capture.prevent
|
||||
>
|
||||
<defs>
|
||||
<clipPath
|
||||
@@ -1065,7 +1138,7 @@ onBeforeUnmount(() => {
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g :transform="`translate(${chartLeftMargin}, ${margin.top})`">
|
||||
<g :transform="`translate(${resolvedChartLeftMargin}, ${margin.top})`">
|
||||
<g v-if="displayMode !== 'compact'" class="waveform-chart__grid-slots" aria-hidden="true">
|
||||
<g
|
||||
v-for="cell in gridCells"
|
||||
|
||||
@@ -77,10 +77,9 @@ describe('waveform annotation controls', () => {
|
||||
)
|
||||
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('Y2')
|
||||
expect(wrapper.get('button.is-primary').attributes('disabled')).toBeDefined()
|
||||
await vi.waitFor(
|
||||
() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
await vi.waitFor(() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3), {
|
||||
timeout: 5000,
|
||||
})
|
||||
const colorPickers = wrapper.findAllComponents(ColorPicker)
|
||||
expect(wrapper.findAll('.waveform-annotation-editor__color-field')).toHaveLength(3)
|
||||
expect(colorPickers.map((picker) => picker.props('pureColor'))).toEqual([
|
||||
@@ -156,10 +155,9 @@ describe('waveform annotation controls', () => {
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
await vi.waitFor(
|
||||
() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3),
|
||||
{ timeout: 5000 },
|
||||
)
|
||||
await vi.waitFor(() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3), {
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.findAllComponents(ColorPicker).map((picker) => picker.props('pureColor')),
|
||||
|
||||
@@ -32,11 +32,27 @@ const createTrack = (
|
||||
|
||||
describe('waveform annotation markup', () => {
|
||||
it('interpolates a line value at the pointer X', () => {
|
||||
expect(interpolateAnnotationPoint([{ x: 0, y: 0 }, { x: 2, y: 10 }], 1)).toEqual({
|
||||
expect(
|
||||
interpolateAnnotationPoint(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2, y: 10 },
|
||||
],
|
||||
1,
|
||||
),
|
||||
).toEqual({
|
||||
x: 1,
|
||||
y: 5,
|
||||
})
|
||||
expect(interpolateAnnotationPoint([{ x: 0, y: 0 }, { x: 2, y: 10 }], 3)).toBeNull()
|
||||
expect(
|
||||
interpolateAnnotationPoint(
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2, y: 10 },
|
||||
],
|
||||
3,
|
||||
),
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('sorts line candidates by screen distance and keeps series metadata', () => {
|
||||
@@ -106,10 +122,16 @@ describe('waveform annotation markup', () => {
|
||||
})
|
||||
|
||||
it('filters invalid entries and separates nearby annotation boxes', () => {
|
||||
const track = createTrack(0, 'a', 0, [
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
], 300)
|
||||
const track = createTrack(
|
||||
0,
|
||||
'a',
|
||||
0,
|
||||
[
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
300,
|
||||
)
|
||||
const annotations: WaveformAnnotation[] = [
|
||||
{ id: 'first', seriesId: 'a', x: 1, y: 2, text: '第一个标注' },
|
||||
{ id: 'second', seriesId: 'a', x: 1, y: 2, text: '第二个标注' },
|
||||
@@ -136,10 +158,16 @@ describe('waveform annotation markup', () => {
|
||||
})
|
||||
|
||||
it('prefers centered vertical placements and moves below a top boundary', () => {
|
||||
const track = createTrack(0, 'a', 0, [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
], 200)
|
||||
const track = createTrack(
|
||||
0,
|
||||
'a',
|
||||
0,
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
200,
|
||||
)
|
||||
|
||||
const centered = layoutAnnotations(
|
||||
[{ id: 'centered', seriesId: 'a', x: 1, y: 5, text: '居中' }],
|
||||
@@ -166,10 +194,16 @@ describe('waveform annotation markup', () => {
|
||||
})
|
||||
|
||||
it('reverses direction when the preferred placement is clipped by a boundary', () => {
|
||||
const track = createTrack(0, 'a', 0, [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
], 200)
|
||||
const track = createTrack(
|
||||
0,
|
||||
'a',
|
||||
0,
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
200,
|
||||
)
|
||||
|
||||
const nearTop = layoutAnnotations(
|
||||
[{ id: 'top-space', seriesId: 'a', x: 1, y: 7.5, text: '顶部空间不足' }],
|
||||
@@ -207,10 +241,16 @@ describe('waveform annotation markup', () => {
|
||||
})
|
||||
|
||||
it('uses later directional candidates when vertical candidates collide', () => {
|
||||
const track = createTrack(0, 'a', 0, [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
], 200)
|
||||
const track = createTrack(
|
||||
0,
|
||||
'a',
|
||||
0,
|
||||
[
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
200,
|
||||
)
|
||||
const rendered = layoutAnnotations(
|
||||
[
|
||||
{ id: 'one', seriesId: 'a', x: 1, y: 5, text: '同一位置一' },
|
||||
@@ -225,5 +265,4 @@ describe('waveform annotation markup', () => {
|
||||
expect(rendered[1].placement).not.toBe('top')
|
||||
expect(rendered[1].box).not.toMatchObject({ x: rendered[0].box.x, y: rendered[0].box.y })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -91,7 +91,9 @@ export function findAnnotationSeriesCandidates(
|
||||
},
|
||||
]
|
||||
})
|
||||
.sort((first, second) => first.distance - second.distance || first.trackIndex - second.trackIndex)
|
||||
.sort(
|
||||
(first, second) => first.distance - second.distance || first.trackIndex - second.trackIndex,
|
||||
)
|
||||
}
|
||||
let annotationTextMeasurementContext: CanvasRenderingContext2D | null | undefined
|
||||
|
||||
@@ -113,7 +115,9 @@ export function measureAnnotationTextWidth(text: string): number {
|
||||
}
|
||||
}
|
||||
}
|
||||
return annotationTextMeasurementContext?.measureText(text).width ?? fallbackAnnotationTextWidth(text)
|
||||
return (
|
||||
annotationTextMeasurementContext?.measureText(text).width ?? fallbackAnnotationTextWidth(text)
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveAnnotationStyle(style?: WaveformAnnotationStyle) {
|
||||
|
||||
@@ -51,14 +51,7 @@ export interface AnnotationBoxLayout {
|
||||
}
|
||||
|
||||
export type AnnotationPlacement =
|
||||
| 'top'
|
||||
| 'bottom'
|
||||
| 'right'
|
||||
| 'left'
|
||||
| 'top-right'
|
||||
| 'top-left'
|
||||
| 'bottom-right'
|
||||
| 'bottom-left'
|
||||
'top' | 'bottom' | 'right' | 'left' | 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'
|
||||
|
||||
export interface RenderedAnnotation {
|
||||
annotation: WaveformAnnotation
|
||||
|
||||
@@ -28,9 +28,19 @@ describe('waveform grid helpers', () => {
|
||||
|
||||
it('resolves mode-specific gaps and bottom cells', () => {
|
||||
const options = normalizeGridOptions({ rowCount: 2, columnCount: 2 })
|
||||
const separated = resolveGridCellGeometry(400, 200, options, 'separated', [true, true, true, true])
|
||||
const separated = resolveGridCellGeometry(400, 200, options, 'separated', [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
])
|
||||
const compact = resolveGridCellGeometry(400, 200, options, 'compact', [true, true, true, true])
|
||||
const independent = resolveGridCellGeometry(400, 200, options, 'independent', [true, true, false, false])
|
||||
const independent = resolveGridCellGeometry(400, 200, options, 'independent', [
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
])
|
||||
expect(separated[1].left).toBeGreaterThan(separated[0].left + separated[0].width)
|
||||
expect(separated[2].top).toBe(separated[0].plotHeight + 16)
|
||||
expect(separated[2].xAxisBand).toBe(X_AXIS_BAND)
|
||||
@@ -50,7 +60,14 @@ describe('waveform grid helpers', () => {
|
||||
|
||||
it('accepts an independent horizontal gap without changing vertical spacing', () => {
|
||||
const options = normalizeGridOptions({ rowCount: 2, columnCount: 2 })
|
||||
const cells = resolveGridCellGeometry(400, 200, options, 'independent', [true, true, true, true], 64)
|
||||
const cells = resolveGridCellGeometry(
|
||||
400,
|
||||
200,
|
||||
options,
|
||||
'independent',
|
||||
[true, true, true, true],
|
||||
64,
|
||||
)
|
||||
|
||||
expect(cells[0].width).toBe(168)
|
||||
expect(cells[1].left).toBe(232)
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { WaveformDisplayMode } from '../../types'
|
||||
|
||||
export const GRID_MIN_COUNT = 1
|
||||
export const GRID_MAX_COUNT = 10
|
||||
export const X_AXIS_BAND = 16
|
||||
export const X_AXIS_BAND = 30
|
||||
|
||||
export interface WaveformGridOptions {
|
||||
rowCount?: number
|
||||
@@ -76,7 +76,9 @@ export function resolveGridCellGeometry(
|
||||
horizontalGap?: number,
|
||||
): GridCellGeometry[] {
|
||||
const defaultGap = getGridGap(displayMode)
|
||||
const columnGap = Number.isFinite(horizontalGap) ? Math.max(0, horizontalGap as number) : defaultGap
|
||||
const columnGap = Number.isFinite(horizontalGap)
|
||||
? Math.max(0, horizontalGap as number)
|
||||
: defaultGap
|
||||
const totalHorizontalGap = Math.max(0, options.columnCount - 1) * columnGap
|
||||
const axisRows = new Set<number>()
|
||||
if (displayMode === 'independent') {
|
||||
@@ -99,7 +101,10 @@ export function resolveGridCellGeometry(
|
||||
const totalVerticalGap = Math.max(0, options.rowCount - 1) * defaultGap
|
||||
const totalAxisBand = axisRows.size * X_AXIS_BAND
|
||||
const width = Math.max(1, (innerWidth - totalHorizontalGap) / options.columnCount)
|
||||
const plotHeight = Math.max(1, (innerHeight - totalVerticalGap - totalAxisBand) / options.rowCount)
|
||||
const plotHeight = Math.max(
|
||||
1,
|
||||
(innerHeight - totalVerticalGap - totalAxisBand) / options.rowCount,
|
||||
)
|
||||
|
||||
return Array.from({ length: getPageSize(options) }, (_, slotIndex) => {
|
||||
const row = Math.floor(slotIndex / options.columnCount)
|
||||
|
||||
98
src/components/core/layout.test.ts
Normal file
98
src/components/core/layout.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { DisplaySeries, DisplayTrack } from './types'
|
||||
import { buildYAxisSeriesGroups, MAX_MULTI_Y_AXIS_COUNT } from './layout'
|
||||
|
||||
function series(id: string, minimum: number, maximum: number): DisplaySeries {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
color: '#1677ff',
|
||||
points: [
|
||||
{ x: 0, y: minimum },
|
||||
{ x: 1, y: maximum },
|
||||
],
|
||||
xDomain: [0, 1],
|
||||
yDomain: [minimum, maximum],
|
||||
}
|
||||
}
|
||||
|
||||
function track(seriesList: DisplaySeries[]): DisplayTrack {
|
||||
return {
|
||||
id: 'track',
|
||||
series: seriesList,
|
||||
xDomain: [0, 1],
|
||||
yDomain: [0, 50],
|
||||
}
|
||||
}
|
||||
|
||||
describe('multi-value Y-axis grouping', () => {
|
||||
it('keeps every overlaid series on one axis in single-axis mode', () => {
|
||||
const groups = buildYAxisSeriesGroups(
|
||||
track([series('a', 0, 1), series('b', 10, 20)]),
|
||||
'single-axis',
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(1)
|
||||
expect(groups[0]?.seriesList.map((item) => item.id)).toEqual(['a', 'b'])
|
||||
expect(groups[0]?.domain).toEqual([0, 50])
|
||||
})
|
||||
|
||||
it('uses the reference left-right axis order and merges overflow into axis four', () => {
|
||||
const groups = buildYAxisSeriesGroups(
|
||||
track([
|
||||
series('a', 0, 1),
|
||||
series('b', 10, 11),
|
||||
series('c', 20, 21),
|
||||
series('d', 30, 31),
|
||||
series('e', 40, 50),
|
||||
]),
|
||||
'multi-axis',
|
||||
)
|
||||
|
||||
expect(groups).toHaveLength(MAX_MULTI_Y_AXIS_COUNT)
|
||||
expect(groups.map((group) => group.side)).toEqual(['left', 'left', 'right', 'right'])
|
||||
expect(groups.map((group) => group.seriesList.map((item) => item.id))).toEqual([
|
||||
['a'],
|
||||
['b'],
|
||||
['c'],
|
||||
['d', 'e'],
|
||||
])
|
||||
expect(groups[3]?.domain[0]).toBeLessThanOrEqual(30)
|
||||
expect(groups[3]?.domain[1]).toBeGreaterThanOrEqual(50)
|
||||
})
|
||||
|
||||
it('derives merged multi-axis domains from precomputed series domains', () => {
|
||||
const first = series('a', -20, -10)
|
||||
const second = series('b', 40, 60)
|
||||
first.points = [{ x: 0, y: -15 }]
|
||||
second.points = [{ x: 0, y: 50 }]
|
||||
|
||||
const groups = buildYAxisSeriesGroups(
|
||||
track([
|
||||
series('left', 0, 1),
|
||||
series('middle', 10, 11),
|
||||
series('right', 20, 21),
|
||||
first,
|
||||
second,
|
||||
]),
|
||||
'multi-axis',
|
||||
)
|
||||
|
||||
expect(groups[3]?.domain[0]).toBeLessThanOrEqual(-20)
|
||||
expect(groups[3]?.domain[1]).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('places two and three axes on the expected sides', () => {
|
||||
const source = [series('a', 0, 1), series('b', 10, 11), series('c', 20, 21)]
|
||||
|
||||
expect(
|
||||
buildYAxisSeriesGroups(track(source.slice(0, 2)), 'multi-axis').map((g) => g.side),
|
||||
).toEqual(['left', 'right'])
|
||||
expect(buildYAxisSeriesGroups(track(source), 'multi-axis').map((g) => g.side)).toEqual([
|
||||
'left',
|
||||
'right',
|
||||
'right',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,149 @@
|
||||
import { line, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
||||
|
||||
import { selectRenderablePoints, type ResolvedWaveformRenderingOptions } from '../../core'
|
||||
import type { WaveformDisplayMode, WaveformPoint } from '../../types'
|
||||
import { buildMinorTicks, formatEndpointTime } from '../../utils'
|
||||
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
|
||||
import {
|
||||
buildMinorTicks,
|
||||
formatAxisTimeExponent,
|
||||
formatEndpointTime,
|
||||
formatScientificAxisExponent,
|
||||
formatScientificAxisLabel,
|
||||
paddedDomain,
|
||||
} from '../../utils'
|
||||
import {
|
||||
getBottomRowCellIndexes,
|
||||
type GridCellGeometry,
|
||||
type NormalizedWaveformGridOptions,
|
||||
} from './grid'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
||||
|
||||
export const MAX_MULTI_Y_AXIS_COUNT = 4
|
||||
const Y_AXIS_CHARACTER_WIDTH = 7
|
||||
const Y_AXIS_TICK_PADDING = 7
|
||||
const Y_AXIS_OUTER_PADDING = 4
|
||||
const Y_AXIS_LABEL_GAP = 6
|
||||
const Y_AXIS_LABEL_BAND_WIDTH = 24
|
||||
const Y_AXIS_EXPONENT_GAP = 4
|
||||
|
||||
interface YAxisSeriesGroup {
|
||||
index: number
|
||||
side: 'left' | 'right'
|
||||
seriesList: DisplaySeries[]
|
||||
domain: [number, number]
|
||||
}
|
||||
|
||||
function resolveAxisSides(axisCount: number): Array<'left' | 'right'> {
|
||||
if (axisCount >= 4) return ['left', 'left', 'right', 'right']
|
||||
if (axisCount === 3) return ['left', 'right', 'right']
|
||||
if (axisCount === 2) return ['left', 'right']
|
||||
return ['left']
|
||||
}
|
||||
|
||||
// 缓存 axis groups 计算结果,避免重复计算
|
||||
const yAxisGroupsCache = new WeakMap<DisplayTrack, Map<WaveformOverlayMode, YAxisSeriesGroup[]>>()
|
||||
|
||||
export function buildYAxisSeriesGroups(
|
||||
track: DisplayTrack,
|
||||
overlayMode: WaveformOverlayMode,
|
||||
): YAxisSeriesGroup[] {
|
||||
// 检查缓存
|
||||
let trackCache = yAxisGroupsCache.get(track)
|
||||
if (!trackCache) {
|
||||
trackCache = new Map()
|
||||
yAxisGroupsCache.set(track, trackCache)
|
||||
}
|
||||
|
||||
const cached = trackCache.get(overlayMode)
|
||||
if (cached) return cached
|
||||
const axisCount =
|
||||
overlayMode === 'multi-axis'
|
||||
? Math.min(track.series.length, MAX_MULTI_Y_AXIS_COUNT)
|
||||
: Math.min(track.series.length, 1)
|
||||
const sides = resolveAxisSides(axisCount)
|
||||
const grouped = Array.from({ length: axisCount }, (_, index) => ({
|
||||
index,
|
||||
side: sides[index],
|
||||
seriesList: [] as DisplaySeries[],
|
||||
domain: [0, 1] as [number, number],
|
||||
}))
|
||||
|
||||
track.series.forEach((series, index) => {
|
||||
grouped[Math.min(index, axisCount - 1)]?.seriesList.push(series)
|
||||
})
|
||||
grouped.forEach((group) => {
|
||||
if (overlayMode === 'single-axis') {
|
||||
group.domain = track.yDomain
|
||||
} else {
|
||||
const yDomainValues = group.seriesList.flatMap((series) => series.yDomain)
|
||||
group.domain = yDomainValues.length > 0 ? paddedDomain(yDomainValues) : track.yDomain
|
||||
}
|
||||
})
|
||||
|
||||
// 缓存结果
|
||||
trackCache.set(overlayMode, grouped)
|
||||
return grouped
|
||||
}
|
||||
|
||||
function axisTextMetrics(domain: [number, number]): {
|
||||
exponentLabel: string | null
|
||||
exponentWidth: number
|
||||
tickTextWidth: number
|
||||
} {
|
||||
const scale = scaleLinear(domain, [1, 0]).nice()
|
||||
const [axisMin, axisMax] = scale.domain()
|
||||
const values = scale.ticks(10)
|
||||
const maximumTickCharacters = Math.max(
|
||||
1,
|
||||
...values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax }).length),
|
||||
)
|
||||
const exponentLabel = formatScientificAxisExponent(axisMin, axisMax)
|
||||
return {
|
||||
exponentLabel,
|
||||
exponentWidth: exponentLabel ? exponentLabel.length * Y_AXIS_CHARACTER_WIDTH : 0,
|
||||
tickTextWidth: maximumTickCharacters * Y_AXIS_CHARACTER_WIDTH,
|
||||
}
|
||||
}
|
||||
|
||||
function axisExponentClearance(domain: [number, number]): number {
|
||||
const { exponentLabel, exponentWidth } = axisTextMetrics(domain)
|
||||
return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
}
|
||||
|
||||
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
|
||||
return (
|
||||
axisTextMetrics(group.domain).tickTextWidth +
|
||||
axisExponentClearance(group.domain) +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
)
|
||||
}
|
||||
|
||||
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
|
||||
return (
|
||||
axisTextMetrics(group.domain).tickTextWidth +
|
||||
axisExponentClearance(group.domain) +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
)
|
||||
}
|
||||
|
||||
export function measureTrackYAxisClearance(
|
||||
track: DisplayTrack,
|
||||
overlayMode: WaveformOverlayMode,
|
||||
): { left: number; right: number } {
|
||||
return buildYAxisSeriesGroups(track, overlayMode).reduce(
|
||||
(clearance, group) => {
|
||||
clearance[group.side] +=
|
||||
overlayMode === 'multi-axis' || track.series.length === 1
|
||||
? measureYAxisGroupClearance(group)
|
||||
: measureYAxisGroupTickClearance(group)
|
||||
return clearance
|
||||
},
|
||||
{ left: 0, right: 0 },
|
||||
)
|
||||
}
|
||||
|
||||
interface SeriesGridCell extends GridCellGeometry {
|
||||
series?: DisplayTrack
|
||||
@@ -18,6 +153,7 @@ export interface BuildTrackLayoutsOptions {
|
||||
cells: SeriesGridCell[]
|
||||
grid: NormalizedWaveformGridOptions
|
||||
displayMode: WaveformDisplayMode
|
||||
overlayMode: WaveformOverlayMode
|
||||
independentTransforms: ZoomTransform[]
|
||||
sharedZoomDomain: [number, number]
|
||||
timeUnit: 's' | 'ms'
|
||||
@@ -58,22 +194,67 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
? (options.independentTransforms[index] ?? zoomIdentity)
|
||||
: zoomIdentity
|
||||
const xScale = transform.rescaleX(baseXScale)
|
||||
const yScale = scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0]).nice()
|
||||
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode)
|
||||
const sideOffsets = { left: 0, right: 0 }
|
||||
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
|
||||
const scale = scaleLinear(group.domain, [cell.plotHeight, 0]).nice()
|
||||
const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
|
||||
const [axisStart, axisEnd] = scale.domain()
|
||||
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
|
||||
const visibleMajorTicks = showAxisEnd
|
||||
? majorTicks
|
||||
: majorTicks.filter((tick) => tick !== axisEnd)
|
||||
const tickValues = Array.from(
|
||||
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
|
||||
)
|
||||
const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics(group.domain)
|
||||
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
const clearance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
exponentClearance +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right
|
||||
const exponentX =
|
||||
x +
|
||||
(group.side === 'left'
|
||||
? -(Y_AXIS_TICK_PADDING + tickTextWidth + exponentClearance)
|
||||
: Y_AXIS_TICK_PADDING + tickTextWidth + exponentClearance)
|
||||
const labelDistance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
exponentClearance +
|
||||
exponentWidth +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH / 2
|
||||
const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
|
||||
sideOffsets[group.side] += clearance
|
||||
return {
|
||||
index: group.index,
|
||||
side: group.side,
|
||||
x,
|
||||
labelX,
|
||||
exponentX,
|
||||
exponentLabel,
|
||||
scale,
|
||||
majorTicks,
|
||||
minorTicks: buildMinorTicks(majorTicks),
|
||||
tickValues,
|
||||
seriesList: group.seriesList,
|
||||
}
|
||||
})
|
||||
const yScale = yAxes[0]?.scale ?? scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0]).nice()
|
||||
const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
|
||||
const yMajorTicks = yScale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
|
||||
const [yAxisStart, yAxisEnd] = yScale.domain()
|
||||
const showYAxisEnd = options.displayMode !== 'compact' || cell.row === 0
|
||||
const visibleYMajorTicks = showYAxisEnd
|
||||
? yMajorTicks
|
||||
: yMajorTicks.filter((tick) => tick !== yAxisEnd)
|
||||
const yAxisTickValues = Array.from(
|
||||
new Set([yAxisStart, ...visibleYMajorTicks, ...(showYAxisEnd ? [yAxisEnd] : [])]),
|
||||
)
|
||||
const yMajorTicks = yAxes[0]?.majorTicks ?? []
|
||||
const yAxisTickValues = yAxes[0]?.tickValues ?? []
|
||||
const domain = xScale.domain() as [number, number]
|
||||
const endpointLabels = {
|
||||
start: formatEndpointTime(domain[0], domain, options.timeUnit),
|
||||
end: formatEndpointTime(domain[1], domain, options.timeUnit),
|
||||
}
|
||||
const xAxisExponent = formatAxisTimeExponent(domain, options.timeUnit)
|
||||
const leftClearance = endpointLabels.start.length * 7 + 10
|
||||
const rightClearance = endpointLabels.end.length * 7 + 10
|
||||
const xAxisTickValues = xMajorTicks.filter((tick) => {
|
||||
@@ -81,6 +262,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
return position > leftClearance && position < cell.width - rightClearance
|
||||
})
|
||||
const seriesPaths = displayTrack.series.map((trackSeries) => {
|
||||
const yAxis = yAxes.find((axis) =>
|
||||
axis.seriesList.some((series) => series.id === trackSeries.id),
|
||||
)
|
||||
const seriesYScale = yAxis?.scale ?? yScale
|
||||
const renderPoints = selectRenderablePoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
@@ -93,7 +278,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
? null
|
||||
: line<WaveformPoint>()
|
||||
.x((point) => xScale(point.x))
|
||||
.y((point) => yScale(point.y))(renderPoints),
|
||||
.y((point) => seriesYScale(point.y))(renderPoints),
|
||||
yScale: seriesYScale,
|
||||
yAxisIndex: yAxis?.index ?? 0,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -111,13 +298,15 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
height: cell.plotHeight,
|
||||
xScale,
|
||||
yScale,
|
||||
yAxes,
|
||||
xMajorTicks,
|
||||
xMinorTicks: buildMinorTicks(xMajorTicks, 5, domain),
|
||||
yMajorTicks,
|
||||
yMinorTicks: buildMinorTicks(yMajorTicks),
|
||||
yMinorTicks: yAxes[0]?.minorTicks ?? [],
|
||||
yAxisTickValues,
|
||||
xAxisTickValues,
|
||||
endpointLabels,
|
||||
xAxisExponent,
|
||||
path: seriesPaths[0]?.path ?? null,
|
||||
seriesPaths,
|
||||
showXAxis:
|
||||
|
||||
@@ -60,10 +60,8 @@ export function calculateRotatedTitleLayout({
|
||||
}
|
||||
|
||||
const maximumVisualHeight = TITLE_AREA_MAX_HEIGHT - TITLE_AREA_VERTICAL_PADDING
|
||||
const naturalVisualWidth =
|
||||
safeNaturalWidth * absoluteCosine + safeNaturalHeight * absoluteSine
|
||||
const naturalVisualHeight =
|
||||
safeNaturalWidth * absoluteSine + safeNaturalHeight * absoluteCosine
|
||||
const naturalVisualWidth = safeNaturalWidth * absoluteCosine + safeNaturalHeight * absoluteSine
|
||||
const naturalVisualHeight = safeNaturalWidth * absoluteSine + safeNaturalHeight * absoluteCosine
|
||||
const scale = Math.min(
|
||||
1,
|
||||
safeAvailableWidth / naturalVisualWidth,
|
||||
|
||||
@@ -25,6 +25,22 @@ export interface DisplayTrack {
|
||||
export interface TrackSeriesPath {
|
||||
series: DisplaySeries
|
||||
path: string | null
|
||||
yScale: ScaleLinear<number, number>
|
||||
yAxisIndex: number
|
||||
}
|
||||
|
||||
export interface WaveformYAxisLayout {
|
||||
index: number
|
||||
side: 'left' | 'right'
|
||||
x: number
|
||||
labelX: number
|
||||
exponentX: number
|
||||
exponentLabel: string | null
|
||||
scale: ScaleLinear<number, number>
|
||||
majorTicks: number[]
|
||||
minorTicks: number[]
|
||||
tickValues: number[]
|
||||
seriesList: DisplaySeries[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,6 +68,7 @@ export interface TrackLayout {
|
||||
height: number
|
||||
xScale: ScaleLinear<number, number>
|
||||
yScale: ScaleLinear<number, number>
|
||||
yAxes: WaveformYAxisLayout[]
|
||||
xMajorTicks: number[]
|
||||
xMinorTicks: number[]
|
||||
yMajorTicks: number[]
|
||||
@@ -59,6 +76,7 @@ export interface TrackLayout {
|
||||
yAxisTickValues: number[]
|
||||
xAxisTickValues: number[]
|
||||
endpointLabels: { start: string; end: string }
|
||||
xAxisExponent: string | null
|
||||
path: string | null
|
||||
seriesPaths: TrackSeriesPath[]
|
||||
showXAxis: boolean
|
||||
|
||||
@@ -34,10 +34,7 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie
|
||||
}))
|
||||
}
|
||||
|
||||
export function usePreparedWaveformSeries(
|
||||
data: () => WaveformData,
|
||||
onDataChange: () => void,
|
||||
) {
|
||||
export function usePreparedWaveformSeries(data: () => WaveformData, onDataChange: () => void) {
|
||||
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(prepareWaveformSeries(data()))
|
||||
watch(data, (nextData) => {
|
||||
preparedSeries.value = prepareWaveformSeries(nextData)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export type {
|
||||
WaveformPoint,
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
|
||||
@@ -5,6 +5,7 @@ export type {
|
||||
SingleWaveformData,
|
||||
WaveformData,
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { axisBottom, axisLeft, select } from 'd3'
|
||||
import { formatAxisTime, formatScientificYAxisLabel } from '../../utils'
|
||||
import { axisBottom, axisLeft, axisRight, select } from 'd3'
|
||||
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
|
||||
import type { WaveformFrameStyle } from '../../types'
|
||||
import type {
|
||||
WaveformDisplayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformLegendPosition,
|
||||
} from '../data/types'
|
||||
import type { DisplaySeries, HoveredSeriesPoint, TrackLayout } from '../core/types'
|
||||
import type {
|
||||
DisplaySeries,
|
||||
HoveredSeriesPoint,
|
||||
TrackLayout,
|
||||
WaveformYAxisLayout,
|
||||
} from '../core/types'
|
||||
import WaveformLegend from './WaveformLegend.vue'
|
||||
|
||||
interface Props {
|
||||
@@ -60,7 +65,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const xAxisElement = ref<SVGGElement>()
|
||||
const yAxisElement = ref<SVGGElement>()
|
||||
const yAxisElements = ref<SVGGElement[]>([])
|
||||
const resolvedFrameStyle = computed(() => {
|
||||
const borderWidth = props.frameStyle?.borderWidth
|
||||
return {
|
||||
@@ -78,6 +83,23 @@ function resolveYAxisLabel(series: DisplaySeries): string {
|
||||
return series.name.trim() || props.yLabel || ''
|
||||
}
|
||||
|
||||
function hasYAxisTitle(axis: WaveformYAxisLayout): boolean {
|
||||
const series = axis.seriesList[0]
|
||||
return Boolean(series && resolveYAxisLabel(series))
|
||||
}
|
||||
|
||||
function setYAxisElement(element: unknown, index: number) {
|
||||
if (element) yAxisElements.value[index] = element as SVGGElement
|
||||
}
|
||||
|
||||
function resolveHoveredYScale() {
|
||||
const seriesId = props.hoveredPoint?.id
|
||||
return (
|
||||
props.track.seriesPaths.find((seriesPath) => seriesPath.series.id === seriesId)?.yScale ??
|
||||
props.track.yScale
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该显示 Y 轴标签
|
||||
* 在紧凑模式下,当轨道高度太小时隐藏标签避免重叠
|
||||
@@ -101,7 +123,7 @@ function crosshairX(): number {
|
||||
|
||||
function crosshairY(): number {
|
||||
return props.hoveredPoint && props.hoveredPoint.trackIndex === props.track.index
|
||||
? props.track.yScale(props.hoveredPoint.point.y)
|
||||
? resolveHoveredYScale()(props.hoveredPoint.point.y)
|
||||
: 0
|
||||
}
|
||||
|
||||
@@ -114,35 +136,32 @@ function hasCrosshair(): boolean {
|
||||
}
|
||||
|
||||
function renderAxes() {
|
||||
if (yAxisElement.value) {
|
||||
const [axisMin, axisMax] = props.track.yScale.domain()
|
||||
const visibleTicks = props.track.yAxisTickValues ?? props.track.yMajorTicks
|
||||
const topTickValue = visibleTicks.reduce<number | undefined>((closestTick, tickValue) => {
|
||||
if (closestTick === undefined) return tickValue
|
||||
return Math.abs(tickValue - axisMax) < Math.abs(closestTick - axisMax)
|
||||
? tickValue
|
||||
: closestTick
|
||||
}, undefined)
|
||||
const yAxis = axisLeft(props.track.yScale)
|
||||
.tickFormat((value) =>
|
||||
formatScientificYAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
|
||||
)
|
||||
props.track.yAxes.forEach((axis, index) => {
|
||||
const element = yAxisElements.value[index]
|
||||
if (!element) return
|
||||
const [axisMin, axisMax] = axis.scale.domain()
|
||||
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
|
||||
.tickFormat((value) => formatScientificAxisLabel(Number(value), { axisMin, axisMax }))
|
||||
.tickSize(-4)
|
||||
.tickPadding(7)
|
||||
.tickSizeOuter(0)
|
||||
|
||||
if (props.track.yAxisTickValues) {
|
||||
yAxis.tickValues(props.track.yAxisTickValues)
|
||||
}
|
||||
yAxis.tickValues(axis.tickValues)
|
||||
|
||||
select(yAxisElement.value).call(yAxis)
|
||||
}
|
||||
select(element).call(yAxis)
|
||||
})
|
||||
|
||||
if (xAxisElement.value) {
|
||||
select(xAxisElement.value).call(
|
||||
axisBottom(props.track.xScale)
|
||||
.tickValues(props.track.xAxisTickValues)
|
||||
.tickFormat((value) => formatAxisTime(Number(value), props.timeUnit))
|
||||
.tickFormat((value) =>
|
||||
formatAxisTime(
|
||||
Number(value),
|
||||
props.timeUnit,
|
||||
props.track.xScale.domain() as [number, number],
|
||||
),
|
||||
)
|
||||
.tickSize(-4)
|
||||
.tickPadding(7)
|
||||
.tickSizeOuter(0),
|
||||
@@ -158,7 +177,7 @@ onMounted(async () => {
|
||||
watch(
|
||||
[
|
||||
() => props.track.xScale,
|
||||
() => props.track.yScale,
|
||||
() => props.track.yAxes,
|
||||
() => props.track.xAxisTickValues,
|
||||
() => props.track.yAxisTickValues,
|
||||
() => props.timeUnit,
|
||||
@@ -285,13 +304,41 @@ watch(
|
||||
{{ track.endpointLabels.end }}
|
||||
</text>
|
||||
</g>
|
||||
<text
|
||||
v-if="track.showXAxis && track.xAxisExponent"
|
||||
class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
|
||||
:x="track.width ?? innerWidth"
|
||||
:y="track.height + 27"
|
||||
text-anchor="end"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ track.xAxisExponent }}
|
||||
</text>
|
||||
|
||||
<!-- Y 轴 -->
|
||||
<g
|
||||
v-if="!track.isEmpty"
|
||||
ref="yAxisElement"
|
||||
v-for="axis in track.isEmpty ? [] : track.yAxes"
|
||||
:key="`y-axis-${track.index}-${axis.index}`"
|
||||
:ref="(element) => setYAxisElement(element, axis.index)"
|
||||
class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y"
|
||||
:class="`waveform-track__axis--${axis.side}`"
|
||||
:data-y-axis-index="axis.index"
|
||||
:data-y-axis-side="axis.side"
|
||||
:transform="`translate(${axis.x}, 0)`"
|
||||
/>
|
||||
<text
|
||||
v-for="axis in track.isEmpty ? [] : track.yAxes.filter((item) => item.exponentLabel)"
|
||||
:key="`y-axis-exponent-${track.index}-${axis.index}`"
|
||||
class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
|
||||
:data-y-axis-index="axis.index"
|
||||
:x="axis.exponentX"
|
||||
y="0"
|
||||
dy="0.32em"
|
||||
:text-anchor="axis.side === 'left' ? 'end' : 'start'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ axis.exponentLabel }}
|
||||
</text>
|
||||
|
||||
<!-- Y 轴标签 -->
|
||||
<g
|
||||
@@ -322,6 +369,31 @@ watch(
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<g
|
||||
v-for="axis in track.yAxes.length > 1 ? track.yAxes.filter(hasYAxisTitle) : []"
|
||||
:key="`y-axis-title-${track.index}-${axis.index}`"
|
||||
class="waveform-track__multi-axis-title"
|
||||
:data-y-axis-title-index="axis.index"
|
||||
>
|
||||
<rect
|
||||
class="waveform-track__y-axis-label-bg waveform-chart__y-axis-label-bg"
|
||||
:x="axis.labelX - 12"
|
||||
:y="track.height / 2 - 40"
|
||||
width="24"
|
||||
height="80"
|
||||
rx="2"
|
||||
/>
|
||||
<text
|
||||
class="waveform-track__y-axis-label waveform-chart__y-axis-label"
|
||||
:fill="axis.seriesList[0].color"
|
||||
:transform="`translate(${axis.labelX}, ${track.height / 2}) rotate(-90)`"
|
||||
text-anchor="middle"
|
||||
dominant-baseline="central"
|
||||
>
|
||||
{{ resolveYAxisLabel(axis.seriesList[0]) }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- 轨道边框 -->
|
||||
<rect
|
||||
v-if="!track.isEmpty"
|
||||
@@ -343,6 +415,7 @@ watch(
|
||||
class="waveform-track__line waveform-chart__line"
|
||||
:data-series-id="seriesPath.series.id"
|
||||
:data-series-name="seriesPath.series.name || undefined"
|
||||
:data-y-axis-index="seriesPath.yAxisIndex"
|
||||
:d="seriesPath.path ?? undefined"
|
||||
:stroke="seriesPath.series.color"
|
||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
||||
@@ -480,6 +553,12 @@ watch(
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-track__axis-exponent {
|
||||
fill: #666;
|
||||
font-family: sans-serif;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
:deep(.waveform-track__axis path),
|
||||
:deep(.waveform-track__axis line) {
|
||||
stroke: #1f2937;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export type {
|
||||
WaveformPoint,
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { SingleWaveformData, WaveformData, WaveformPoint, NormalizedWaveformSeries } from '../types'
|
||||
import type {
|
||||
SingleWaveformData,
|
||||
WaveformData,
|
||||
WaveformPoint,
|
||||
NormalizedWaveformSeries,
|
||||
} from '../types'
|
||||
|
||||
/**
|
||||
* 规范化单波形数据
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {
|
||||
// 图表类型
|
||||
WaveformPoint,
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface WaveformPoint {
|
||||
*/
|
||||
export type WaveformDisplayMode = 'independent' | 'separated' | 'compact'
|
||||
|
||||
/** Controls whether overlaid series share one Y axis or use up to four value axes. */
|
||||
export type WaveformOverlayMode = 'single-axis' | 'multi-axis'
|
||||
|
||||
/** 标注工具模式 */
|
||||
export type WaveformInteractionMode = 'zoom' | 'annotation'
|
||||
|
||||
@@ -67,14 +70,7 @@ export interface WaveformTitleOptions {
|
||||
|
||||
/** Preset positions for legends rendered inside multi-series tracks. */
|
||||
export type WaveformLegendPosition =
|
||||
| 'top-left'
|
||||
| 'top'
|
||||
| 'top-right'
|
||||
| 'right'
|
||||
| 'bottom-right'
|
||||
| 'bottom'
|
||||
| 'bottom-left'
|
||||
| 'left'
|
||||
'top-left' | 'top' | 'top-right' | 'right' | 'bottom-right' | 'bottom' | 'bottom-left' | 'left'
|
||||
|
||||
/** Controls whether legend items follow the position default or a fixed direction. */
|
||||
export type WaveformLegendOrientation = 'auto' | 'horizontal' | 'vertical'
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export type {
|
||||
WaveformPoint,
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
|
||||
@@ -19,8 +19,8 @@ describe('buildMinorTicks', () => {
|
||||
const ticks = buildMinorTicks([-7000, -6000, -5000], 5, [-7950, -4100])
|
||||
|
||||
expect(ticks).toEqual([
|
||||
-7800, -7600, -7400, -7200, -6800, -6600, -6400, -6200, -5800, -5600, -5400,
|
||||
-5200, -4800, -4600, -4400, -4200,
|
||||
-7800, -7600, -7400, -7200, -6800, -6600, -6400, -6200, -5800, -5600, -5400, -5200, -4800,
|
||||
-4600, -4400, -4200,
|
||||
])
|
||||
expect(ticks).not.toContain(-7950)
|
||||
expect(ticks).not.toContain(-4100)
|
||||
|
||||
@@ -39,7 +39,10 @@ export function buildMinorTicks(
|
||||
const nextValue = intervalBoundaries[index + 1]
|
||||
if (nextValue === undefined) return []
|
||||
const step = (nextValue - value) / subdivisions
|
||||
return Array.from({ length: subdivisions - 1 }, (_, minorIndex) => value + step * (minorIndex + 1))
|
||||
return Array.from(
|
||||
{ length: subdivisions - 1 },
|
||||
(_, minorIndex) => value + step * (minorIndex + 1),
|
||||
)
|
||||
})
|
||||
|
||||
if (!domain) return minorTicks
|
||||
|
||||
@@ -2,41 +2,64 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatAnnotationTime,
|
||||
formatAxisTime,
|
||||
formatAxisTimeExponent,
|
||||
formatEndpointTime,
|
||||
formatPlainNumber,
|
||||
formatScientificYAxisLabel,
|
||||
formatScientificAxisExponent,
|
||||
formatScientificAxisLabel,
|
||||
formatTooltipNumber,
|
||||
shouldUseScientificYAxisLabel,
|
||||
shouldUseScientificAxisLabel,
|
||||
} from './formatters'
|
||||
|
||||
describe('waveform number formatters', () => {
|
||||
it('uses the reference Y-axis scientific notation boundaries', () => {
|
||||
expect(shouldUseScientificYAxisLabel(0)).toBe(false)
|
||||
expect(shouldUseScientificYAxisLabel(0.009)).toBe(true)
|
||||
expect(shouldUseScientificYAxisLabel(0.01)).toBe(false)
|
||||
expect(shouldUseScientificYAxisLabel(99.99)).toBe(false)
|
||||
expect(shouldUseScientificYAxisLabel(100)).toBe(true)
|
||||
expect(shouldUseScientificAxisLabel(0)).toBe(false)
|
||||
expect(shouldUseScientificAxisLabel(0.009)).toBe(true)
|
||||
expect(shouldUseScientificAxisLabel(0.01)).toBe(false)
|
||||
expect(shouldUseScientificAxisLabel(99.99)).toBe(false)
|
||||
expect(shouldUseScientificAxisLabel(100)).toBe(true)
|
||||
})
|
||||
|
||||
it('shares one exponent and prefixes only the top visible tick', () => {
|
||||
const positiveAxis = { axisMin: 0, axisMax: 254, topTickValue: 254 }
|
||||
expect(formatScientificYAxisLabel(127, positiveAxis)).toBe('1.27')
|
||||
expect(formatScientificYAxisLabel(254, positiveAxis)).toBe('E+02 2.54')
|
||||
it('shares one separate exponent across an axis', () => {
|
||||
const positiveAxis = { axisMin: 0, axisMax: 254 }
|
||||
expect(formatScientificAxisLabel(127, positiveAxis)).toBe('1.27')
|
||||
expect(formatScientificAxisLabel(254, positiveAxis)).toBe('2.54')
|
||||
expect(formatScientificAxisExponent(0, 254)).toBe('E+02')
|
||||
expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120')
|
||||
|
||||
const tinyAxis = { axisMin: 0, axisMax: 0.0002, topTickValue: 0.0002 }
|
||||
expect(formatScientificYAxisLabel(0.0001, tinyAxis)).toBe('1.00')
|
||||
expect(formatScientificYAxisLabel(0.0002, tinyAxis)).toBe('E-04 2.00')
|
||||
const tinyAxis = { axisMin: 0, axisMax: 0.0002 }
|
||||
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1.00')
|
||||
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2.00')
|
||||
expect(formatScientificAxisExponent(0, 0.0002)).toBe('E-04')
|
||||
|
||||
const negativeAxis = { axisMin: -254, axisMax: 0, topTickValue: 0 }
|
||||
expect(formatScientificYAxisLabel(-254, negativeAxis)).toBe('-2.54')
|
||||
expect(formatScientificYAxisLabel(0, negativeAxis)).toBe('E+02 0.00')
|
||||
const negativeAxis = { axisMin: -254, axisMax: 0 }
|
||||
expect(formatScientificAxisLabel(-254, negativeAxis)).toBe('-2.54')
|
||||
expect(formatScientificAxisLabel(0, negativeAxis)).toBe('0.00')
|
||||
expect(formatScientificAxisExponent(-254, 0)).toBe('E+02')
|
||||
})
|
||||
|
||||
it('keeps plain axes at two decimals and removes negative zero', () => {
|
||||
expect(formatScientificYAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99')
|
||||
expect(formatScientificYAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01')
|
||||
expect(formatScientificYAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00')
|
||||
expect(formatScientificYAxisLabel(Number.NaN)).toBe('NaN')
|
||||
expect(formatScientificYAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
|
||||
expect(formatScientificAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99')
|
||||
expect(formatScientificAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01')
|
||||
expect(formatScientificAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00')
|
||||
expect(formatScientificAxisLabel(Number.NaN)).toBe('NaN')
|
||||
expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
|
||||
expect(formatScientificAxisExponent(0, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('formats X-axis ticks and endpoints from the selected display unit', () => {
|
||||
const domain: [number, number] = [0, 1]
|
||||
expect(formatAxisTime(0.5, 'ms', domain)).toBe('0.50')
|
||||
expect(formatEndpointTime(1, domain, 'ms')).toBe('1.00')
|
||||
expect(formatAxisTimeExponent(domain, 'ms')).toBe('E+03')
|
||||
expect(formatAxisTime(0.5, 's', domain)).toBe('0.50')
|
||||
expect(formatEndpointTime(1, domain, 's')).toBe('1.00')
|
||||
expect(formatAxisTimeExponent(domain, 's')).toBeNull()
|
||||
|
||||
const tinyDomain: [number, number] = [0, 0.000001]
|
||||
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('1.00')
|
||||
expect(formatAxisTimeExponent(tinyDomain, 's')).toBe('E-06')
|
||||
})
|
||||
|
||||
it('formats tooltip and raw values for their display contexts', () => {
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
*/
|
||||
export type TimeUnit = 'ms' | 's'
|
||||
|
||||
export interface ScientificYAxisLabelOptions {
|
||||
export interface ScientificAxisLabelOptions {
|
||||
precision?: number
|
||||
axisMin?: number
|
||||
axisMax?: number
|
||||
}
|
||||
|
||||
/** @deprecated Use ScientificAxisLabelOptions. */
|
||||
export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions & {
|
||||
topTickValue?: number
|
||||
}
|
||||
|
||||
@@ -23,7 +27,7 @@ function formatFixedNumber(value: number, precision: number): string {
|
||||
}
|
||||
|
||||
/** Whether an axis magnitude should use one shared scientific exponent. */
|
||||
export function shouldUseScientificYAxisLabel(maxAbsoluteValue: number): boolean {
|
||||
export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean {
|
||||
return (
|
||||
Number.isFinite(maxAbsoluteValue) &&
|
||||
(maxAbsoluteValue >= SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE ||
|
||||
@@ -31,11 +35,11 @@ export function shouldUseScientificYAxisLabel(maxAbsoluteValue: number): boolean
|
||||
)
|
||||
}
|
||||
|
||||
function resolveScientificExponent(axisMin?: number, axisMax?: number): number | null {
|
||||
export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
|
||||
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
|
||||
|
||||
const maxAbsoluteValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
|
||||
return shouldUseScientificYAxisLabel(maxAbsoluteValue)
|
||||
return shouldUseScientificAxisLabel(maxAbsoluteValue)
|
||||
? Math.floor(Math.log10(maxAbsoluteValue))
|
||||
: null
|
||||
}
|
||||
@@ -46,20 +50,33 @@ function formatExponent(exponent: number): string {
|
||||
}
|
||||
|
||||
/** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */
|
||||
export function formatScientificYAxisLabel(
|
||||
export function formatScientificAxisLabel(
|
||||
value: number,
|
||||
options: ScientificYAxisLabelOptions = {},
|
||||
options: ScientificAxisLabelOptions = {},
|
||||
): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
|
||||
const precision = options.precision ?? DEFAULT_Y_AXIS_PRECISION
|
||||
const exponent = resolveScientificExponent(options.axisMin, options.axisMax)
|
||||
const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax)
|
||||
const scaledValue = exponent === null ? value : value / 10 ** exponent
|
||||
const formattedValue = formatFixedNumber(scaledValue, precision)
|
||||
return formatFixedNumber(scaledValue, precision)
|
||||
}
|
||||
|
||||
return exponent !== null && value === options.topTickValue
|
||||
? `${formatExponent(exponent)} ${formattedValue}`
|
||||
: formattedValue
|
||||
/** Return the shared E-style multiplier for an axis, or null for a plain axis. */
|
||||
export function formatScientificAxisExponent(axisMin?: number, axisMax?: number): string | null {
|
||||
const exponent = resolveScientificAxisExponent(axisMin, axisMax)
|
||||
return exponent === null ? null : formatExponent(exponent)
|
||||
}
|
||||
|
||||
/** @deprecated Use shouldUseScientificAxisLabel. */
|
||||
export const shouldUseScientificYAxisLabel = shouldUseScientificAxisLabel
|
||||
|
||||
/** @deprecated Use formatScientificAxisLabel. */
|
||||
export function formatScientificYAxisLabel(
|
||||
value: number,
|
||||
options: ScientificYAxisLabelOptions = {},
|
||||
): string {
|
||||
return formatScientificAxisLabel(value, options)
|
||||
}
|
||||
|
||||
/** Format tooltip values as localized plain numbers with at most four decimal places. */
|
||||
@@ -117,7 +134,7 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化端点时间(动态精度,本地化格式)
|
||||
* 按完整 X 轴显示域格式化端点时间
|
||||
* @param value 时间值(秒)
|
||||
* @param domain 数据域
|
||||
* @param timeUnit 时间单位
|
||||
@@ -128,36 +145,45 @@ export function formatEndpointTime(
|
||||
domain: [number, number],
|
||||
timeUnit: TimeUnit,
|
||||
): string {
|
||||
const displayValue = displayTime(value, timeUnit)
|
||||
const digits = endpointFractionDigits(domain, timeUnit)
|
||||
|
||||
// 如果是整数值且计算出的小数位数会导致显示小数,则强制为0
|
||||
if (displayValue === Math.floor(displayValue) && digits > 0) {
|
||||
return displayValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return displayValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
const [axisMin, axisMax] = domain.map((domainValue) => displayTime(domainValue, timeUnit)) as [
|
||||
number,
|
||||
number,
|
||||
]
|
||||
return formatScientificAxisLabel(displayTime(value, timeUnit), {
|
||||
axisMin,
|
||||
axisMax,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化坐标轴时间(整数,本地化格式)
|
||||
* 按完整 X 轴显示域格式化时间刻度
|
||||
* @param value 时间值(秒)
|
||||
* @param timeUnit 时间单位
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function formatAxisTime(value: number, timeUnit: TimeUnit): string {
|
||||
export function formatAxisTime(
|
||||
value: number,
|
||||
timeUnit: TimeUnit,
|
||||
domain?: [number, number],
|
||||
): string {
|
||||
const displayValue = displayTime(value, timeUnit)
|
||||
return displayValue.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 0,
|
||||
const displayDomain = domain?.map((domainValue) => displayTime(domainValue, timeUnit)) as
|
||||
[number, number] | undefined
|
||||
return formatScientificAxisLabel(displayValue, {
|
||||
axisMin: displayDomain?.[0],
|
||||
axisMax: displayDomain?.[1],
|
||||
})
|
||||
}
|
||||
|
||||
/** Return the shared multiplier for an X-axis domain in its selected display unit. */
|
||||
export function formatAxisTimeExponent(
|
||||
domain: [number, number],
|
||||
timeUnit: TimeUnit,
|
||||
): string | null {
|
||||
const [axisMin, axisMax] = domain.map((value) => displayTime(value, timeUnit)) as [number, number]
|
||||
return formatScientificAxisExponent(axisMin, axisMax)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化悬浮提示时间(4 位小数,本地化格式)
|
||||
* @param value 时间值(秒)
|
||||
|
||||
@@ -11,12 +11,18 @@ export {
|
||||
endpointFractionDigits,
|
||||
formatEndpointTime,
|
||||
formatAxisTime,
|
||||
formatAxisTimeExponent,
|
||||
formatTooltipTime,
|
||||
formatAnnotationTime,
|
||||
formatPlainNumber,
|
||||
formatScientificAxisLabel,
|
||||
formatScientificAxisExponent,
|
||||
formatScientificYAxisLabel,
|
||||
formatTooltipNumber,
|
||||
resolveScientificAxisExponent,
|
||||
shouldUseScientificAxisLabel,
|
||||
shouldUseScientificYAxisLabel,
|
||||
type ScientificAxisLabelOptions,
|
||||
type ScientificYAxisLabelOptions,
|
||||
type TimeUnit,
|
||||
} from './formatters'
|
||||
|
||||
Reference in New Issue
Block a user