feat(网格): 支持不完整末行铺满布局

This commit is contained in:
李启源
2026-08-18 13:21:40 +08:00
parent 8987cae846
commit c81ff8da03
12 changed files with 283 additions and 32 deletions

View File

@@ -114,7 +114,7 @@ const data = ref<WaveformData>({
| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 |
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true, fillIncompleteLastRow: false }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线、Y 轴分割数与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
@@ -544,6 +544,10 @@ scale 定位零线:
`grid` 控制独立图框的行列数(范围 `110`)以及是否显示分页器。默认值为 `2` 行、
`1` 列并开启分页;当图框数量超过网格容量时,分页器会显示在图表右下角。
`fillIncompleteLastRow` 默认关闭。开启后,分页容量仍由 `rowCount * columnCount` 决定;
最后一页或未满页会移除没有数据的整行,并将最后一行的实际图框等宽铺满可用宽度。例如 2 列
网格的最后一行只有一个图框时,该图框会占满整行。完整页和关闭该选项时的布局保持不变。
还可以通过 `trackLines` 按轨道 ID 分别控制水平/垂直网格线的显隐和颜色。颜色未配置时,
继续使用组件默认的主/次网格颜色:
@@ -554,6 +558,7 @@ scale 定位零线:
rowCount: 2,
columnCount: 2,
showPagination: true,
fillIncompleteLastRow: true,
trackLines: {
voltage: {
horizontal: false,

View File

@@ -1,6 +1,6 @@
{
"name": "waveform-analysis",
"version": "0.1.41",
"version": "0.1.42",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/types/index.d.ts",

View File

@@ -1,3 +1,33 @@
/* The modal is teleported to body. Keep its entire subtree namespaced so host
application resets (for example `button { ... }`) cannot change the editor. */
:global(.waveform-annotation-editor-root),
:global(.waveform-annotation-editor-root *) {
box-sizing: border-box;
}
:global(.waveform-annotation-editor-root) {
color: #344054;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif;
line-height: 1.5715;
text-align: left;
text-transform: none;
}
:global(.waveform-annotation-editor-root button),
:global(.waveform-annotation-editor-root input),
:global(.waveform-annotation-editor-root select),
:global(.waveform-annotation-editor-root textarea) {
font-family: inherit;
letter-spacing: normal;
text-transform: none;
}
:global(.waveform-annotation-editor-root button) {
appearance: auto;
}
.waveform-annotation-editor__content {
display: grid;
gap: 20px;

View File

@@ -126,6 +126,7 @@ function handleSeriesChange(event: Event) {
cancel-text="取消"
ok-text="保存标注"
:ok-button-props="{ disabled: !canConfirm }"
root-class-name="waveform-annotation-editor-root"
wrap-class-name="waveform-annotation-editor"
@cancel="emit('cancel')"
@ok="confirm"

View File

@@ -18,13 +18,14 @@ const modalStub = defineComponent({
'cancelText',
'okText',
'okButtonProps',
'rootClassName',
],
emits: ['cancel', 'ok'],
setup(props, { emit, slots }) {
return () => {
const title = slots.title?.() ?? []
const titleId = (title[0]?.props as { id?: string } | undefined)?.id
return h('div', { class: 'ant-modal-root' }, [
return h('div', { class: ['ant-modal-root', props.rootClassName] }, [
h(
'div',
{
@@ -96,6 +97,18 @@ describe('waveform annotation controls', () => {
expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId)
})
it('uses a dedicated root class to isolate teleported modal styles', () => {
const wrapper = mountEditor({
props: {
annotation: { id: 'isolated', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'add',
},
})
expect(wrapper.get('.ant-modal-root').classes()).toContain('waveform-annotation-editor-root')
expect(wrapper.get('.ant-modal-wrap').classes()).toContain('waveform-annotation-editor')
})
it.each(['add', 'edit'] as const)('shows the X-axis snapping hint in %s mode', (mode) => {
const wrapper = mountEditor({
props: {

View File

@@ -53,7 +53,13 @@ describe('fixed Y-domain layout', () => {
series: sourceTrack,
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
@@ -89,7 +95,13 @@ describe('fixed Y-domain layout', () => {
series: track([series('a', 0, 100)]),
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
@@ -124,7 +136,13 @@ describe('fixed Y-domain layout', () => {
series: track([series('a', 3, 97)]),
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],

View File

@@ -15,12 +15,14 @@ describe('waveform grid helpers', () => {
rowCount: 2,
columnCount: 1,
showPagination: true,
fillIncompleteLastRow: false,
trackLines: {},
})
expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
rowCount: 1,
columnCount: 10,
showPagination: true,
fillIncompleteLastRow: false,
trackLines: {},
})
})
@@ -131,4 +133,50 @@ describe('waveform grid helpers', () => {
expect(cells[1].left).toBe(232)
expect(cells[2].top).toBe(cells[0].plotHeight + X_AXIS_BAND + 14)
})
it('fills incomplete final rows without changing page capacity', () => {
const oneColumn = resolveGridCellGeometry(
400,
400,
normalizeGridOptions({ rowCount: 4, columnCount: 1, fillIncompleteLastRow: true }),
'separated',
[true, true, true],
)
expect(oneColumn).toHaveLength(3)
expect(oneColumn.map((cell) => cell.width)).toEqual([400, 400, 400])
expect(oneColumn.at(-1)?.isLastRow).toBe(true)
const twoColumns = resolveGridCellGeometry(
400,
300,
normalizeGridOptions({ rowCount: 2, columnCount: 2, fillIncompleteLastRow: true }),
'separated',
[true, true, true],
)
expect(twoColumns).toHaveLength(3)
expect(twoColumns[2]).toMatchObject({ row: 1, column: 0, left: 0, width: 400 })
const threeColumns = resolveGridCellGeometry(
400,
300,
normalizeGridOptions({ rowCount: 2, columnCount: 3, fillIncompleteLastRow: true }),
'separated',
[true, true, true, true, true],
)
expect(threeColumns).toHaveLength(5)
expect(threeColumns.slice(3).map((cell) => cell.width)).toEqual([192, 192])
expect(threeColumns.slice(3).map((cell) => cell.left)).toEqual([0, 208])
})
it('keeps full pages and the disabled option byte-for-byte compatible', () => {
const base = normalizeGridOptions({ rowCount: 2, columnCount: 2 })
const filled = normalizeGridOptions({ rowCount: 2, columnCount: 2, fillIncompleteLastRow: true })
const fullSlots = [true, true, true, true]
expect(resolveGridCellGeometry(400, 300, filled, 'independent', fullSlots)).toEqual(
resolveGridCellGeometry(400, 300, base, 'independent', fullSlots),
)
expect(resolveGridCellGeometry(400, 300, base, 'independent', [true, true, true])).toHaveLength(
4,
)
})
})

View File

@@ -8,6 +8,7 @@ export interface WaveformGridOptions {
rowCount?: number
columnCount?: number
showPagination?: boolean
fillIncompleteLastRow?: boolean
trackLines?: WaveformGridTrackLines
}
@@ -33,6 +34,7 @@ export interface NormalizedWaveformGridOptions {
rowCount: number
columnCount: number
showPagination: boolean
fillIncompleteLastRow: boolean
trackLines: Record<string, NormalizedWaveformGridLineOptions>
}
@@ -48,6 +50,7 @@ export interface GridCellGeometry {
plotHeight: number
cellHeight: number
xAxisBand: number
isLastRow?: boolean
}
const normalizeCount = (value: unknown, fallback: number) => {
@@ -78,6 +81,7 @@ export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedW
rowCount: normalizeCount(options?.rowCount, 2),
columnCount: normalizeCount(options?.columnCount, 1),
showPagination: options?.showPagination ?? true,
fillIncompleteLastRow: options?.fillIncompleteLastRow ?? false,
trackLines,
}
}
@@ -114,20 +118,23 @@ export function resolveGridCellGeometry(
horizontalGap?: number,
showXAxis = true,
): GridCellGeometry[] {
const pageSize = getPageSize(options)
const seriesCount = slotHasSeries.filter(Boolean).length
const filledLastRow = options.fillIncompleteLastRow && seriesCount > 0 && seriesCount < pageSize
const rowCount = filledLastRow ? Math.ceil(seriesCount / options.columnCount) : options.rowCount
const defaultGap = getGridGap(displayMode)
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 (!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)
for (let row = 0; row < rowCount; row += 1) axisRows.add(row)
} else if (displayMode === 'compact') {
// Compact tracks share one continuous plot stack. Reserve the X-axis band
// only beneath the final grid row, including when that row is empty.
axisRows.add(options.rowCount - 1)
axisRows.add(rowCount - 1)
} else {
for (let column = 0; column < options.columnCount; column += 1) {
for (let slotIndex = getPageSize(options) - 1; slotIndex >= 0; slotIndex -= 1) {
@@ -139,17 +146,22 @@ export function resolveGridCellGeometry(
}
}
}
const totalVerticalGap = Math.max(0, options.rowCount - 1) * defaultGap
const totalVerticalGap = Math.max(0, 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,
(innerHeight - totalVerticalGap - totalAxisBand) / rowCount,
)
return Array.from({ length: getPageSize(options) }, (_, slotIndex) => {
return Array.from({ length: filledLastRow ? seriesCount : pageSize }, (_, slotIndex) => {
const row = Math.floor(slotIndex / options.columnCount)
const column = slotIndex % options.columnCount
const rowSeriesCount =
filledLastRow && row === rowCount - 1
? seriesCount - row * options.columnCount
: options.columnCount
const rowGap = Math.max(0, rowSeriesCount - 1) * columnGap
const width = Math.max(1, (innerWidth - rowGap) / rowSeriesCount)
const xAxisBand = axisRows.has(row) ? X_AXIS_BAND : 0
const cellHeight = plotHeight + xAxisBand
const top = Array.from({ length: row }, (_, previousRow) => {
@@ -167,6 +179,7 @@ export function resolveGridCellGeometry(
plotHeight,
cellHeight,
xAxisBand,
isLastRow: row === rowCount - 1,
}
})
}

View File

@@ -65,7 +65,13 @@ function layoutForSeries(
series: sourceTrack,
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [transform],
@@ -107,7 +113,13 @@ describe('multi-value Y-axis grouping', () => {
series: sourceTrack,
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
@@ -217,7 +229,13 @@ describe('multi-value Y-axis grouping', () => {
series: track([series('left', 1000, 3000), series('right', 1000, 3000)]),
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent',
overlayMode: 'multi-axis',
independentTransforms: [zoomIdentity],

View File

@@ -266,7 +266,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
(isEmpty || hasVisibleSeries) &&
(options.displayMode === 'independent' ||
(options.displayMode === 'compact'
? cell.row === options.grid.rowCount - 1
? (cell.isLastRow ?? cell.row === options.grid.rowCount - 1)
: bottomCells.has(cell.slotIndex))),
}
})

View File

@@ -11,6 +11,7 @@ import {
import type { AnnotationSeriesCandidate } from '../annotation'
import type { useWaveformAnnotationInteraction } from '../annotation'
import type { NormalizedWaveformGridOptions } from './grid'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
@@ -33,7 +34,7 @@ interface LifecycleContext {
currentPage: Ref<number>
pageCount: ComputedRef<number>
pagedTracks: ComputedRef<Array<DisplayTrack | undefined>>
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
gridOptions: ComputedRef<NormalizedWaveformGridOptions>
chartSeries: ComputedRef<DisplaySeries[]>
chartTracks: ComputedRef<DisplayTrack[]>
trackLayouts: ComputedRef<TrackLayout[]>
@@ -220,6 +221,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
currentPage,
() => gridOptions.value.rowCount,
() => gridOptions.value.columnCount,
() => gridOptions.value.fillIncompleteLastRow,
activeInteractionMode,
],
async () => {
@@ -239,20 +241,28 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
},
)
watch([pageCount, () => props.grid?.rowCount, () => props.grid?.columnCount], () => {
const previousPage = currentPage.value
currentPage.value =
currentPage.value > pageCount.value
? pageCount.value
: currentPage.value !== 1
? 1
: currentPage.value
if (previousPage !== currentPage.value) {
emit('page-change', currentPage.value, pageCount.value)
}
clearHover()
void nextTick(configureZoom)
})
watch(
[
pageCount,
() => props.grid?.rowCount,
() => props.grid?.columnCount,
() => props.grid?.fillIncompleteLastRow,
],
() => {
const previousPage = currentPage.value
currentPage.value =
currentPage.value > pageCount.value
? pageCount.value
: currentPage.value !== 1
? 1
: currentPage.value
if (previousPage !== currentPage.value) {
emit('page-change', currentPage.value, pageCount.value)
}
clearHover()
void nextTick(configureZoom)
},
)
watch(activeInteractionMode, () => {
editorSeriesOptions.value = []

View File

@@ -0,0 +1,95 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
function geometry(track: { attributes: (name: string) => string | undefined }) {
return {
left: Number(track.attributes('data-track-left')),
top: Number(track.attributes('data-track-top')),
width: Number(track.attributes('data-track-width')),
height: Number(track.attributes('data-track-height')),
}
}
describe('WaveformChart incomplete grid rows', () => {
it.each(['independent', 'separated', 'compact'] as const)(
'fills an incomplete two by two row in %s mode',
async (displayMode) => {
const wrapper = await mountSizedChart(gridSeries(3), {
displayMode,
grid: { rowCount: 2, columnCount: 2, fillIncompleteLastRow: true },
})
const tracks = wrapper.findAll('.waveform-chart__track')
const first = geometry(tracks[0]!)
const last = geometry(tracks[2]!)
expect(tracks).toHaveLength(3)
expect(wrapper.findAll('.waveform-chart__track--empty')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__grid-slot-placeholder')).toHaveLength(0)
expect(last.left).toBe(0)
expect(last.width).toBeGreaterThan(first.width * 1.8)
expect(last.top).toBeGreaterThan(first.top)
if (displayMode === 'independent') {
const overlays = wrapper.findAll('.waveform-chart__overlay--independent')
expect(overlays).toHaveLength(3)
expect(Number(overlays[2]?.attributes('width'))).toBe(last.width)
} else {
expect(wrapper.findAll('.waveform-chart__overlay--shared')).toHaveLength(1)
}
},
)
it('removes unused one-column rows and redistributes chart height', async () => {
const base = await mountSizedChart(gridSeries(3), {
displayMode: 'independent',
grid: { rowCount: 4, columnCount: 1 },
})
const filled = await mountSizedChart(gridSeries(3), {
displayMode: 'independent',
grid: { rowCount: 4, columnCount: 1, fillIncompleteLastRow: true },
})
const baseFirst = geometry(base.findAll('.waveform-chart__track')[0]!)
const filledTracks = filled.findAll('.waveform-chart__track')
const filledFirst = geometry(filledTracks[0]!)
const filledLast = geometry(filledTracks[2]!)
expect(filledTracks).toHaveLength(3)
expect(filledFirst.height).toBeGreaterThan(baseFirst.height)
expect(filledLast.top + filledLast.height).toBeGreaterThan(baseFirst.height * 3)
})
it('keeps the final two tracks evenly distributed in a two by three grid', async () => {
const wrapper = await mountSizedChart(gridSeries(5), {
displayMode: 'separated',
grid: { rowCount: 2, columnCount: 3, fillIncompleteLastRow: true },
})
const tracks = wrapper.findAll('.waveform-chart__track').map(geometry)
expect(tracks).toHaveLength(5)
expect(tracks[3]?.left).toBe(0)
expect(tracks[3]?.width).toBeCloseTo(tracks[4]?.width ?? 0)
expect(tracks[4]?.left).toBeGreaterThan((tracks[3]?.width ?? 0) + 1)
})
it('keeps full pages unchanged and fills a partial second page', async () => {
const data = gridSeries(5)
const standard = await mountSizedChart(data, {
displayMode: 'separated',
grid: { rowCount: 2, columnCount: 2 },
})
const filled = await mountSizedChart(data, {
displayMode: 'separated',
grid: { rowCount: 2, columnCount: 2, fillIncompleteLastRow: true },
})
const standardFirstPage = standard.findAll('.waveform-chart__track').map(geometry)
expect(filled.findAll('.waveform-chart__track').map(geometry)).toEqual(standardFirstPage)
await filled.get('.ant-pagination-next button').trigger('click')
await flushPromises()
const secondPage = filled.findAll('.waveform-chart__track').map(geometry)
expect(secondPage).toHaveLength(1)
expect(secondPage[0]?.left).toBe(0)
expect(secondPage[0]?.width).toBeGreaterThan(standardFirstPage[0]?.width ?? 0)
})
})