2 Commits

Author SHA1 Message Date
李启源
7637f411d7 feat(网格): 支持不完整末行铺满布局
All checks were successful
Package component / package (push) Successful in 4m43s
2026-08-18 13:21:40 +08:00
李启源
8987cae846 feat(坐标轴): 支持配置纵轴等分刻度
All checks were successful
Package component / package (push) Successful in 4m43s
2026-08-18 11:45:40 +08:00
18 changed files with 451 additions and 64 deletions

View File

@@ -114,8 +114,8 @@ const data = ref<WaveformData>({
| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 | | `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 |
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | | `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 | | `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 轴基线显隐与 X 轴 label 格式化 | | `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线、Y 轴分割数与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
@@ -208,7 +208,8 @@ import 'waveform-analysis/style.css'
范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须 范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须
是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。 是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。
固定范围会精确作为坐标域使用,不会经过 D3 的 `nice()` 扩展 固定范围作为 Y 轴刻度算法的初始范围;轴会按漂亮步长经过 D3 的 `nice()` 扩展,确保主刻度
等间距。原始数据和传入的范围值不会被修改。
单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置, 单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置,
超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。 超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。
@@ -216,6 +217,16 @@ import 'waveform-analysis/style.css'
固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖X 轴缩放、平移和重置保持原有 固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖X 轴缩放、平移和重置保持原有
行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。 行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。
### Y 轴自动等分
Y 轴默认显示 5 个主刻度(包含上下端点),并使用类似 ECharts 的 `1 / 2 / 5 × 10ⁿ` 漂亮
步长,保证主刻度之间数值等间距。可以通过 `axes.y.splitNumber` 指定主刻度总数;每条
Y 轴会独立计算:
```vue
<WaveformChart :data="chartData" :axes="{ y: { splitNumber: 5 } }" />
```
### 缩放后按可视区间加载数据 ### 缩放后按可视区间加载数据
组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放 组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
@@ -533,6 +544,10 @@ scale 定位零线:
`grid` 控制独立图框的行列数(范围 `110`)以及是否显示分页器。默认值为 `2` 行、 `grid` 控制独立图框的行列数(范围 `110`)以及是否显示分页器。默认值为 `2` 行、
`1` 列并开启分页;当图框数量超过网格容量时,分页器会显示在图表右下角。 `1` 列并开启分页;当图框数量超过网格容量时,分页器会显示在图表右下角。
`fillIncompleteLastRow` 默认关闭。开启后,分页容量仍由 `rowCount * columnCount` 决定;
最后一页或未满页会移除没有数据的整行,并将最后一行的实际图框等宽铺满可用宽度。例如 2 列
网格的最后一行只有一个图框时,该图框会占满整行。完整页和关闭该选项时的布局保持不变。
还可以通过 `trackLines` 按轨道 ID 分别控制水平/垂直网格线的显隐和颜色。颜色未配置时, 还可以通过 `trackLines` 按轨道 ID 分别控制水平/垂直网格线的显隐和颜色。颜色未配置时,
继续使用组件默认的主/次网格颜色: 继续使用组件默认的主/次网格颜色:
@@ -543,6 +558,7 @@ scale 定位零线:
rowCount: 2, rowCount: 2,
columnCount: 2, columnCount: 2,
showPagination: true, showPagination: true,
fillIncompleteLastRow: true,
trackLines: { trackLines: {
voltage: { voltage: {
horizontal: false, horizontal: false,

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.40", "version": "0.1.42",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/types/index.d.ts", "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 { .waveform-annotation-editor__content {
display: grid; display: grid;
gap: 20px; gap: 20px;

View File

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

View File

@@ -18,13 +18,14 @@ const modalStub = defineComponent({
'cancelText', 'cancelText',
'okText', 'okText',
'okButtonProps', 'okButtonProps',
'rootClassName',
], ],
emits: ['cancel', 'ok'], emits: ['cancel', 'ok'],
setup(props, { emit, slots }) { setup(props, { emit, slots }) {
return () => { return () => {
const title = slots.title?.() ?? [] const title = slots.title?.() ?? []
const titleId = (title[0]?.props as { id?: string } | undefined)?.id 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( h(
'div', 'div',
{ {
@@ -96,6 +97,18 @@ describe('waveform annotation controls', () => {
expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId) 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) => { it.each(['add', 'edit'] as const)('shows the X-axis snapping hint in %s mode', (mode) => {
const wrapper = mountEditor({ const wrapper = mountEditor({
props: { props: {

View File

@@ -35,7 +35,7 @@ function track(seriesList: DisplaySeries[]): DisplayTrack {
} }
describe('fixed Y-domain layout', () => { describe('fixed Y-domain layout', () => {
it('uses an exact global fixed domain without applying nice bounds', () => { it('expands a global fixed domain to nice equal intervals', () => {
const sourceTrack = track([series('a', 0, 100)]) const sourceTrack = track([series('a', 0, 100)])
const result = buildTrackLayouts({ const result = buildTrackLayouts({
cells: [ cells: [
@@ -53,7 +53,13 @@ describe('fixed Y-domain layout', () => {
series: sourceTrack, series: sourceTrack,
}, },
], ],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent', displayMode: 'independent',
overlayMode: 'single-axis', overlayMode: 'single-axis',
independentTransforms: [zoomIdentity], independentTransforms: [zoomIdentity],
@@ -66,10 +72,92 @@ describe('fixed Y-domain layout', () => {
showCompactEmptyTracks: false, showCompactEmptyTracks: false,
})[0] })[0]
expect(result?.yScale.domain()).toEqual([3, 97]) expect(result?.yScale.domain()).toEqual([0, 100])
expect(result?.yAxes[0]?.tickValues).toContain(3) expect(result?.yAxes[0]?.tickValues).toContain(0)
expect(result?.yAxes[0]?.tickValues).toContain(97) expect(result?.yAxes[0]?.tickValues).toContain(100)
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([3, 97]) expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([0, 100])
})
it('uses the configured split number for fixed domains', () => {
const result = buildTrackLayouts({
cells: [
{
slotIndex: 0,
row: 0,
column: 0,
left: 0,
top: 0,
width: 120,
height: 100,
plotHeight: 100,
cellHeight: 130,
xAxisBand: 30,
series: track([series('a', 0, 100)]),
},
],
grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
sharedZoomDomain: [0, 1],
fixedYDomain: [3, 97],
yAxisSplitNumber: 5,
timeUnit: 'ms',
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
hideSecondaryLabels: false,
yAxisLabelX: -50,
showCompactEmptyTracks: false,
})[0]
expect(result?.yAxes[0]?.majorTicks).toEqual([0, 25, 50, 75, 100])
})
it('defaults to five ticks and supports a two-tick axis', () => {
const build = (splitNumber?: number) =>
buildTrackLayouts({
cells: [
{
slotIndex: 0,
row: 0,
column: 0,
left: 0,
top: 0,
width: 120,
height: 100,
plotHeight: 100,
cellHeight: 130,
xAxisBand: 30,
series: track([series('a', 3, 97)]),
},
],
grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
sharedZoomDomain: [0, 1],
fixedYDomain: [3, 97],
yAxisSplitNumber: splitNumber,
timeUnit: 'ms',
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
hideSecondaryLabels: false,
yAxisLabelX: -50,
showCompactEmptyTracks: false,
})[0]?.yAxes[0]?.majorTicks
expect(build()).toHaveLength(5)
expect(build(2)).toEqual([0, 100])
}) })
it('resolves track, series, and global fixed-domain precedence', () => { it('resolves track, series, and global fixed-domain precedence', () => {

View File

@@ -15,12 +15,14 @@ describe('waveform grid helpers', () => {
rowCount: 2, rowCount: 2,
columnCount: 1, columnCount: 1,
showPagination: true, showPagination: true,
fillIncompleteLastRow: false,
trackLines: {}, trackLines: {},
}) })
expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({ expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
rowCount: 1, rowCount: 1,
columnCount: 10, columnCount: 10,
showPagination: true, showPagination: true,
fillIncompleteLastRow: false,
trackLines: {}, trackLines: {},
}) })
}) })
@@ -131,4 +133,50 @@ describe('waveform grid helpers', () => {
expect(cells[1].left).toBe(232) expect(cells[1].left).toBe(232)
expect(cells[2].top).toBe(cells[0].plotHeight + X_AXIS_BAND + 14) 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 rowCount?: number
columnCount?: number columnCount?: number
showPagination?: boolean showPagination?: boolean
fillIncompleteLastRow?: boolean
trackLines?: WaveformGridTrackLines trackLines?: WaveformGridTrackLines
} }
@@ -33,6 +34,7 @@ export interface NormalizedWaveformGridOptions {
rowCount: number rowCount: number
columnCount: number columnCount: number
showPagination: boolean showPagination: boolean
fillIncompleteLastRow: boolean
trackLines: Record<string, NormalizedWaveformGridLineOptions> trackLines: Record<string, NormalizedWaveformGridLineOptions>
} }
@@ -48,6 +50,7 @@ export interface GridCellGeometry {
plotHeight: number plotHeight: number
cellHeight: number cellHeight: number
xAxisBand: number xAxisBand: number
isLastRow?: boolean
} }
const normalizeCount = (value: unknown, fallback: number) => { const normalizeCount = (value: unknown, fallback: number) => {
@@ -78,6 +81,7 @@ export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedW
rowCount: normalizeCount(options?.rowCount, 2), rowCount: normalizeCount(options?.rowCount, 2),
columnCount: normalizeCount(options?.columnCount, 1), columnCount: normalizeCount(options?.columnCount, 1),
showPagination: options?.showPagination ?? true, showPagination: options?.showPagination ?? true,
fillIncompleteLastRow: options?.fillIncompleteLastRow ?? false,
trackLines, trackLines,
} }
} }
@@ -114,20 +118,23 @@ export function resolveGridCellGeometry(
horizontalGap?: number, horizontalGap?: number,
showXAxis = true, showXAxis = true,
): GridCellGeometry[] { ): 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 defaultGap = getGridGap(displayMode)
const columnGap = Number.isFinite(horizontalGap) const columnGap = Number.isFinite(horizontalGap)
? Math.max(0, horizontalGap as number) ? Math.max(0, horizontalGap as number)
: defaultGap : defaultGap
const totalHorizontalGap = Math.max(0, options.columnCount - 1) * columnGap
const axisRows = new Set<number>() const axisRows = new Set<number>()
if (!showXAxis) { if (!showXAxis) {
// Net view uses the full drawing area for waveform pixels. // Net view uses the full drawing area for waveform pixels.
} else if (displayMode === 'independent') { } 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') { } else if (displayMode === 'compact') {
// Compact tracks share one continuous plot stack. Reserve the X-axis band // Compact tracks share one continuous plot stack. Reserve the X-axis band
// only beneath the final grid row, including when that row is empty. // only beneath the final grid row, including when that row is empty.
axisRows.add(options.rowCount - 1) axisRows.add(rowCount - 1)
} else { } else {
for (let column = 0; column < options.columnCount; column += 1) { for (let column = 0; column < options.columnCount; column += 1) {
for (let slotIndex = getPageSize(options) - 1; slotIndex >= 0; slotIndex -= 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 totalAxisBand = axisRows.size * X_AXIS_BAND
const width = Math.max(1, (innerWidth - totalHorizontalGap) / options.columnCount)
const plotHeight = Math.max( const plotHeight = Math.max(
1, 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 row = Math.floor(slotIndex / options.columnCount)
const column = 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 xAxisBand = axisRows.has(row) ? X_AXIS_BAND : 0
const cellHeight = plotHeight + xAxisBand const cellHeight = plotHeight + xAxisBand
const top = Array.from({ length: row }, (_, previousRow) => { const top = Array.from({ length: row }, (_, previousRow) => {
@@ -167,6 +179,7 @@ export function resolveGridCellGeometry(
plotHeight, plotHeight,
cellHeight, cellHeight,
xAxisBand, xAxisBand,
isLastRow: row === rowCount - 1,
} }
}) })
} }

View File

@@ -9,6 +9,7 @@ import {
findClosestTrackAtPointer, findClosestTrackAtPointer,
MAX_MULTI_Y_AXIS_COUNT, MAX_MULTI_Y_AXIS_COUNT,
measureYAxisGroupClearance, measureYAxisGroupClearance,
resolveYAxisTickCount,
} from './layout' } from './layout'
function series(id: string, minimum: number, maximum: number): DisplaySeries { function series(id: string, minimum: number, maximum: number): DisplaySeries {
@@ -64,7 +65,13 @@ function layoutForSeries(
series: sourceTrack, series: sourceTrack,
}, },
], ],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent', displayMode: 'independent',
overlayMode: 'single-axis', overlayMode: 'single-axis',
independentTransforms: [transform], independentTransforms: [transform],
@@ -106,7 +113,13 @@ describe('multi-value Y-axis grouping', () => {
series: sourceTrack, series: sourceTrack,
}, },
], ],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, grid: {
rowCount: 1,
columnCount: 1,
showPagination: false,
fillIncompleteLastRow: false,
trackLines: {},
},
displayMode: 'independent', displayMode: 'independent',
overlayMode: 'single-axis', overlayMode: 'single-axis',
independentTransforms: [zoomIdentity], independentTransforms: [zoomIdentity],
@@ -119,8 +132,15 @@ describe('multi-value Y-axis grouping', () => {
showCompactEmptyTracks: false, showCompactEmptyTracks: false,
})[0] })[0]
expect(result?.yScale.domain()).toEqual([25, 75]) expect(result?.yScale.domain()).toEqual([20, 80])
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([25, 75]) expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([20, 80])
})
it('normalizes configured Y-axis split counts', () => {
expect(resolveYAxisTickCount(100, 0)).toBe(2)
expect(resolveYAxisTickCount(100, 4.8)).toBe(4)
expect(resolveYAxisTickCount(100, Number.NaN)).toBe(5)
expect(resolveYAxisTickCount(220)).toBe(5)
}) })
it('keeps every overlaid series on one axis in single-axis mode', () => { it('keeps every overlaid series on one axis in single-axis mode', () => {
@@ -209,7 +229,13 @@ describe('multi-value Y-axis grouping', () => {
series: track([series('left', 1000, 3000), series('right', 1000, 3000)]), 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', displayMode: 'independent',
overlayMode: 'multi-axis', overlayMode: 'multi-axis',
independentTransforms: [zoomIdentity], independentTransforms: [zoomIdentity],

View File

@@ -20,6 +20,13 @@ const Y_AXIS_OUTER_PADDING = 4
const Y_AXIS_LABEL_GAP = 6 const Y_AXIS_LABEL_GAP = 6
const Y_AXIS_LABEL_BAND_WIDTH = 24 const Y_AXIS_LABEL_BAND_WIDTH = 24
export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number {
if (typeof splitNumber === 'number' && Number.isFinite(splitNumber)) {
return Math.max(2, Math.floor(splitNumber))
}
return 5
}
export interface YAxisSeriesGroup { export interface YAxisSeriesGroup {
index: number index: number
side: 'left' | 'right' side: 'left' | 'right'
@@ -104,11 +111,16 @@ export function axisTextMetrics(
nice = true, nice = true,
tickValues?: number[], tickValues?: number[],
unit?: string, unit?: string,
tickCount = 10,
): { tickTextWidth: number } { ): { tickTextWidth: number } {
const effectiveTickCount =
typeof tickCount === 'number' && Number.isFinite(tickCount)
? Math.max(2, Math.floor(tickCount))
: 10
const scale = scaleLinear(domain, [1, 0]) const scale = scaleLinear(domain, [1, 0])
if (nice) scale.nice() if (nice) scale.nice(effectiveTickCount)
const [axisMin, axisMax] = scale.domain() const [axisMin, axisMax] = scale.domain()
const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax])) const values = tickValues ?? scale.ticks(effectiveTickCount)
const topTickValue = Math.max(...values) const topTickValue = Math.max(...values)
const maximumTickCharacters = Math.max( const maximumTickCharacters = Math.max(
1, 1,
@@ -121,9 +133,9 @@ export function axisTextMetrics(
} }
} }
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number {
return ( return (
axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
.tickTextWidth + .tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
@@ -132,9 +144,9 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
) )
} }
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { function measureYAxisGroupTickClearance(group: YAxisSeriesGroup, tickCount?: number): number {
return ( return (
axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
.tickTextWidth + .tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
@@ -146,13 +158,14 @@ export function measureTrackYAxisClearance(
overlayMode: WaveformOverlayMode, overlayMode: WaveformOverlayMode,
yDomain?: WaveformYDomain, yDomain?: WaveformYDomain,
yDomains?: Record<string, WaveformYDomain>, yDomains?: Record<string, WaveformYDomain>,
tickCount?: number,
): { left: number; right: number } { ): { left: number; right: number } {
return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce( return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
(clearance, group) => { (clearance, group) => {
clearance[group.side] += clearance[group.side] +=
overlayMode === 'multi-axis' || track.visibleSeries.length === 1 overlayMode === 'multi-axis' || track.visibleSeries.length === 1
? measureYAxisGroupClearance(group) ? measureYAxisGroupClearance(group, tickCount)
: measureYAxisGroupTickClearance(group) : measureYAxisGroupTickClearance(group, tickCount)
return clearance return clearance
}, },
{ left: 0, right: 0 }, { left: 0, right: 0 },

View File

@@ -25,7 +25,7 @@ import {
type GridCellGeometry, type GridCellGeometry,
type NormalizedWaveformGridOptions, type NormalizedWaveformGridOptions,
} from './grid' } from './grid'
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout' import { axisTextMetrics, resolveYAxisSeriesGroups, resolveYAxisTickCount } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types' import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
import { applyXDomainStrategy } from './xDomain' import { applyXDomainStrategy } from './xDomain'
import { import {
@@ -54,6 +54,7 @@ export interface BuildTrackLayoutsOptions {
yDomains?: Record<string, [number, number]> yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms' timeUnit: 's' | 'ms'
xAxisLabelFormatter?: WaveformXAxisLabelFormatter xAxisLabelFormatter?: WaveformXAxisLabelFormatter
yAxisSplitNumber?: number
rendering: ResolvedWaveformRenderingOptions rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean hideSecondaryLabels: boolean
yAxisLabelX: number yAxisLabelX: number
@@ -124,22 +125,26 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
) )
const sideOffsets = { left: 0, right: 0 } const sideOffsets = { left: 0, right: 0 }
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => { const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
const niceCount = Math.max(1, tickCount - 1)
const scale = scaleLinear(group.domain, [cell.plotHeight, 0]) const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
if (!group.fixed) scale.nice() scale.nice(niceCount)
const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
const [axisStart, axisEnd] = scale.domain() const [axisStart, axisEnd] = scale.domain()
const majorTicks = Array.from(
{ length: tickCount },
(_, index) => axisStart + ((axisEnd - axisStart) * index) / (tickCount - 1),
)
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0 const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
const visibleMajorTicks = showAxisEnd const visibleMajorTicks = showAxisEnd
? majorTicks ? majorTicks
: majorTicks.filter((tick) => tick !== axisEnd) : majorTicks.filter((tick) => tick !== axisEnd)
const tickValues = Array.from( const tickValues = visibleMajorTicks
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
)
const { tickTextWidth } = axisTextMetrics( const { tickTextWidth } = axisTextMetrics(
group.domain, scale.domain() as [number, number],
!group.fixed, false,
tickValues, tickValues,
group.seriesList[0]?.unit, group.seriesList[0]?.unit,
tickCount,
) )
const clearance = const clearance =
tickTextWidth + tickTextWidth +
@@ -261,7 +266,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
(isEmpty || hasVisibleSeries) && (isEmpty || hasVisibleSeries) &&
(options.displayMode === 'independent' || (options.displayMode === 'independent' ||
(options.displayMode === 'compact' (options.displayMode === 'compact'
? cell.row === options.grid.rowCount - 1 ? (cell.isLastRow ?? cell.row === options.grid.rowCount - 1)
: bottomCells.has(cell.slotIndex))), : bottomCells.has(cell.slotIndex))),
} }
}) })

View File

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

View File

@@ -114,8 +114,13 @@ export function useWaveformLayout(context: LayoutContext) {
) )
.map( .map(
(group) => (group) =>
axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) axisTextMetrics(
.tickTextWidth, group.domain,
true,
undefined,
group.seriesList[0]?.unit,
props.axes?.y?.splitNumber,
).tickTextWidth,
) )
const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText) const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
@@ -161,6 +166,7 @@ export function useWaveformLayout(context: LayoutContext) {
props.overlayMode, props.overlayMode,
props.yDomain, props.yDomain,
props.yDomains, props.yDomains,
props.axes?.y?.splitNumber,
) )
return { return {
left: Math.max(maximum.left, clearance.left), left: Math.max(maximum.left, clearance.left),
@@ -302,6 +308,7 @@ export function useWaveformLayout(context: LayoutContext) {
: sharedYDomains.value, : sharedYDomains.value,
timeUnit: props.timeUnit, timeUnit: props.timeUnit,
xAxisLabelFormatter: props.axes?.x?.labelFormatter, xAxisLabelFormatter: props.axes?.x?.labelFormatter,
yAxisSplitNumber: props.axes?.y?.splitNumber,
rendering: renderingOptions.value, rendering: renderingOptions.value,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels, hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX, yAxisLabelX: yAxisMetrics.value.labelCenterX,

View File

@@ -65,9 +65,9 @@ describe('WaveformChart', () => {
) )
expect(endTicks[0]).toHaveLength(1) expect(endTicks[0]).toHaveLength(1)
expect(Number(endTicks[0][0].text())).toBe(0.9) expect(Number(endTicks[0][0].text())).toBe(1)
expect(endTicks[1]).toHaveLength(1) expect(endTicks[1]).toHaveLength(1)
expect(Number(endTicks[1][0].text())).toBe(1.9) expect(Number(endTicks[1][0].text())).toBe(2)
expect(endTicks[2]).toHaveLength(0) expect(endTicks[2]).toHaveLength(0)
expect(endTicks[3]).toHaveLength(0) expect(endTicks[3]).toHaveLength(0)
}) })

View File

@@ -261,7 +261,7 @@ describe('WaveformChart', () => {
}) })
expect(startTicks).toHaveLength(1) expect(startTicks).toHaveLength(1)
expect(Number(startTicks[0].text())).toBe(0.1) expect(Number(startTicks[0].text())).toBe(0)
}, },
) )
@@ -297,7 +297,7 @@ describe('WaveformChart', () => {
{ displayMode }, { displayMode },
) )
const expectedEndValues = [0.9, 4.9] const expectedEndValues = [1, 5]
wrapper.findAll('.waveform-chart__track').forEach((track, index) => { wrapper.findAll('.waveform-chart__track').forEach((track, index) => {
const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => { const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/) const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)

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)
})
})

View File

@@ -6,6 +6,26 @@ import { resizeObservers } from '../../test/setup'
import { gridSeries, mountSizedChart } from '../../test/waveformChart' import { gridSeries, mountSizedChart } from '../../test/waveformChart'
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('applies the configured Y-axis split number', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 3 },
{ x: 1, y: 97 },
],
},
{ axes: { y: { splitNumber: 5 } } },
)
expect(
wrapper
.get('.waveform-chart__axis--y')
.findAll('.tick text')
.map((tick) => tick.text()),
).toEqual(['0', '25', '50', '75', '100'])
})
it('expands the Y-axis label gutter for signed values and long exponents', async () => { it('expands the Y-axis label gutter for signed values and long exponents', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
@@ -296,7 +316,7 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
}) })
it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => { it('reacts to nice fixed Y-domain props and returns to automatic bounds', async () => {
const wrapper = await mountSizedChart({ const wrapper = await mountSizedChart({
kind: 'points', kind: 'points',
points: [ points: [
@@ -312,8 +332,8 @@ describe('WaveformChart', () => {
await wrapper.setProps({ yDomain: [3, 97] }) await wrapper.setProps({ yDomain: [3, 97] })
await flushPromises() await flushPromises()
expect(yTickLabels()).toContain('3') expect(yTickLabels()).toContain('0')
expect(yTickLabels()).toContain('97') expect(yTickLabels()).toContain('100')
await wrapper.setProps({ yDomain: undefined }) await wrapper.setProps({ yDomain: undefined })
await flushPromises() await flushPromises()

View File

@@ -174,6 +174,8 @@ export interface WaveformAxesOptions {
} }
y?: { y?: {
lineVisible?: boolean lineVisible?: boolean
/** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */
splitNumber?: number
} }
} }