feat(chart): add zero line and clean view
This commit is contained in:
40
README.md
40
README.md
@@ -39,9 +39,9 @@ pnpm add waveform-analysis vue d3 ant-design-vue vue3-colorpicker
|
||||
|
||||
组件库支持以下运行时版本:
|
||||
|
||||
| 依赖 | 支持版本 |
|
||||
| --- | --- |
|
||||
| Vue | `>=3.2.33 <4` |
|
||||
| 依赖 | 支持版本 |
|
||||
| -------------- | ------------- |
|
||||
| Vue | `>=3.2.33 <4` |
|
||||
| Ant Design Vue | `>=3.2.20 <4` |
|
||||
|
||||
安装时请确保业务项目中的 Vue 与 Ant Design Vue 版本满足上述范围。
|
||||
@@ -104,12 +104,15 @@ const data = ref<WaveformData>({
|
||||
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
|
||||
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
||||
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
|
||||
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
|
||||
| `cleanView` | `boolean` | `false` | 仅保留波形的净图模式 |
|
||||
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
|
||||
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
|
||||
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
|
||||
|
||||
所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、
|
||||
`WaveformAnnotation`、`WaveformRenderingOptions` 和 `WaveformGridOptions`。
|
||||
`WaveformAnnotation`、`WaveformRenderingOptions`、`WaveformZeroLineOptions` 和
|
||||
`WaveformGridOptions`。
|
||||
|
||||
### 数据结构
|
||||
|
||||
@@ -381,6 +384,35 @@ const hiddenSeriesIds = ref<string[]>([])
|
||||
显隐状态以规范化后的 `series.id` 为键。要在数据刷新和重新排序后稳定保留状态,每个系列都应
|
||||
提供全图唯一且稳定的显式 `id`;自动生成的索引 ID 或重复 ID 添加的后缀不保证跨排序稳定。
|
||||
|
||||
### 零值参考线与净图
|
||||
|
||||
`zeroLine` 用于绘制 `y = 0` 的水平参考线,默认隐藏。参考线只在对应 Y 轴的当前 domain
|
||||
包含 0 时渲染,不会为了显示参考线而扩展数据范围。多值轴模式下,每根可见 Y 轴分别按自身
|
||||
scale 定位零线:
|
||||
|
||||
```vue
|
||||
<WaveformChart
|
||||
:data="chartData"
|
||||
:zero-line="{
|
||||
visible: true,
|
||||
color: '#98a2b3',
|
||||
width: 1,
|
||||
dash: '6 4',
|
||||
}"
|
||||
/>
|
||||
```
|
||||
|
||||
`dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的
|
||||
`width` 会回退到 `1`。
|
||||
|
||||
设置 `cleanView` 后,组件隐藏标题、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印、
|
||||
零值参考线、标注和分页器,并取消这些元素预留的边距,仅保留波形数据层。缩放、悬浮、十字线和
|
||||
tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失:
|
||||
|
||||
```vue
|
||||
<WaveformChart :data="chartData" :clean-view="cleanViewEnabled" />
|
||||
```
|
||||
|
||||
### 网格、分页与交互模式
|
||||
|
||||
`grid` 控制独立图框的行列数(范围 `1–10`)以及是否显示分页器。默认值为 `2` 行、
|
||||
|
||||
@@ -41,6 +41,12 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
|
||||
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(panel.find('[aria-label="净图模式"]').exists()).toBe(true)
|
||||
expect(panel.find('[aria-label="显示零值参考线"]').exists()).toBe(true)
|
||||
const zeroLineControls = panel.get('.zero-line-controls')
|
||||
expect(zeroLineControls.findAllComponents(ColorPicker)).toHaveLength(1)
|
||||
expect(zeroLineControls.find('[aria-label="零值参考线线宽"]').exists()).toBe(true)
|
||||
expect(zeroLineControls.find('[aria-label="零值参考线线型"]').exists()).toBe(true)
|
||||
expect(frameControls.findAllComponents(ColorPicker)).toHaveLength(2)
|
||||
expect(frameControls.text()).toContain('边框颜色')
|
||||
expect(frameControls.text()).toContain('背景颜色')
|
||||
@@ -71,6 +77,23 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('passes clean view and zero-line controls to the chart', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const chart = wrapper.getComponent(WaveformChart)
|
||||
|
||||
expect(chart.props('cleanView')).toBe(false)
|
||||
expect(chart.props('zeroLine')).toMatchObject({ visible: false, color: '#98a2b3', width: 1 })
|
||||
|
||||
await wrapper.get('[aria-label="净图模式"]').trigger('click')
|
||||
await wrapper.get('[aria-label="显示零值参考线"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(chart.props('cleanView')).toBe(true)
|
||||
expect(chart.props('zeroLine')).toMatchObject({ visible: true, color: '#98a2b3', width: 1 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('switches overlaid tracks between single-axis and multi-axis rendering', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
|
||||
66
src/App.vue
66
src/App.vue
@@ -17,6 +17,7 @@ import {
|
||||
type WaveformSeries,
|
||||
type WaveformTitleOptions,
|
||||
type WaveformZoomEndPayload,
|
||||
type WaveformZeroLineOptions,
|
||||
} from './components'
|
||||
import chartWaveformsJson from './data/chartWaveforms.json'
|
||||
import demoWaveformsJson from './data/demoWaveforms.json'
|
||||
@@ -53,6 +54,11 @@ const frameBackgroundColor = ref('rgba(255, 255, 255, 0)')
|
||||
const frameWatermarkVisible = ref(true)
|
||||
const annotations = ref<WaveformAnnotation[]>([])
|
||||
const annotationsVisible = ref(true)
|
||||
const cleanView = ref(false)
|
||||
const zeroLineVisible = ref(false)
|
||||
const zeroLineColor = ref('#98a2b3')
|
||||
const zeroLineWidth = ref(1)
|
||||
const zeroLineDash = ref('6 4')
|
||||
const interactionMode = ref<WaveformInteractionMode>('zoom')
|
||||
const legendPosition = ref<WaveformLegendPosition>('top-right')
|
||||
const legendOrientation = ref<WaveformLegendOrientation>('auto')
|
||||
@@ -88,6 +94,11 @@ const frameBorderStyleOptions = [
|
||||
{ label: '实线', value: 'solid' },
|
||||
{ label: '虚线', value: 'dashed' },
|
||||
]
|
||||
const zeroLineDashOptions = [
|
||||
{ label: '虚线', value: '6 4' },
|
||||
{ label: '点划线', value: '2 3' },
|
||||
{ label: '实线', value: '' },
|
||||
]
|
||||
const titleAlignOptions: Array<{
|
||||
label: string
|
||||
value: NonNullable<WaveformTitleOptions['align']>
|
||||
@@ -109,6 +120,12 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
|
||||
borderStyle: frameBorderStyle.value,
|
||||
backgroundColor: frameBackgroundColor.value,
|
||||
}))
|
||||
const zeroLine = computed<WaveformZeroLineOptions>(() => ({
|
||||
visible: zeroLineVisible.value,
|
||||
color: zeroLineColor.value,
|
||||
width: zeroLineWidth.value,
|
||||
dash: zeroLineDash.value,
|
||||
}))
|
||||
|
||||
const seriesStylePresets: Array<Pick<WaveformSeries, 'lineType' | 'pointType' | 'errorBar'>> = [
|
||||
{ lineType: 'none', pointType: 'triangle', errorBar: { visible: true } },
|
||||
@@ -353,6 +370,53 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
||||
<section class="control-section">
|
||||
<h2>视图</h2>
|
||||
<Button block aria-label="重置波形视图" @click="resetWaveformViewport">重置视图</Button>
|
||||
<div class="auxiliary-style-controls" style="margin-top: 10px">
|
||||
<label class="frame-style-control frame-style-control--switch">
|
||||
<span>净图</span>
|
||||
<Switch v-model:checked="cleanView" size="small" aria-label="净图模式" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
<div class="control-section__header">
|
||||
<h2>零值参考线</h2>
|
||||
<Switch v-model:checked="zeroLineVisible" size="small" aria-label="显示零值参考线" />
|
||||
</div>
|
||||
<div class="auxiliary-style-controls zero-line-controls" style="margin-top: 10px">
|
||||
<label class="frame-style-control">
|
||||
<span>颜色</span>
|
||||
<ColorPicker
|
||||
v-model:pure-color="zeroLineColor"
|
||||
aria-label="零值参考线颜色"
|
||||
use-type="pure"
|
||||
picker-type="chrome"
|
||||
format="hex"
|
||||
:disable-alpha="true"
|
||||
:blur-close="true"
|
||||
/>
|
||||
</label>
|
||||
<label class="frame-style-control">
|
||||
<span>线宽</span>
|
||||
<InputNumber
|
||||
v-model:value="zeroLineWidth"
|
||||
:min="0.5"
|
||||
:max="10"
|
||||
:step="0.5"
|
||||
size="small"
|
||||
aria-label="零值参考线线宽"
|
||||
/>
|
||||
</label>
|
||||
<label class="frame-style-control">
|
||||
<span>线型</span>
|
||||
<Select
|
||||
v-model:value="zeroLineDash"
|
||||
:options="zeroLineDashOptions"
|
||||
size="small"
|
||||
aria-label="零值参考线线型"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="control-section">
|
||||
@@ -602,6 +666,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
||||
interactive: true,
|
||||
}"
|
||||
:frame-style="frameStyle"
|
||||
:clean-view="cleanView"
|
||||
:zero-line="zeroLine"
|
||||
:frame-number="frameWatermarkVisible ? 1 : undefined"
|
||||
v-model:annotations="annotations"
|
||||
v-model:annotations-visible="annotationsVisible"
|
||||
|
||||
@@ -220,6 +220,152 @@ describe('WaveformChart', () => {
|
||||
second.unmount()
|
||||
})
|
||||
|
||||
it('renders a configurable zero line only when the Y domain contains zero', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: -2 },
|
||||
{ x: 1, y: 4 },
|
||||
],
|
||||
},
|
||||
{ zeroLine: { visible: true, color: '#475467', width: 2, dash: '3 2' } },
|
||||
)
|
||||
|
||||
const zeroLine = wrapper.get('.waveform-chart__zero-line')
|
||||
expect(zeroLine.attributes()).toMatchObject({
|
||||
stroke: '#475467',
|
||||
'stroke-width': '2',
|
||||
'stroke-dasharray': '3 2',
|
||||
'data-y-axis-index': '0',
|
||||
})
|
||||
expect(zeroLine.attributes('y1')).toBe(zeroLine.attributes('y2'))
|
||||
|
||||
await wrapper.setProps({ zeroLine: { visible: false } })
|
||||
expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false)
|
||||
|
||||
const positive = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 1, y: 4 },
|
||||
],
|
||||
},
|
||||
{ zeroLine: { visible: true } },
|
||||
)
|
||||
expect(positive.find('.waveform-chart__zero-line').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders zero lines from each visible Y axis in multi-axis mode', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'small',
|
||||
trackId: 'overlay',
|
||||
name: 'small',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: -1 },
|
||||
{ x: 1, y: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'large',
|
||||
trackId: 'overlay',
|
||||
name: 'large',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: -10 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ overlayMode: 'multi-axis', zeroLine: { visible: true } },
|
||||
)
|
||||
|
||||
const zeroLines = wrapper.findAll('.waveform-chart__zero-line')
|
||||
expect(zeroLines).toHaveLength(2)
|
||||
expect(zeroLines.map((line) => line.attributes('data-y-axis-index'))).toEqual(['0', '1'])
|
||||
expect(zeroLines[0].attributes('y1')).not.toBe(zeroLines[1].attributes('y1'))
|
||||
})
|
||||
|
||||
it('uses the full drawing area and hides auxiliary layers in clean view', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'first',
|
||||
trackId: 'overlay',
|
||||
name: 'first',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: -1 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
trackId: 'overlay',
|
||||
name: 'second',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'third',
|
||||
name: 'third',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 1, y: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
cleanView: true,
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: true },
|
||||
title: { text: 'hidden title' },
|
||||
frameNumber: 1,
|
||||
annotations: [{ id: 'note', seriesId: 'first', x: 0.5, y: 0, text: 'hidden note' }],
|
||||
zeroLine: { visible: true },
|
||||
},
|
||||
)
|
||||
|
||||
expect(wrapper.get('.waveform-chart').attributes('data-chart-left-margin')).toBe('0')
|
||||
expect(wrapper.get('.waveform-chart__track').attributes('data-track-height')).toBe('360')
|
||||
expect(wrapper.findAll('.waveform-chart__series')).toHaveLength(2)
|
||||
expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true)
|
||||
expect(wrapper.find('.waveform-chart__title-area').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__axis').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__plot-background').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__watermark').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__legend-layer').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__label').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false)
|
||||
expect(wrapper.find('.ant-pagination').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('places start, middle, and end step transitions at the expected X positions', async () => {
|
||||
const lineTypes = ['step-start', 'step-middle', 'step-end', 'step-after'] as const
|
||||
const wrapper = await mountSizedChart({
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type WaveformPoint,
|
||||
type WaveformRenderingOptions,
|
||||
type WaveformTitleOptions,
|
||||
type WaveformZeroLineOptions,
|
||||
type WaveformZoomEndPayload,
|
||||
} from './data/types'
|
||||
import {
|
||||
@@ -109,6 +110,8 @@ const props = withDefaults(
|
||||
legend?: WaveformLegendOptions
|
||||
hiddenSeriesIds?: string[]
|
||||
defaultHiddenSeriesIds?: string[]
|
||||
cleanView?: boolean
|
||||
zeroLine?: WaveformZeroLineOptions
|
||||
}>(),
|
||||
{
|
||||
displayMode: 'independent',
|
||||
@@ -128,6 +131,8 @@ const props = withDefaults(
|
||||
rendering: () => ({}),
|
||||
legend: () => ({ position: 'top-right', orientation: 'auto' }),
|
||||
defaultHiddenSeriesIds: () => [],
|
||||
cleanView: false,
|
||||
zeroLine: () => ({ visible: false }),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -255,6 +260,16 @@ const containerStyle = computed(() => ({
|
||||
height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`,
|
||||
}))
|
||||
const legendPosition = computed<WaveformLegendPosition>(() => props.legend?.position ?? 'top-right')
|
||||
const isCleanView = computed(() => props.cleanView === true)
|
||||
const resolvedZeroLine = computed(() => {
|
||||
const width = props.zeroLine?.width
|
||||
return {
|
||||
visible: props.zeroLine?.visible === true,
|
||||
color: props.zeroLine?.color || '#98a2b3',
|
||||
width: typeof width === 'number' && Number.isFinite(width) && width > 0 ? width : 1,
|
||||
dash: props.zeroLine?.dash ?? '6 4',
|
||||
}
|
||||
})
|
||||
const legendBackgroundColor = computed(
|
||||
() => props.legend?.backgroundColor || 'rgba(255, 255, 255, 0.7)',
|
||||
)
|
||||
@@ -275,7 +290,10 @@ const legendOrientation = computed<Exclude<WaveformLegendOrientation, 'auto'>>((
|
||||
const resolvedTitleText = computed(() => props.title?.text.trim() ?? '')
|
||||
const titleVisible = computed(
|
||||
() =>
|
||||
Boolean(props.title) && props.title?.visible !== false && resolvedTitleText.value.length > 0,
|
||||
!isCleanView.value &&
|
||||
Boolean(props.title) &&
|
||||
props.title?.visible !== false &&
|
||||
resolvedTitleText.value.length > 0,
|
||||
)
|
||||
const titleFontSize = computed(() => {
|
||||
const fontSize = props.title?.textStyle?.fontSize
|
||||
@@ -326,8 +344,11 @@ const titleLayout = computed(() =>
|
||||
}),
|
||||
)
|
||||
const titleAreaHeight = computed(() => (titleVisible.value ? titleLayout.value.areaHeight : 0))
|
||||
const chartTopMargin = computed(() => (isCleanView.value ? 0 : margin.top))
|
||||
const drawingHeight = computed(() => Math.max(0, chartHeight.value - titleAreaHeight.value))
|
||||
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom))
|
||||
const innerHeight = computed(() =>
|
||||
Math.max(0, drawingHeight.value - (isCleanView.value ? 0 : margin.top + margin.bottom)),
|
||||
)
|
||||
const titleAreaStyle = computed<CSSProperties>(() => ({
|
||||
height: `${titleAreaHeight.value}px`,
|
||||
justifyContent:
|
||||
@@ -440,7 +461,7 @@ const hasVisibleWaveformData = computed(() =>
|
||||
)
|
||||
const chartLeftMargin = computed(() =>
|
||||
Math.max(
|
||||
margin.left,
|
||||
isCleanView.value ? 0 : margin.left,
|
||||
hasYAxisLabels.value
|
||||
? yAxisMetrics.value.fullClearance
|
||||
: hasVisibleWaveformData.value
|
||||
@@ -461,14 +482,20 @@ const multiAxisClearance = computed(() =>
|
||||
),
|
||||
)
|
||||
const resolvedChartLeftMargin = computed(() =>
|
||||
props.overlayMode === 'multi-axis'
|
||||
? Math.max(chartLeftMargin.value, multiAxisClearance.value.left)
|
||||
: chartLeftMargin.value,
|
||||
isCleanView.value
|
||||
? 0
|
||||
: 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,
|
||||
? isCleanView.value
|
||||
? 0
|
||||
: Math.max(margin.right, multiAxisClearance.value.right)
|
||||
: isCleanView.value
|
||||
? 0
|
||||
: margin.right,
|
||||
)
|
||||
const innerWidth = computed(() =>
|
||||
Math.max(0, chartWidth.value - resolvedChartLeftMargin.value - chartRightMargin.value),
|
||||
@@ -568,6 +595,7 @@ const gridCells = computed(() => {
|
||||
props.displayMode,
|
||||
pagedTracks.value.map(Boolean),
|
||||
yAxisLayout.value.horizontalGap,
|
||||
!isCleanView.value,
|
||||
)
|
||||
return cells.map((cell, index) => ({ ...cell, series: pagedTracks.value[index] }))
|
||||
})
|
||||
@@ -593,7 +621,7 @@ const trackLayouts = computed<TrackLayout[]>(() =>
|
||||
: sharedYDomains.value,
|
||||
timeUnit: props.timeUnit,
|
||||
rendering: renderingOptions.value,
|
||||
hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels,
|
||||
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
|
||||
yAxisLabelX: yAxisMetrics.value.labelCenterX,
|
||||
showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
|
||||
}),
|
||||
@@ -1004,7 +1032,7 @@ function resolvePointerEditorAnchor(
|
||||
const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
|
||||
return {
|
||||
x: resolvedChartLeftMargin.value + (track ? track.left + pointerX : pointerX),
|
||||
y: titleAreaHeight.value + margin.top + (track ? track.top + pointerY : pointerY),
|
||||
y: titleAreaHeight.value + chartTopMargin.value + (track ? track.top + pointerY : pointerY),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,7 +1046,7 @@ function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): Annotati
|
||||
: chartWidth.value / 2,
|
||||
y: track
|
||||
? titleAreaHeight.value +
|
||||
margin.top +
|
||||
chartTopMargin.value +
|
||||
track.top +
|
||||
resolveSeriesYScale(track, annotation.seriesId)(annotation.y)
|
||||
: chartHeight.value / 2,
|
||||
@@ -1293,7 +1321,7 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
|
||||
})
|
||||
commitHover(nextPoints, trackIndex, {
|
||||
x: resolvedChartLeftMargin.value + track.left + pointerX,
|
||||
y: titleAreaHeight.value + margin.top + track.top + pointerY,
|
||||
y: titleAreaHeight.value + chartTopMargin.value + track.top + pointerY,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1324,7 +1352,7 @@ function handleSharedPointerMove(event: PointerEvent) {
|
||||
)
|
||||
commitHover(nextPoints, null, {
|
||||
x: resolvedChartLeftMargin.value + pointerX,
|
||||
y: titleAreaHeight.value + margin.top + pointerY,
|
||||
y: titleAreaHeight.value + chartTopMargin.value + pointerY,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1842,8 +1870,12 @@ onBeforeUnmount(() => {
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<g :transform="`translate(${resolvedChartLeftMargin}, ${margin.top})`">
|
||||
<g v-if="displayMode !== 'compact'" class="waveform-chart__grid-slots" aria-hidden="true">
|
||||
<g :transform="`translate(${resolvedChartLeftMargin}, ${chartTopMargin})`">
|
||||
<g
|
||||
v-if="displayMode !== 'compact' && !isCleanView"
|
||||
class="waveform-chart__grid-slots"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g
|
||||
v-for="cell in gridCells"
|
||||
:key="`grid-slot-${cell.slotIndex}`"
|
||||
@@ -1889,6 +1921,8 @@ onBeforeUnmount(() => {
|
||||
:interaction-mode="activeInteractionMode"
|
||||
:frame-number="resolveFrameNumber(track.index)"
|
||||
:frame-style="frameStyle"
|
||||
:clean-view="isCleanView"
|
||||
:zero-line="resolvedZeroLine"
|
||||
:time-unit="timeUnit"
|
||||
:y-label="yLabel"
|
||||
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
|
||||
@@ -1912,6 +1946,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<WaveformAnnotationLayer
|
||||
v-if="!isCleanView"
|
||||
:annotations="renderedAnnotations"
|
||||
:visible="annotationsVisible"
|
||||
@contextmenu="handleExistingAnnotationContextMenu"
|
||||
@@ -1920,7 +1955,7 @@ onBeforeUnmount(() => {
|
||||
@drag-end="endAnnotationDrag"
|
||||
/>
|
||||
|
||||
<g class="waveform-chart__legend-layer">
|
||||
<g v-if="!isCleanView" class="waveform-chart__legend-layer">
|
||||
<g
|
||||
v-for="track in trackLayouts"
|
||||
:key="`legend-${track.index}-${track.series.name}`"
|
||||
@@ -1944,7 +1979,7 @@ onBeforeUnmount(() => {
|
||||
</g>
|
||||
|
||||
<text
|
||||
v-if="resolvedXLabel"
|
||||
v-if="resolvedXLabel && !isCleanView"
|
||||
class="waveform-chart__label"
|
||||
:x="innerWidth / 2"
|
||||
:y="xAxisTitleY"
|
||||
@@ -1966,7 +2001,7 @@ onBeforeUnmount(() => {
|
||||
</svg>
|
||||
|
||||
<Pagination
|
||||
v-if="gridOptions.showPagination && pageCount > 1"
|
||||
v-if="gridOptions.showPagination && pageCount > 1 && !isCleanView"
|
||||
class="waveform-chart__pagination"
|
||||
aria-label="波形分页"
|
||||
:current="currentPage"
|
||||
@@ -1978,7 +2013,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<WaveformAnnotationToolbar
|
||||
v-if="showAnnotationToolbar"
|
||||
v-if="showAnnotationToolbar && !isCleanView"
|
||||
:interaction-mode="activeInteractionMode"
|
||||
:annotations-visible="annotationsVisible"
|
||||
@update:interaction-mode="setInteractionMode"
|
||||
@@ -1986,7 +2021,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<WaveformAnnotationEditor
|
||||
v-if="annotationInteraction.editorDraft.value"
|
||||
v-if="annotationInteraction.editorDraft.value && !isCleanView"
|
||||
:annotation="annotationInteraction.editorDraft.value.annotation"
|
||||
:mode="annotationInteraction.editorDraft.value.mode"
|
||||
:series="editorSeries"
|
||||
@@ -1998,6 +2033,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
|
||||
<WaveformAnnotationContextMenu
|
||||
v-if="!isCleanView"
|
||||
:visible="annotationInteraction.contextMenu.value !== null"
|
||||
:x="annotationInteraction.contextMenu.value?.x || 0"
|
||||
:y="annotationInteraction.contextMenu.value?.y || 0"
|
||||
|
||||
@@ -74,6 +74,7 @@ export function resolveGridCellGeometry(
|
||||
displayMode: WaveformDisplayMode,
|
||||
slotHasSeries: boolean[] = [],
|
||||
horizontalGap?: number,
|
||||
showXAxis = true,
|
||||
): GridCellGeometry[] {
|
||||
const defaultGap = getGridGap(displayMode)
|
||||
const columnGap = Number.isFinite(horizontalGap)
|
||||
@@ -81,7 +82,9 @@ export function resolveGridCellGeometry(
|
||||
: defaultGap
|
||||
const totalHorizontalGap = Math.max(0, options.columnCount - 1) * columnGap
|
||||
const axisRows = new Set<number>()
|
||||
if (displayMode === 'independent') {
|
||||
if (!showXAxis) {
|
||||
// Net view uses the full drawing area for waveform pixels.
|
||||
} else if (displayMode === 'independent') {
|
||||
for (let row = 0; row < options.rowCount; row += 1) axisRows.add(row)
|
||||
} else if (displayMode === 'compact') {
|
||||
// Compact tracks share one continuous plot stack. Reserve the X-axis band
|
||||
|
||||
@@ -19,6 +19,7 @@ export type {
|
||||
WaveformLegendOrientation,
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
WaveformZeroLineOptions,
|
||||
SingleWaveformData,
|
||||
WaveformLineType,
|
||||
WaveformPointType,
|
||||
|
||||
@@ -17,6 +17,7 @@ export type {
|
||||
WaveformLegendOrientation,
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
WaveformZeroLineOptions,
|
||||
WaveformPoint,
|
||||
WaveformSeries,
|
||||
WaveformLineType,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { axisBottom, axisLeft, axisRight, select } from 'd3'
|
||||
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
|
||||
import type { WaveformFrameStyle } from '../../types'
|
||||
import type { WaveformFrameStyle, WaveformZeroLineOptions } from '../../types'
|
||||
import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types'
|
||||
import type {
|
||||
DisplaySeries,
|
||||
@@ -37,6 +37,12 @@ interface Props {
|
||||
hoveredPoint?: HoveredSeriesPoint
|
||||
/** Y 轴标签回退值 */
|
||||
yLabel?: string
|
||||
/** Hide visual aids while keeping chart interaction active. */
|
||||
cleanView?: boolean
|
||||
/** Resolved zero reference line style. */
|
||||
zeroLine?: Required<Pick<WaveformZeroLineOptions, 'color' | 'width' | 'dash'>> & {
|
||||
visible: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -51,6 +57,8 @@ interface Emits {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
interactionMode: 'zoom',
|
||||
cleanView: false,
|
||||
zeroLine: () => ({ visible: false, color: '#98a2b3', width: 1, dash: '6 4' }),
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -111,6 +119,12 @@ function hasCrosshair(): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
function zeroLineY(axis: WaveformYAxisLayout): number | null {
|
||||
const [minimum, maximum] = axis.scale.domain()
|
||||
if (!props.zeroLine.visible || minimum > 0 || maximum < 0) return null
|
||||
return axis.scale(0)
|
||||
}
|
||||
|
||||
function renderAxes() {
|
||||
props.track.yAxes.forEach((axis, index) => {
|
||||
const element = yAxisElements.value[index]
|
||||
@@ -180,7 +194,7 @@ watch(
|
||||
:transform="`translate(${track.left ?? 0}, ${track.top})`"
|
||||
>
|
||||
<rect
|
||||
v-if="!track.isEmpty"
|
||||
v-if="!track.isEmpty && !cleanView"
|
||||
class="waveform-track__plot-background waveform-chart__plot-background"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
@@ -190,7 +204,7 @@ watch(
|
||||
|
||||
<!-- 网格和背景 -->
|
||||
<g
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries"
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries && !cleanView"
|
||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -236,9 +250,31 @@ watch(
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<g
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries && zeroLine.visible && !cleanView"
|
||||
class="waveform-track__zero-lines waveform-chart__zero-lines"
|
||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<template v-for="axis in track.yAxes" :key="`zero-line-${track.index}-${axis.index}`">
|
||||
<line
|
||||
v-if="zeroLineY(axis) !== null"
|
||||
class="waveform-track__zero-line waveform-chart__zero-line"
|
||||
:data-y-axis-index="axis.index"
|
||||
x1="0"
|
||||
:x2="track.width ?? innerWidth"
|
||||
:y1="zeroLineY(axis) ?? 0"
|
||||
:y2="zeroLineY(axis) ?? 0"
|
||||
:stroke="zeroLine.color"
|
||||
:stroke-width="zeroLine.width"
|
||||
:stroke-dasharray="zeroLine.dash || undefined"
|
||||
/>
|
||||
</template>
|
||||
</g>
|
||||
|
||||
<!-- 帧编号水印 -->
|
||||
<text
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries && frameNumber !== undefined"
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries && frameNumber !== undefined && !cleanView"
|
||||
class="waveform-track__watermark waveform-chart__watermark"
|
||||
:x="(track.width ?? innerWidth) / 2"
|
||||
:y="track.height / 2"
|
||||
@@ -252,13 +288,13 @@ watch(
|
||||
|
||||
<!-- X 轴 -->
|
||||
<g
|
||||
v-if="track.showXAxis"
|
||||
v-if="track.showXAxis && !cleanView"
|
||||
ref="xAxisElement"
|
||||
class="waveform-track__axis waveform-track__axis--x waveform-chart__axis waveform-chart__axis--x"
|
||||
:transform="`translate(0, ${track.height})`"
|
||||
/>
|
||||
<g
|
||||
v-if="track.showXAxis"
|
||||
v-if="track.showXAxis && !cleanView"
|
||||
class="waveform-track__axis-endpoints waveform-chart__axis-endpoints"
|
||||
:transform="`translate(0, ${track.height})`"
|
||||
font-family="sans-serif"
|
||||
@@ -285,7 +321,7 @@ watch(
|
||||
</text>
|
||||
</g>
|
||||
<text
|
||||
v-if="track.showXAxis && track.xAxisExponent"
|
||||
v-if="track.showXAxis && track.xAxisExponent && !cleanView"
|
||||
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"
|
||||
@@ -297,7 +333,7 @@ watch(
|
||||
|
||||
<!-- Y 轴 -->
|
||||
<g
|
||||
v-for="axis in track.isEmpty ? [] : track.yAxes"
|
||||
v-for="axis in track.isEmpty || cleanView ? [] : 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"
|
||||
@@ -307,7 +343,9 @@ watch(
|
||||
:transform="`translate(${axis.x}, 0)`"
|
||||
/>
|
||||
<text
|
||||
v-for="axis in track.isEmpty ? [] : track.yAxes.filter((item) => item.exponentLabel)"
|
||||
v-for="axis in track.isEmpty || cleanView
|
||||
? []
|
||||
: 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"
|
||||
@@ -323,6 +361,7 @@ watch(
|
||||
<!-- Y 轴标签 -->
|
||||
<g
|
||||
v-if="
|
||||
!cleanView &&
|
||||
!track.isEmpty &&
|
||||
track.hasVisibleSeries &&
|
||||
track.seriesList.length === 1 &&
|
||||
@@ -351,7 +390,7 @@ watch(
|
||||
</g>
|
||||
|
||||
<g
|
||||
v-for="axis in track.yAxes.length > 1 ? track.yAxes.filter(hasYAxisTitle) : []"
|
||||
v-for="axis in !cleanView && 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"
|
||||
@@ -377,7 +416,7 @@ watch(
|
||||
|
||||
<!-- 轨道边框 -->
|
||||
<rect
|
||||
v-if="!track.isEmpty"
|
||||
v-if="!track.isEmpty && !cleanView"
|
||||
class="waveform-track__plot-frame waveform-chart__plot-frame"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
@@ -421,7 +460,7 @@ watch(
|
||||
/>
|
||||
|
||||
<text
|
||||
v-if="!track.isEmpty && !track.hasVisibleSeries"
|
||||
v-if="!track.isEmpty && !track.hasVisibleSeries && !cleanView"
|
||||
class="waveform-track__no-visible-series"
|
||||
:x="(track.width ?? innerWidth) / 2"
|
||||
:y="track.height / 2"
|
||||
@@ -516,6 +555,11 @@ watch(
|
||||
stroke-dasharray: 4 3;
|
||||
}
|
||||
|
||||
.waveform-track__zero-line {
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-track__axis-endpoint {
|
||||
fill: #667085;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
WaveformLegendOrientation,
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
WaveformZeroLineOptions,
|
||||
// 数据类型
|
||||
SingleWaveformData,
|
||||
WaveformLineType,
|
||||
|
||||
@@ -156,7 +156,8 @@ body {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.frame-style-controls {
|
||||
.frame-style-controls,
|
||||
.auxiliary-style-controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -118,3 +118,11 @@ export interface WaveformFrameStyle {
|
||||
borderStyle?: 'solid' | 'dashed'
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
/** Styling and visibility options for the horizontal zero-value reference line. */
|
||||
export interface WaveformZeroLineOptions {
|
||||
visible?: boolean
|
||||
color?: string
|
||||
width?: number
|
||||
dash?: string
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type {
|
||||
WaveformLegendOrientation,
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
WaveformZeroLineOptions,
|
||||
} from './chart'
|
||||
|
||||
// 数据类型
|
||||
|
||||
Reference in New Issue
Block a user