2026-07-20 10:21:30 +08:00
|
|
|
import type { WaveformDisplayMode } from '../../types'
|
|
|
|
|
|
|
|
|
|
export const GRID_MIN_COUNT = 1
|
|
|
|
|
export const GRID_MAX_COUNT = 10
|
|
|
|
|
export const X_AXIS_BAND = 16
|
|
|
|
|
|
|
|
|
|
export interface WaveformGridOptions {
|
|
|
|
|
rowCount?: number
|
|
|
|
|
columnCount?: number
|
|
|
|
|
showPagination?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface NormalizedWaveformGridOptions {
|
|
|
|
|
rowCount: number
|
|
|
|
|
columnCount: number
|
|
|
|
|
showPagination: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GridCellGeometry {
|
|
|
|
|
slotIndex: number
|
|
|
|
|
row: number
|
|
|
|
|
column: number
|
|
|
|
|
left: number
|
|
|
|
|
top: number
|
|
|
|
|
width: number
|
|
|
|
|
/** Backward-compatible alias for the actual plot height. */
|
|
|
|
|
height: number
|
|
|
|
|
plotHeight: number
|
|
|
|
|
cellHeight: number
|
|
|
|
|
xAxisBand: number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const normalizeCount = (value: unknown, fallback: number) => {
|
|
|
|
|
const numeric = typeof value === 'number' ? value : Number(value)
|
|
|
|
|
if (!Number.isFinite(numeric)) return fallback
|
|
|
|
|
return Math.min(GRID_MAX_COUNT, Math.max(GRID_MIN_COUNT, Math.floor(numeric)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedWaveformGridOptions {
|
|
|
|
|
return {
|
|
|
|
|
rowCount: normalizeCount(options?.rowCount, 2),
|
|
|
|
|
columnCount: normalizeCount(options?.columnCount, 1),
|
|
|
|
|
showPagination: options?.showPagination ?? true,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getPageSize(options: NormalizedWaveformGridOptions): number {
|
|
|
|
|
return options.rowCount * options.columnCount
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getPageCount(seriesCount: number, options: NormalizedWaveformGridOptions): number {
|
|
|
|
|
return Math.max(1, Math.ceil(Math.max(0, seriesCount) / getPageSize(options)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function paginateSeries<T>(
|
|
|
|
|
series: T[],
|
|
|
|
|
page: number,
|
|
|
|
|
options: NormalizedWaveformGridOptions,
|
|
|
|
|
): T[] {
|
|
|
|
|
const pageCount = getPageCount(series.length, options)
|
|
|
|
|
const safePage = Math.min(pageCount, Math.max(1, Math.floor(page)))
|
|
|
|
|
const start = (safePage - 1) * getPageSize(options)
|
|
|
|
|
return series.slice(start, start + getPageSize(options))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getGridGap(displayMode: WaveformDisplayMode): number {
|
|
|
|
|
return displayMode === 'compact' ? 0 : displayMode === 'separated' ? 16 : 14
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function resolveGridCellGeometry(
|
|
|
|
|
innerWidth: number,
|
|
|
|
|
innerHeight: number,
|
|
|
|
|
options: NormalizedWaveformGridOptions,
|
|
|
|
|
displayMode: WaveformDisplayMode,
|
|
|
|
|
slotHasSeries: boolean[] = [],
|
|
|
|
|
horizontalGap?: number,
|
|
|
|
|
): GridCellGeometry[] {
|
|
|
|
|
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 (displayMode === 'independent') {
|
|
|
|
|
for (let row = 0; row < options.rowCount; row += 1) axisRows.add(row)
|
2026-07-20 11:27:50 +08:00
|
|
|
} 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)
|
2026-07-20 10:21:30 +08:00
|
|
|
} else {
|
|
|
|
|
for (let column = 0; column < options.columnCount; column += 1) {
|
|
|
|
|
for (let slotIndex = getPageSize(options) - 1; slotIndex >= 0; slotIndex -= 1) {
|
|
|
|
|
if (slotIndex % options.columnCount !== column) continue
|
|
|
|
|
if (slotHasSeries[slotIndex]) {
|
|
|
|
|
axisRows.add(Math.floor(slotIndex / options.columnCount))
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const totalVerticalGap = Math.max(0, options.rowCount - 1) * defaultGap
|
|
|
|
|
const totalAxisBand = axisRows.size * X_AXIS_BAND
|
|
|
|
|
const width = Math.max(1, (innerWidth - totalHorizontalGap) / options.columnCount)
|
|
|
|
|
const plotHeight = Math.max(1, (innerHeight - totalVerticalGap - totalAxisBand) / options.rowCount)
|
|
|
|
|
|
|
|
|
|
return Array.from({ length: getPageSize(options) }, (_, slotIndex) => {
|
|
|
|
|
const row = Math.floor(slotIndex / options.columnCount)
|
|
|
|
|
const column = slotIndex % options.columnCount
|
|
|
|
|
const xAxisBand = axisRows.has(row) ? X_AXIS_BAND : 0
|
|
|
|
|
const cellHeight = plotHeight + xAxisBand
|
|
|
|
|
const top = Array.from({ length: row }, (_, previousRow) => {
|
|
|
|
|
const previousBand = axisRows.has(previousRow) ? X_AXIS_BAND : 0
|
|
|
|
|
return plotHeight + previousBand + defaultGap
|
|
|
|
|
}).reduce((sum, value) => sum + value, 0)
|
|
|
|
|
return {
|
|
|
|
|
slotIndex,
|
|
|
|
|
row,
|
|
|
|
|
column,
|
|
|
|
|
left: column * (width + columnGap),
|
|
|
|
|
top,
|
|
|
|
|
width,
|
|
|
|
|
height: plotHeight,
|
|
|
|
|
plotHeight,
|
|
|
|
|
cellHeight,
|
|
|
|
|
xAxisBand,
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getBottomRowCellIndexes(
|
|
|
|
|
cells: Array<GridCellGeometry & { hasSeries?: boolean }>,
|
|
|
|
|
columnCount: number,
|
|
|
|
|
): Set<number> {
|
|
|
|
|
const visible = new Set<number>()
|
|
|
|
|
for (let column = 0; column < columnCount; column += 1) {
|
|
|
|
|
for (let index = cells.length - 1; index >= 0; index -= 1) {
|
|
|
|
|
const cell = cells[index]
|
|
|
|
|
if (cell.column === column && cell.hasSeries) {
|
|
|
|
|
visible.add(cell.slotIndex)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return visible
|
|
|
|
|
}
|