first commit
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
李启源
2026-07-20 10:21:30 +08:00
commit ea832229da
75 changed files with 16864 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
/**
* 核心常量定义
*/
/** 通道颜色 */
export const channelColors = [
'#0960bd',
'#ff7f0e',
'#389e0d',
'#cf1322',
'#531dab',
'#08979c',
'#c41d7f',
'#434343',
'#7cb305',
'#1d39c4',
]
/** 图表边距 */
export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
/** 最小高度 */
export const minimumHeight = 180

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
getBottomRowCellIndexes,
getPageCount,
normalizeGridOptions,
paginateSeries,
resolveGridCellGeometry,
X_AXIS_BAND,
} from './grid'
describe('waveform grid helpers', () => {
it('normalizes grid counts and uses a two by one default', () => {
expect(normalizeGridOptions()).toEqual({ rowCount: 2, columnCount: 1, showPagination: true })
expect(normalizeGridOptions({ rowCount: 0, columnCount: 99 })).toEqual({
rowCount: 1,
columnCount: 10,
showPagination: true,
})
})
it('paginates row-major slots and keeps at least one page for empty data', () => {
const options = normalizeGridOptions({ rowCount: 2, columnCount: 2 })
expect(getPageCount([1, 2, 3, 4, 5].length, options)).toBe(2)
expect(paginateSeries([1, 2, 3, 4, 5], 2, options)).toEqual([5])
expect(getPageCount(0, options)).toBe(1)
})
it('resolves mode-specific gaps and bottom cells', () => {
const options = normalizeGridOptions({ rowCount: 2, columnCount: 2 })
const separated = resolveGridCellGeometry(400, 200, options, 'separated', [true, true, true, true])
const compact = resolveGridCellGeometry(400, 200, options, 'compact', [true, true, true, true])
const independent = resolveGridCellGeometry(400, 200, options, 'independent', [true, true, false, false])
expect(separated[1].left).toBeGreaterThan(separated[0].left + separated[0].width)
expect(separated[2].top).toBe(separated[0].plotHeight + 16)
expect(separated[2].xAxisBand).toBe(X_AXIS_BAND)
expect(compact[2].top).toBe(compact[0].top + compact[0].plotHeight)
expect(compact[2].xAxisBand).toBe(X_AXIS_BAND)
expect(independent[2].top).toBe(
independent[0].top + independent[0].plotHeight + X_AXIS_BAND + 14,
)
expect(independent[0].cellHeight).toBe(independent[0].plotHeight + X_AXIS_BAND)
expect(
getBottomRowCellIndexes(
separated.map((cell, index) => ({ ...cell, hasSeries: index !== 3 })),
2,
),
).toEqual(new Set([2, 1]))
})
it('accepts an independent horizontal gap without changing vertical spacing', () => {
const options = normalizeGridOptions({ rowCount: 2, columnCount: 2 })
const cells = resolveGridCellGeometry(400, 200, options, 'independent', [true, true, true, true], 64)
expect(cells[0].width).toBe(168)
expect(cells[1].left).toBe(232)
expect(cells[2].top).toBe(cells[0].plotHeight + X_AXIS_BAND + 14)
})
})

139
src/components/core/grid.ts Normal file
View File

@@ -0,0 +1,139 @@
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)
} 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
}

View File

@@ -0,0 +1,5 @@
export * from './constants'
export * from './grid'
export * from './layout'
export * from './types'
export * from './useWaveformData'

View File

@@ -0,0 +1,95 @@
import { line, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
import { selectRenderablePoints, type ResolvedWaveformRenderingOptions } from '../../core'
import type { WaveformDisplayMode, WaveformPoint } from '../../types'
import { buildMinorTicks, formatEndpointTime } from '../../utils'
import {
getBottomRowCellIndexes,
type GridCellGeometry,
type NormalizedWaveformGridOptions,
} from './grid'
import type { DisplaySeries, TrackLayout } from './types'
interface SeriesGridCell extends GridCellGeometry {
series?: DisplaySeries
}
export interface BuildTrackLayoutsOptions {
cells: SeriesGridCell[]
grid: NormalizedWaveformGridOptions
displayMode: WaveformDisplayMode
independentTransforms: ZoomTransform[]
sharedZoomDomain: [number, number]
timeUnit: 's' | 'ms'
rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean
yAxisLabelX: number
}
export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) }))
const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
return visibleCells.flatMap((cell, index) => {
const series = cell.series
if (!series) return []
const baseXScale =
options.displayMode === 'independent'
? scaleLinear(series.xDomain, [0, cell.width])
: scaleLinear(options.sharedZoomDomain, [0, cell.width])
const transform =
options.displayMode === 'independent'
? (options.independentTransforms[index] ?? zoomIdentity)
: zoomIdentity
const xScale = transform.rescaleX(baseXScale)
const yScale = scaleLinear(series.yDomain, [cell.plotHeight, 0]).nice()
const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
const yMajorTicks = yScale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
const yAxisTickValues =
options.displayMode === 'compact' && cell.row < options.grid.rowCount - 1
? yMajorTicks.slice(1)
: yMajorTicks
const domain = xScale.domain() as [number, number]
const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit),
end: formatEndpointTime(domain[1], domain, options.timeUnit),
}
const leftClearance = endpointLabels.start.length * 7 + 10
const rightClearance = endpointLabels.end.length * 7 + 10
const xAxisTickValues = xMajorTicks.filter((tick) => {
const position = xScale(tick)
return position > leftClearance && position < cell.width - rightClearance
})
const renderPoints = selectRenderablePoints(
series.points,
domain,
cell.width,
options.rendering,
)
return {
index,
series,
column: cell.column,
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
yAxisLabelX: options.yAxisLabelX,
left: cell.left,
top: cell.top,
width: cell.width,
height: cell.plotHeight,
xScale,
yScale,
xMajorTicks,
xMinorTicks: buildMinorTicks(xMajorTicks),
yMajorTicks,
yMinorTicks: buildMinorTicks(yMajorTicks),
yAxisTickValues,
xAxisTickValues,
endpointLabels,
path: line<WaveformPoint>()
.x((point) => xScale(point.x))
.y((point) => yScale(point.y))(renderPoints),
showXAxis: options.displayMode === 'independent' || bottomCells.has(cell.slotIndex),
}
})
}

View File

@@ -0,0 +1,52 @@
import type { ScaleLinear } from 'd3'
import type { WaveformPoint } from '../../types'
/**
* 显示系列
*/
export interface DisplaySeries {
id: string
name: string
unit?: string
color: string
points: WaveformPoint[]
xDomain: [number, number]
yDomain: [number, number]
}
/**
* 悬浮的系列点
*/
export interface HoveredSeriesPoint extends DisplaySeries {
trackIndex: number
point: WaveformPoint
}
/**
* 轨道布局
*/
export interface TrackLayout {
index: number
series: DisplaySeries
column: number
showYAxisLabel: boolean
yAxisLabelX: number
left: number
top: number
width: number
height: number
xScale: ScaleLinear<number, number>
yScale: ScaleLinear<number, number>
xMajorTicks: number[]
xMinorTicks: number[]
yMajorTicks: number[]
yMinorTicks: number[]
yAxisTickValues: number[]
xAxisTickValues: number[]
endpointLabels: { start: string; end: string }
path: string | null
showXAxis: boolean
}
// 重新导出 WaveformPoint 方便使用
export type { WaveformPoint }

View File

@@ -0,0 +1,46 @@
import { shallowRef, watch } from 'vue'
import { normalizeWaveformSeries } from '../../core'
import type { WaveformData, WaveformPoint } from '../../types'
import { paddedDomain } from '../../utils'
export interface PreparedWaveformSeries {
id: string
name: string
unit?: string
color?: string
points: WaveformPoint[]
xDomain: [number, number]
yDomain: [number, number]
}
function pointDomain(points: WaveformPoint[], key: 'x' | 'y'): [number, number] {
let minimum = Number.POSITIVE_INFINITY
let maximum = Number.NEGATIVE_INFINITY
points.forEach((point) => {
const value = point[key]
if (value < minimum) minimum = value
if (value > maximum) maximum = value
})
return paddedDomain(Number.isFinite(minimum) ? [minimum, maximum] : [])
}
export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSeries[] {
return normalizeWaveformSeries(data).map((series) => ({
...series,
xDomain: pointDomain(series.points, 'x'),
yDomain: pointDomain(series.points, 'y'),
}))
}
export function usePreparedWaveformSeries(
data: () => WaveformData,
onDataChange: () => void,
) {
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(prepareWaveformSeries(data()))
watch(data, (nextData) => {
preparedSeries.value = prepareWaveformSeries(nextData)
onDataChange()
})
return preparedSeries
}