23
src/components/core/constants.ts
Normal file
23
src/components/core/constants.ts
Normal 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
|
||||
59
src/components/core/grid.test.ts
Normal file
59
src/components/core/grid.test.ts
Normal 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
139
src/components/core/grid.ts
Normal 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
|
||||
}
|
||||
5
src/components/core/index.ts
Normal file
5
src/components/core/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './constants'
|
||||
export * from './grid'
|
||||
export * from './layout'
|
||||
export * from './types'
|
||||
export * from './useWaveformData'
|
||||
95
src/components/core/layout.ts
Normal file
95
src/components/core/layout.ts
Normal 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),
|
||||
}
|
||||
})
|
||||
}
|
||||
52
src/components/core/types.ts
Normal file
52
src/components/core/types.ts
Normal 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 }
|
||||
46
src/components/core/useWaveformData.ts
Normal file
46
src/components/core/useWaveformData.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user