Files
waveform-analysis/src/components/core/layout.test.ts

263 lines
8.0 KiB
TypeScript
Raw Normal View History

import { zoomIdentity } from 'd3'
import { describe, expect, it } from 'vitest'
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core'
import type { DisplaySeries, DisplayTrack } from './types'
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 },
],
xDomain: [0, 1],
yDomain: [minimum, maximum],
}
}
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(
track([series('a', 0, 1), series('b', 10, 20)]),
'single-axis',
)
expect(groups).toHaveLength(1)
expect(groups[0]?.seriesList.map((item) => item.id)).toEqual(['a', 'b'])
expect(groups[0]?.domain).toEqual([0, 50])
})
it('uses the reference left-right axis order and merges overflow into axis four', () => {
const groups = buildYAxisSeriesGroups(
track([
series('a', 0, 1),
series('b', 10, 11),
series('c', 20, 21),
series('d', 30, 31),
series('e', 40, 50),
]),
'multi-axis',
)
expect(groups).toHaveLength(MAX_MULTI_Y_AXIS_COUNT)
expect(groups.map((group) => group.side)).toEqual(['left', 'left', 'right', 'right'])
expect(groups.map((group) => group.seriesList.map((item) => item.id))).toEqual([
['a'],
['b'],
['c'],
['d', 'e'],
])
expect(groups[3]?.domain[0]).toBeLessThanOrEqual(30)
expect(groups[3]?.domain[1]).toBeGreaterThanOrEqual(50)
})
it('derives merged multi-axis domains from precomputed series domains', () => {
const first = series('a', -20, -10)
const second = series('b', 40, 60)
first.points = [{ x: 0, y: -15 }]
second.points = [{ x: 0, y: 50 }]
const groups = buildYAxisSeriesGroups(
track([
series('left', 0, 1),
series('middle', 10, 11),
series('right', 20, 21),
first,
second,
]),
'multi-axis',
)
expect(groups[3]?.domain[0]).toBeLessThanOrEqual(-20)
expect(groups[3]?.domain[1]).toBeGreaterThanOrEqual(60)
})
it('places two and three axes on the expected sides', () => {
const source = [series('a', 0, 1), series('b', 10, 11), series('c', 20, 21)]
expect(
buildYAxisSeriesGroups(track(source.slice(0, 2)), 'multi-axis').map((g) => g.side),
).toEqual(['left', 'right'])
expect(buildYAxisSeriesGroups(track(source), 'multi-axis').map((g) => g.side)).toEqual([
'left',
'right',
'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])
})
})