feat(chart): add configurable chart presentation
Some checks failed
CI / verify (push) Failing after 1m31s
CI / verify (pull_request) Failing after 31s

This commit is contained in:
李启源
2026-07-20 14:07:43 +08:00
parent f535e9611a
commit dda75b5d13
24 changed files with 2242 additions and 201 deletions

View File

@@ -8,10 +8,10 @@ import {
type GridCellGeometry,
type NormalizedWaveformGridOptions,
} from './grid'
import type { DisplaySeries, TrackLayout } from './types'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
interface SeriesGridCell extends GridCellGeometry {
series?: DisplaySeries
series?: DisplayTrack
}
export interface BuildTrackLayoutsOptions {
@@ -34,7 +34,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
return visibleCells.flatMap((cell, index) => {
const isEmpty = !cell.series
if (isEmpty && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks)) return []
const series: DisplaySeries = cell.series ?? {
const emptySeries: DisplaySeries = {
id: `empty-grid-slot-${cell.slotIndex}`,
name: '',
color: 'transparent',
@@ -42,16 +42,23 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
xDomain: [0, 1],
yDomain: [0, 1],
}
const displayTrack: DisplayTrack = cell.series ?? {
id: emptySeries.id,
series: [emptySeries],
xDomain: emptySeries.xDomain,
yDomain: emptySeries.yDomain,
}
const series = displayTrack.series[0]
const baseXScale =
options.displayMode === 'independent'
? scaleLinear(series.xDomain, [0, cell.width])
? scaleLinear(displayTrack.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 yScale = scaleLinear(displayTrack.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 [yAxisStart, yAxisEnd] = yScale.domain()
@@ -73,16 +80,27 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const position = xScale(tick)
return position > leftClearance && position < cell.width - rightClearance
})
const renderPoints = selectRenderablePoints(
series.points,
domain,
cell.width,
options.rendering,
)
const seriesPaths = displayTrack.series.map((trackSeries) => {
const renderPoints = selectRenderablePoints(
trackSeries.points,
domain,
cell.width,
options.rendering,
)
return {
series: trackSeries,
path: isEmpty
? null
: line<WaveformPoint>()
.x((point) => xScale(point.x))
.y((point) => yScale(point.y))(renderPoints),
}
})
return {
index,
series,
seriesList: displayTrack.series,
isEmpty,
column: cell.column,
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
@@ -100,11 +118,8 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
yAxisTickValues,
xAxisTickValues,
endpointLabels,
path: isEmpty
? null
: line<WaveformPoint>()
.x((point) => xScale(point.x))
.y((point) => yScale(point.y))(renderPoints),
path: seriesPaths[0]?.path ?? null,
seriesPaths,
showXAxis:
options.displayMode === 'independent' ||
(options.displayMode === 'compact'

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { calculateRotatedTitleLayout, TITLE_AREA_MAX_HEIGHT, TITLE_AREA_MIN_HEIGHT } from './title'
describe('calculateRotatedTitleLayout', () => {
it('reserves the available width for an unrotated title that fits', () => {
expect(
calculateRotatedTitleLayout({
naturalWidth: 200,
naturalHeight: 20,
availableWidth: 300,
rotation: 0,
}),
).toEqual({
textWidth: 300,
textHeight: 20,
visualWidth: 300,
visualHeight: 20,
areaHeight: TITLE_AREA_MIN_HEIGHT,
scale: 1,
wrapped: false,
})
})
it.each([45, 90, -90, 180])('keeps a %s degree title inside the maximum area', (rotation) => {
const layout = calculateRotatedTitleLayout({
naturalWidth: 400,
naturalHeight: 20,
availableWidth: 600,
rotation,
})
expect(layout.areaHeight).toBeGreaterThanOrEqual(TITLE_AREA_MIN_HEIGHT)
expect(layout.areaHeight).toBeLessThanOrEqual(TITLE_AREA_MAX_HEIGHT)
expect(layout.visualHeight).toBeLessThanOrEqual(TITLE_AREA_MAX_HEIGHT)
expect(layout.textWidth).toBe(400)
expect(layout.scale).toBeLessThanOrEqual(1)
expect(layout.wrapped).toBe(false)
})
it('wraps an unrotated title to the available width without scaling', () => {
const layout = calculateRotatedTitleLayout({
naturalWidth: 1_000,
naturalHeight: 20,
availableWidth: 120,
rotation: 0,
})
expect(layout.textWidth).toBe(120)
expect(layout.textHeight).toBe(180)
expect(layout.visualWidth).toBe(120)
expect(layout.visualHeight).toBe(180)
expect(layout.areaHeight).toBe(198)
expect(layout.scale).toBe(1)
expect(layout.wrapped).toBe(true)
})
it('falls back safely for non-finite dimensions and rotation', () => {
const layout = calculateRotatedTitleLayout({
naturalWidth: Number.NaN,
naturalHeight: Number.POSITIVE_INFINITY,
availableWidth: Number.NaN,
rotation: Number.NaN,
})
expect(layout).toEqual({
textWidth: 1,
textHeight: 1,
visualWidth: 1,
visualHeight: 1,
areaHeight: TITLE_AREA_MIN_HEIGHT,
scale: 1,
wrapped: false,
})
})
})

View File

@@ -0,0 +1,88 @@
export const TITLE_AREA_MIN_HEIGHT = 44
export const TITLE_AREA_MAX_HEIGHT = 160
export const TITLE_AREA_HORIZONTAL_PADDING = 24
export const TITLE_AREA_VERTICAL_PADDING = 18
const TRIGONOMETRY_EPSILON = 1e-6
export interface RotatedTitleLayout {
textWidth: number
textHeight: number
visualWidth: number
visualHeight: number
areaHeight: number
scale: number
wrapped: boolean
}
export interface RotatedTitleLayoutOptions {
naturalWidth: number
naturalHeight: number
availableWidth: number
rotation: number
}
function clampPositive(value: number): number {
return Number.isFinite(value) ? Math.max(1, value) : 1
}
export function calculateRotatedTitleLayout({
naturalWidth,
naturalHeight,
availableWidth,
rotation,
}: RotatedTitleLayoutOptions): RotatedTitleLayout {
const safeNaturalWidth = clampPositive(naturalWidth)
const safeNaturalHeight = clampPositive(naturalHeight)
const safeAvailableWidth = clampPositive(availableWidth)
const safeRotation = Number.isFinite(rotation) ? rotation : 0
const radians = (safeRotation * Math.PI) / 180
const absoluteCosine = Math.abs(Math.cos(radians))
const absoluteSine = Math.abs(Math.sin(radians))
const normalizedRotation = ((safeRotation % 360) + 360) % 360
const isRotated =
normalizedRotation > TRIGONOMETRY_EPSILON &&
Math.abs(normalizedRotation - 360) > TRIGONOMETRY_EPSILON
if (!isRotated) {
const lineCount = Math.max(1, Math.ceil(safeNaturalWidth / safeAvailableWidth))
const textWidth = safeAvailableWidth
const textHeight = safeNaturalHeight * lineCount
return {
textWidth,
textHeight,
visualWidth: textWidth,
visualHeight: textHeight,
areaHeight: Math.max(TITLE_AREA_MIN_HEIGHT, textHeight + TITLE_AREA_VERTICAL_PADDING),
scale: 1,
wrapped: lineCount > 1,
}
}
const maximumVisualHeight = TITLE_AREA_MAX_HEIGHT - TITLE_AREA_VERTICAL_PADDING
const naturalVisualWidth =
safeNaturalWidth * absoluteCosine + safeNaturalHeight * absoluteSine
const naturalVisualHeight =
safeNaturalWidth * absoluteSine + safeNaturalHeight * absoluteCosine
const scale = Math.min(
1,
safeAvailableWidth / naturalVisualWidth,
maximumVisualHeight / naturalVisualHeight,
)
const visualWidth = naturalVisualWidth * scale
const visualHeight = naturalVisualHeight * scale
const areaHeight = Math.min(
TITLE_AREA_MAX_HEIGHT,
Math.max(TITLE_AREA_MIN_HEIGHT, visualHeight + TITLE_AREA_VERTICAL_PADDING),
)
return {
textWidth: safeNaturalWidth,
textHeight: safeNaturalHeight,
visualWidth,
visualHeight,
areaHeight,
scale,
wrapped: false,
}
}

View File

@@ -6,6 +6,7 @@ import type { WaveformPoint } from '../../types'
*/
export interface DisplaySeries {
id: string
trackId?: string
name: string
unit?: string
color: string
@@ -14,6 +15,18 @@ export interface DisplaySeries {
yDomain: [number, number]
}
export interface DisplayTrack {
id: string
series: DisplaySeries[]
xDomain: [number, number]
yDomain: [number, number]
}
export interface TrackSeriesPath {
series: DisplaySeries
path: string | null
}
/**
* 悬浮的系列点
*/
@@ -28,6 +41,7 @@ export interface HoveredSeriesPoint extends DisplaySeries {
export interface TrackLayout {
index: number
series: DisplaySeries
seriesList: DisplaySeries[]
isEmpty: boolean
column: number
showYAxisLabel: boolean
@@ -46,6 +60,7 @@ export interface TrackLayout {
xAxisTickValues: number[]
endpointLabels: { start: string; end: string }
path: string | null
seriesPaths: TrackSeriesPath[]
showXAxis: boolean
}

View File

@@ -6,6 +6,7 @@ import { paddedDomain } from '../../utils'
export interface PreparedWaveformSeries {
id: string
trackId?: string
name: string
unit?: string
color?: string