diff --git a/README.md b/README.md index d450e2d..8f8348c 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ const data = ref({ | `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 | | `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | | `yDomains` | `Record` | 未设置 | 按 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` 控制独立图框的行列数(范围 `1–10`)以及是否显示分页器。默认值为 `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, diff --git a/package.json b/package.json index 363f293..6fbcfce 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/annotation/WaveformAnnotationEditor.css b/src/components/annotation/WaveformAnnotationEditor.css index 0b277e6..8390935 100644 --- a/src/components/annotation/WaveformAnnotationEditor.css +++ b/src/components/annotation/WaveformAnnotationEditor.css @@ -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; diff --git a/src/components/annotation/WaveformAnnotationEditor.vue b/src/components/annotation/WaveformAnnotationEditor.vue index b8d830f..f126a21 100644 --- a/src/components/annotation/WaveformAnnotationEditor.vue +++ b/src/components/annotation/WaveformAnnotationEditor.vue @@ -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" diff --git a/src/components/annotation/components.test.ts b/src/components/annotation/components.test.ts index 9190b79..9343d69 100644 --- a/src/components/annotation/components.test.ts +++ b/src/components/annotation/components.test.ts @@ -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: { diff --git a/src/components/core/fixedYDomainLayout.test.ts b/src/components/core/fixedYDomainLayout.test.ts index d733c4f..10bbb0e 100644 --- a/src/components/core/fixedYDomainLayout.test.ts +++ b/src/components/core/fixedYDomainLayout.test.ts @@ -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], diff --git a/src/components/core/grid.test.ts b/src/components/core/grid.test.ts index 7873684..9db0ce1 100644 --- a/src/components/core/grid.test.ts +++ b/src/components/core/grid.test.ts @@ -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, + ) + }) }) diff --git a/src/components/core/grid.ts b/src/components/core/grid.ts index 35f867d..aaefbb7 100644 --- a/src/components/core/grid.ts +++ b/src/components/core/grid.ts @@ -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 } @@ -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() 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, } }) } diff --git a/src/components/core/layout.test.ts b/src/components/core/layout.test.ts index ca3e69a..d7ab475 100644 --- a/src/components/core/layout.test.ts +++ b/src/components/core/layout.test.ts @@ -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], diff --git a/src/components/core/trackLayoutBuilder.ts b/src/components/core/trackLayoutBuilder.ts index 3157b4f..f9643bd 100644 --- a/src/components/core/trackLayoutBuilder.ts +++ b/src/components/core/trackLayoutBuilder.ts @@ -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))), } }) diff --git a/src/components/core/useWaveformChartLifecycle.ts b/src/components/core/useWaveformChartLifecycle.ts index 2cd3b1d..fd43994 100644 --- a/src/components/core/useWaveformChartLifecycle.ts +++ b/src/components/core/useWaveformChartLifecycle.ts @@ -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 pageCount: ComputedRef pagedTracks: ComputedRef> - gridOptions: ComputedRef<{ rowCount: number; columnCount: number }> + gridOptions: ComputedRef chartSeries: ComputedRef chartTracks: ComputedRef trackLayouts: ComputedRef @@ -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 = [] diff --git a/src/components/waveformChartCases/gridFill.test.ts b/src/components/waveformChartCases/gridFill.test.ts new file mode 100644 index 0000000..9ef94c5 --- /dev/null +++ b/src/components/waveformChartCases/gridFill.test.ts @@ -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) + }) +})