feat(chart): add series styling and visibility controls
This commit is contained in:
@@ -1,13 +1,23 @@
|
||||
import { zoomIdentity } from 'd3'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core'
|
||||
import type { DisplaySeries, DisplayTrack } from './types'
|
||||
import { buildYAxisSeriesGroups, MAX_MULTI_Y_AXIS_COUNT } from './layout'
|
||||
import {
|
||||
buildTrackLayouts,
|
||||
buildYAxisSeriesGroups,
|
||||
MAX_MULTI_Y_AXIS_COUNT,
|
||||
measureYAxisGroupClearance,
|
||||
} from './layout'
|
||||
|
||||
function series(id: string, minimum: number, maximum: number): DisplaySeries {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
color: '#1677ff',
|
||||
lineType: 'linear',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
||||
points: [
|
||||
{ x: 0, y: minimum },
|
||||
{ x: 1, y: maximum },
|
||||
@@ -21,11 +31,49 @@ function track(seriesList: DisplaySeries[]): DisplayTrack {
|
||||
return {
|
||||
id: 'track',
|
||||
series: seriesList,
|
||||
visibleSeries: seriesList,
|
||||
xDomain: [0, 1],
|
||||
yDomain: [0, 50],
|
||||
}
|
||||
}
|
||||
|
||||
function layoutForSeries(
|
||||
sourceSeries: DisplaySeries,
|
||||
rendering = DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
transform = zoomIdentity,
|
||||
) {
|
||||
const sourceTrack = track([sourceSeries])
|
||||
sourceTrack.xDomain = sourceSeries.xDomain
|
||||
sourceTrack.yDomain = sourceSeries.yDomain
|
||||
return buildTrackLayouts({
|
||||
cells: [
|
||||
{
|
||||
slotIndex: 0,
|
||||
row: 0,
|
||||
column: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 120,
|
||||
height: 100,
|
||||
plotHeight: 100,
|
||||
cellHeight: 130,
|
||||
xAxisBand: 30,
|
||||
series: sourceTrack,
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
||||
displayMode: 'independent',
|
||||
overlayMode: 'single-axis',
|
||||
independentTransforms: [transform],
|
||||
sharedZoomDomain: sourceSeries.xDomain,
|
||||
timeUnit: 'ms',
|
||||
rendering,
|
||||
hideSecondaryLabels: false,
|
||||
yAxisLabelX: -50,
|
||||
showCompactEmptyTracks: false,
|
||||
})[0]!.seriesPaths[0]!
|
||||
}
|
||||
|
||||
describe('multi-value Y-axis grouping', () => {
|
||||
it('keeps every overlaid series on one axis in single-axis mode', () => {
|
||||
const groups = buildYAxisSeriesGroups(
|
||||
@@ -95,4 +143,120 @@ describe('multi-value Y-axis grouping', () => {
|
||||
'right',
|
||||
])
|
||||
})
|
||||
|
||||
it('places left and right scientific exponents eight pixels outside their tick labels', () => {
|
||||
const layout = buildTrackLayouts({
|
||||
cells: [
|
||||
{
|
||||
slotIndex: 0,
|
||||
row: 0,
|
||||
column: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 600,
|
||||
height: 300,
|
||||
plotHeight: 300,
|
||||
cellHeight: 330,
|
||||
xAxisBand: 30,
|
||||
series: track([series('left', 0, 254), series('right', 0, 254)]),
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false },
|
||||
displayMode: 'independent',
|
||||
overlayMode: 'multi-axis',
|
||||
independentTransforms: [zoomIdentity],
|
||||
sharedZoomDomain: [0, 1],
|
||||
timeUnit: 'ms',
|
||||
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
hideSecondaryLabels: false,
|
||||
yAxisLabelX: -50,
|
||||
showCompactEmptyTracks: false,
|
||||
})[0]
|
||||
|
||||
expect(
|
||||
layout?.yAxes.map(({ side, x, exponentX, exponentLabel }) => ({
|
||||
side,
|
||||
offset: Math.abs(exponentX - x),
|
||||
exponentLabel,
|
||||
})),
|
||||
).toEqual([
|
||||
{ side: 'left', offset: 43, exponentLabel: 'E+02' },
|
||||
{ side: 'right', offset: 43, exponentLabel: 'E+02' },
|
||||
])
|
||||
})
|
||||
|
||||
it('retains enough outer clearance for long scientific exponents', () => {
|
||||
const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis')
|
||||
|
||||
expect(group).toBeDefined()
|
||||
expect(measureYAxisGroupClearance(group!)).toBe(119)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decoration sampling', () => {
|
||||
const denseSeries = (): DisplaySeries => ({
|
||||
...series('dense', -1, 1),
|
||||
pointType: 'circle',
|
||||
errorBar: { visible: true, width: 1.5, capWidth: 8 },
|
||||
points: Array.from({ length: 1_000 }, (_, index) => ({
|
||||
x: index,
|
||||
y: Math.sin(index / 20),
|
||||
error: index % 200 === 1 ? 0.1 : 0,
|
||||
})),
|
||||
xDomain: [0, 999],
|
||||
})
|
||||
|
||||
it('shares prioritized source points between dense symbols and error bars', () => {
|
||||
const sourceSeries = denseSeries()
|
||||
const path = layoutForSeries(sourceSeries, {
|
||||
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
pointMinSpacing: 10,
|
||||
errorBarMinSpacing: 12,
|
||||
})
|
||||
const sourceErrorPoints = sourceSeries.points.filter((point) => point.error !== 0)
|
||||
|
||||
expect(path.errorBarRenderPoints).toEqual(sourceErrorPoints)
|
||||
expect(path.errorBarRenderPoints.every((point) => path.pointRenderPoints.includes(point))).toBe(
|
||||
true,
|
||||
)
|
||||
expect(path.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 12) + 2)
|
||||
})
|
||||
|
||||
it('keeps standalone, zero-error, and non-downsampled decoration behavior', () => {
|
||||
const noErrors = denseSeries()
|
||||
noErrors.points = noErrors.points.map((point) => ({ x: point.x, y: point.y }))
|
||||
const zeroErrorPath = layoutForSeries(noErrors)
|
||||
expect(zeroErrorPath.errorBarRenderPoints).toEqual([])
|
||||
expect(zeroErrorPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2)
|
||||
|
||||
const errorsOnly = denseSeries()
|
||||
errorsOnly.pointType = 'none'
|
||||
const errorsOnlyPath = layoutForSeries(errorsOnly)
|
||||
expect(errorsOnlyPath.pointRenderPoints).toEqual([])
|
||||
expect(errorsOnlyPath.errorBarRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 12) + 2)
|
||||
|
||||
const pointsOnly = denseSeries()
|
||||
pointsOnly.errorBar.visible = false
|
||||
const pointsOnlyPath = layoutForSeries(pointsOnly)
|
||||
expect(pointsOnlyPath.errorBarRenderPoints).toEqual([])
|
||||
expect(pointsOnlyPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2)
|
||||
|
||||
const completePath = layoutForSeries(denseSeries(), {
|
||||
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
downsample: false,
|
||||
})
|
||||
expect(completePath.pointRenderPoints).toHaveLength(1_000)
|
||||
expect(completePath.errorBarRenderPoints).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('restores every visible source decoration after zooming to sparse spacing', () => {
|
||||
const path = layoutForSeries(
|
||||
denseSeries(),
|
||||
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
zoomIdentity.scale(200),
|
||||
)
|
||||
|
||||
expect(path.pointRenderPoints.map((point) => point.x)).toEqual([0, 1, 2, 3, 4])
|
||||
expect(path.errorBarRenderPoints.map((point) => point.x)).toEqual([1])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import { line, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
||||
import {
|
||||
curveStep,
|
||||
curveStepAfter,
|
||||
curveStepBefore,
|
||||
line,
|
||||
scaleLinear,
|
||||
zoomIdentity,
|
||||
type ZoomTransform,
|
||||
} from 'd3'
|
||||
|
||||
import { selectRenderablePoints, type ResolvedWaveformRenderingOptions } from '../../core'
|
||||
import {
|
||||
selectDecorationPoints,
|
||||
selectRenderablePoints,
|
||||
resolveWaveformPointErrors,
|
||||
type ResolvedWaveformRenderingOptions,
|
||||
} from '../../core'
|
||||
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
|
||||
import {
|
||||
buildMinorTicks,
|
||||
@@ -23,7 +36,7 @@ const Y_AXIS_TICK_PADDING = 7
|
||||
const Y_AXIS_OUTER_PADDING = 4
|
||||
const Y_AXIS_LABEL_GAP = 6
|
||||
const Y_AXIS_LABEL_BAND_WIDTH = 24
|
||||
const Y_AXIS_EXPONENT_GAP = 4
|
||||
export const Y_AXIS_EXPONENT_GAP = 8
|
||||
|
||||
interface YAxisSeriesGroup {
|
||||
index: number
|
||||
@@ -57,8 +70,8 @@ export function buildYAxisSeriesGroups(
|
||||
if (cached) return cached
|
||||
const axisCount =
|
||||
overlayMode === 'multi-axis'
|
||||
? Math.min(track.series.length, MAX_MULTI_Y_AXIS_COUNT)
|
||||
: Math.min(track.series.length, 1)
|
||||
? Math.min(track.visibleSeries.length, MAX_MULTI_Y_AXIS_COUNT)
|
||||
: Math.min(track.visibleSeries.length, 1)
|
||||
const sides = resolveAxisSides(axisCount)
|
||||
const grouped = Array.from({ length: axisCount }, (_, index) => ({
|
||||
index,
|
||||
@@ -67,7 +80,7 @@ export function buildYAxisSeriesGroups(
|
||||
domain: [0, 1] as [number, number],
|
||||
}))
|
||||
|
||||
track.series.forEach((series, index) => {
|
||||
track.visibleSeries.forEach((series, index) => {
|
||||
grouped[Math.min(index, axisCount - 1)]?.seriesList.push(series)
|
||||
})
|
||||
grouped.forEach((group) => {
|
||||
@@ -136,7 +149,7 @@ export function measureTrackYAxisClearance(
|
||||
return buildYAxisSeriesGroups(track, overlayMode).reduce(
|
||||
(clearance, group) => {
|
||||
clearance[group.side] +=
|
||||
overlayMode === 'multi-axis' || track.series.length === 1
|
||||
overlayMode === 'multi-axis' || track.visibleSeries.length === 1
|
||||
? measureYAxisGroupClearance(group)
|
||||
: measureYAxisGroupTickClearance(group)
|
||||
return clearance
|
||||
@@ -174,6 +187,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
id: `empty-grid-slot-${cell.slotIndex}`,
|
||||
name: '',
|
||||
color: 'transparent',
|
||||
lineType: 'linear',
|
||||
pointType: 'none',
|
||||
errorBar: { visible: false, width: 1.5, capWidth: 8 },
|
||||
points: [],
|
||||
xDomain: [0, 1],
|
||||
yDomain: [0, 1],
|
||||
@@ -181,10 +197,12 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const displayTrack: DisplayTrack = cell.series ?? {
|
||||
id: emptySeries.id,
|
||||
series: [emptySeries],
|
||||
visibleSeries: [emptySeries],
|
||||
xDomain: emptySeries.xDomain,
|
||||
yDomain: emptySeries.yDomain,
|
||||
}
|
||||
const series = displayTrack.series[0]
|
||||
const hasVisibleSeries = !isEmpty && displayTrack.visibleSeries.length > 0
|
||||
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
|
||||
const baseXScale =
|
||||
options.displayMode === 'independent'
|
||||
? scaleLinear(displayTrack.xDomain, [0, cell.width])
|
||||
@@ -220,8 +238,8 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const exponentX =
|
||||
x +
|
||||
(group.side === 'left'
|
||||
? -(Y_AXIS_TICK_PADDING + tickTextWidth + exponentClearance)
|
||||
: Y_AXIS_TICK_PADDING + tickTextWidth + exponentClearance)
|
||||
? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
|
||||
: Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
|
||||
const labelDistance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
@@ -261,24 +279,69 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const position = xScale(tick)
|
||||
return position > leftClearance && position < cell.width - rightClearance
|
||||
})
|
||||
const seriesPaths = displayTrack.series.map((trackSeries) => {
|
||||
const seriesPaths = displayTrack.visibleSeries.map((trackSeries) => {
|
||||
const yAxis = yAxes.find((axis) =>
|
||||
axis.seriesList.some((series) => series.id === trackSeries.id),
|
||||
)
|
||||
const seriesYScale = yAxis?.scale ?? yScale
|
||||
const renderPoints = selectRenderablePoints(
|
||||
const pathPoints = selectRenderablePoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
options.rendering,
|
||||
)
|
||||
const hasError = (point: WaveformPoint) => {
|
||||
const { lower, upper } = resolveWaveformPointErrors(point)
|
||||
return lower !== 0 || upper !== 0
|
||||
}
|
||||
const hasErrorPoints = trackSeries.errorBar.visible && trackSeries.points.some(hasError)
|
||||
const sharesDecorationPoints = trackSeries.pointType !== 'none' && hasErrorPoints
|
||||
const sharedDecorationPoints = sharesDecorationPoints
|
||||
? selectDecorationPoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
Math.max(options.rendering.pointMinSpacing, options.rendering.errorBarMinSpacing),
|
||||
options.rendering.downsample,
|
||||
undefined,
|
||||
hasError,
|
||||
)
|
||||
: undefined
|
||||
const pointRenderPoints =
|
||||
trackSeries.pointType === 'none'
|
||||
? []
|
||||
: (sharedDecorationPoints ??
|
||||
selectDecorationPoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
options.rendering.pointMinSpacing,
|
||||
options.rendering.downsample,
|
||||
))
|
||||
const errorBarRenderPoints = trackSeries.errorBar.visible
|
||||
? (sharedDecorationPoints?.filter(hasError) ??
|
||||
selectDecorationPoints(
|
||||
trackSeries.points,
|
||||
domain,
|
||||
cell.width,
|
||||
options.rendering.errorBarMinSpacing,
|
||||
options.rendering.downsample,
|
||||
hasError,
|
||||
))
|
||||
: []
|
||||
const pathGenerator = line<WaveformPoint>()
|
||||
.x((point) => xScale(point.x))
|
||||
.y((point) => seriesYScale(point.y))
|
||||
if (trackSeries.lineType === 'step-start') pathGenerator.curve(curveStepBefore)
|
||||
if (trackSeries.lineType === 'step-middle') pathGenerator.curve(curveStep)
|
||||
if (trackSeries.lineType === 'step-end' || trackSeries.lineType === 'step-after') {
|
||||
pathGenerator.curve(curveStepAfter)
|
||||
}
|
||||
return {
|
||||
series: trackSeries,
|
||||
path: isEmpty
|
||||
? null
|
||||
: line<WaveformPoint>()
|
||||
.x((point) => xScale(point.x))
|
||||
.y((point) => seriesYScale(point.y))(renderPoints),
|
||||
path: isEmpty || trackSeries.lineType === 'none' ? null : pathGenerator(pathPoints),
|
||||
pointRenderPoints,
|
||||
errorBarRenderPoints,
|
||||
yScale: seriesYScale,
|
||||
yAxisIndex: yAxis?.index ?? 0,
|
||||
}
|
||||
@@ -287,8 +350,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
return {
|
||||
index,
|
||||
series,
|
||||
seriesList: displayTrack.series,
|
||||
seriesList: displayTrack.visibleSeries,
|
||||
legendSeries: displayTrack.series,
|
||||
isEmpty,
|
||||
hasVisibleSeries,
|
||||
column: cell.column,
|
||||
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
|
||||
yAxisLabelX: options.yAxisLabelX,
|
||||
@@ -310,10 +375,11 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
path: seriesPaths[0]?.path ?? null,
|
||||
seriesPaths,
|
||||
showXAxis:
|
||||
options.displayMode === 'independent' ||
|
||||
(options.displayMode === 'compact'
|
||||
? cell.row === options.grid.rowCount - 1
|
||||
: bottomCells.has(cell.slotIndex)),
|
||||
(isEmpty || hasVisibleSeries) &&
|
||||
(options.displayMode === 'independent' ||
|
||||
(options.displayMode === 'compact'
|
||||
? cell.row === options.grid.rowCount - 1
|
||||
: bottomCells.has(cell.slotIndex))),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { ScaleLinear } from 'd3'
|
||||
import type { WaveformPoint } from '../../types'
|
||||
import type {
|
||||
ResolvedWaveformErrorBarOptions,
|
||||
WaveformLineType,
|
||||
WaveformPoint,
|
||||
WaveformPointType,
|
||||
} from '../../types'
|
||||
|
||||
/**
|
||||
* 显示系列
|
||||
@@ -10,6 +15,9 @@ export interface DisplaySeries {
|
||||
name: string
|
||||
unit?: string
|
||||
color: string
|
||||
lineType: WaveformLineType
|
||||
pointType: WaveformPointType
|
||||
errorBar: ResolvedWaveformErrorBarOptions
|
||||
points: WaveformPoint[]
|
||||
xDomain: [number, number]
|
||||
yDomain: [number, number]
|
||||
@@ -17,7 +25,10 @@ export interface DisplaySeries {
|
||||
|
||||
export interface DisplayTrack {
|
||||
id: string
|
||||
/** Complete series list retained for legend rendering and visibility restoration. */
|
||||
series: DisplaySeries[]
|
||||
/** Series currently participating in layout, rendering, and interaction. */
|
||||
visibleSeries: DisplaySeries[]
|
||||
xDomain: [number, number]
|
||||
yDomain: [number, number]
|
||||
}
|
||||
@@ -25,6 +36,8 @@ export interface DisplayTrack {
|
||||
export interface TrackSeriesPath {
|
||||
series: DisplaySeries
|
||||
path: string | null
|
||||
pointRenderPoints: WaveformPoint[]
|
||||
errorBarRenderPoints: WaveformPoint[]
|
||||
yScale: ScaleLinear<number, number>
|
||||
yAxisIndex: number
|
||||
}
|
||||
@@ -57,8 +70,12 @@ export interface HoveredSeriesPoint extends DisplaySeries {
|
||||
export interface TrackLayout {
|
||||
index: number
|
||||
series: DisplaySeries
|
||||
/** Visible series used by rendering and interaction code. */
|
||||
seriesList: DisplaySeries[]
|
||||
/** Complete series list used by the legend. */
|
||||
legendSeries: DisplaySeries[]
|
||||
isEmpty: boolean
|
||||
hasVisibleSeries: boolean
|
||||
column: number
|
||||
showYAxisLabel: boolean
|
||||
yAxisLabelX: number
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { shallowRef, watch } from 'vue'
|
||||
|
||||
import { normalizeWaveformSeries } from '../../core'
|
||||
import type { WaveformData, WaveformPoint } from '../../types'
|
||||
import { normalizeWaveformSeries, resolveWaveformPointErrors } from '../../core'
|
||||
import type {
|
||||
ResolvedWaveformErrorBarOptions,
|
||||
WaveformData,
|
||||
WaveformLineType,
|
||||
WaveformPoint,
|
||||
WaveformPointType,
|
||||
} from '../../types'
|
||||
import { paddedDomain } from '../../utils'
|
||||
|
||||
export interface PreparedWaveformSeries {
|
||||
@@ -10,18 +16,30 @@ export interface PreparedWaveformSeries {
|
||||
name: string
|
||||
unit?: string
|
||||
color?: string
|
||||
lineType: WaveformLineType
|
||||
pointType: WaveformPointType
|
||||
errorBar: ResolvedWaveformErrorBarOptions
|
||||
points: WaveformPoint[]
|
||||
xDomain: [number, number]
|
||||
yDomain: [number, number]
|
||||
}
|
||||
|
||||
function pointDomain(points: WaveformPoint[], key: 'x' | 'y'): [number, number] {
|
||||
function pointDomain(
|
||||
points: WaveformPoint[],
|
||||
key: 'x' | 'y',
|
||||
includeErrors = false,
|
||||
): [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
|
||||
if (key === 'y' && includeErrors) {
|
||||
const errors = resolveWaveformPointErrors(point)
|
||||
minimum = Math.min(minimum, point.y - errors.lower)
|
||||
maximum = Math.max(maximum, point.y + errors.upper)
|
||||
}
|
||||
})
|
||||
return paddedDomain(Number.isFinite(minimum) ? [minimum, maximum] : [])
|
||||
}
|
||||
@@ -30,7 +48,7 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie
|
||||
return normalizeWaveformSeries(data).map((series) => ({
|
||||
...series,
|
||||
xDomain: pointDomain(series.points, 'x'),
|
||||
yDomain: pointDomain(series.points, 'y'),
|
||||
yDomain: pointDomain(series.points, 'y', series.errorBar.visible),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user