feat(chart): add configurable chart presentation
This commit is contained in:
@@ -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'
|
||||
|
||||
76
src/components/core/title.test.ts
Normal file
76
src/components/core/title.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
88
src/components/core/title.ts
Normal file
88
src/components/core/title.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { paddedDomain } from '../../utils'
|
||||
|
||||
export interface PreparedWaveformSeries {
|
||||
id: string
|
||||
trackId?: string
|
||||
name: string
|
||||
unit?: string
|
||||
color?: string
|
||||
|
||||
Reference in New Issue
Block a user