fix(chart): honor minimum visible zoom points

This commit is contained in:
李启源
2026-08-10 15:50:50 +08:00
parent 9b42e3797b
commit 4dd53aae11
10 changed files with 528 additions and 69 deletions

View File

@@ -141,9 +141,6 @@ const [initialXMinimum, initialXMaximum] = initialXValues.reduce<[number, number
([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)],
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY],
)
const initialXSpan = initialXMaximum - initialXMinimum
const minZoomSpan =
Number.isFinite(initialXSpan) && initialXSpan > 0 ? initialXSpan / 40 : undefined
const initialXDomainValue: [number, number] | undefined =
Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum)
? [initialXMinimum, initialXMaximum]
@@ -353,7 +350,6 @@ const controlPanelModel = reactive({
const chartModel = reactive({
data: displayChartData,
minZoomSpan,
initialXDomain,
displayMode,
overlayMode,

View File

@@ -81,9 +81,11 @@ export function useWaveformChartController(
const selection = shallowRef<ViewportSelectionState | null>(null)
const spacePressed = ref(false)
const pointerInsideChart = ref(false)
let handleBeforeDataReferenceChange: () => void = () => undefined
let handleDataReferenceChange: () => void = () => undefined
const preparedSeries = usePreparedWaveformSeries(
() => props.data,
() => handleBeforeDataReferenceChange(),
() => handleDataReferenceChange(),
)
@@ -127,6 +129,7 @@ export function useWaveformChartController(
const {
chartSeries,
chartTracks,
trackLayouts,
gridOptions,
pageCount,
pagedTracks,
@@ -138,7 +141,6 @@ export function useWaveformChartController(
initialXDomain,
sharedZoomDomain,
resolveInitialTrackDomain,
trackLayouts,
annotationLayoutsForTrack,
resolveSeriesYScale,
} = layout
@@ -254,6 +256,7 @@ export function useWaveformChartController(
gridOptions,
chartSeries,
chartTracks,
trackLayouts,
innerWidth,
innerHeight,
activeInteractionMode,
@@ -261,6 +264,7 @@ export function useWaveformChartController(
internalHiddenSeriesIds,
independentTransforms,
independentYDomains,
resolveInitialTrackDomain,
annotationInteraction,
editorSeriesOptions,
isPresentationMode,
@@ -272,6 +276,7 @@ export function useWaveformChartController(
cancelPendingHover: hover.cancelPendingHover,
clearZoomBindings: zoom.clearZoomBindings,
})
handleBeforeDataReferenceChange = lifecycle.handleBeforeDataReferenceChange
handleDataReferenceChange = lifecycle.handleDataReferenceChange
return reactive({

View File

@@ -11,8 +11,9 @@ import {
import type { AnnotationSeriesCandidate } from '../annotation'
import type { useWaveformAnnotationInteraction } from '../annotation'
import type { DisplaySeries, DisplayTrack } from './types'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
interface LifecycleContext {
props: ResolvedWaveformChartProps
@@ -35,6 +36,7 @@ interface LifecycleContext {
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
chartSeries: ComputedRef<DisplaySeries[]>
chartTracks: ComputedRef<DisplayTrack[]>
trackLayouts: ComputedRef<TrackLayout[]>
innerWidth: ComputedRef<number>
innerHeight: ComputedRef<number>
activeInteractionMode: ComputedRef<string | undefined>
@@ -42,6 +44,7 @@ interface LifecycleContext {
internalHiddenSeriesIds: Ref<Set<string>>
independentTransforms: ShallowRef<ZoomTransform[]>
independentYDomains: Ref<Record<number, [number, number]>>
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
isPresentationMode: ComputedRef<boolean>
@@ -87,6 +90,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
gridOptions,
chartSeries,
chartTracks,
trackLayouts,
innerWidth,
innerHeight,
activeInteractionMode,
@@ -94,6 +98,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
internalHiddenSeriesIds,
independentTransforms,
independentYDomains,
resolveInitialTrackDomain,
annotationInteraction,
editorSeriesOptions,
isPresentationMode,
@@ -139,6 +144,27 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
emit('page-change', nextPage, pageCount.value)
}
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
function handleBeforeDataReferenceChange() {
if (props.displayMode !== 'independent') return
pendingIndependentXDomains = trackLayouts.value.map((track) => {
const configuredDomain =
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
props.initialXDomains?.[track.series.id] ??
props.initialXDomain
if (
!configuredDomain ||
!Number.isFinite(configuredDomain[0]) ||
!Number.isFinite(configuredDomain[1]) ||
configuredDomain[0] === configuredDomain[1]
) {
return undefined
}
return track.xScale.domain() as [number, number]
})
}
function handleDataReferenceChange() {
if (props.displayMode === 'independent') {
const currentTransforms = independentTransforms.value
@@ -148,7 +174,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
}
clearHover()
editorSeriesOptions.value = []
void nextTick(configureZoom)
void nextTick(() => {
if (props.displayMode === 'independent' && pendingIndependentXDomains) {
const nextTransforms = chartTracks.value.map(() => zoomIdentity)
trackLayouts.value.forEach((track) => {
const previousDomain = pendingIndependentXDomains?.[track.index]
if (!previousDomain) return
const boundary = resolveInitialTrackDomain(track)
const domain = constrainZoomDomain(previousDomain, boundary, [track.seriesList], props)
nextTransforms[track.index] = transformForDomain(domain, boundary, track.width)
})
independentTransforms.value = nextTransforms
pendingIndependentXDomains = undefined
void nextTick(configureZoom)
return
}
pendingIndependentXDomains = undefined
configureZoom()
})
}
watch(
@@ -158,6 +201,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
() => props.zoomable,
isPresentationMode,
() => props.minZoomSpan,
() => props.minVisiblePoints,
() => props.initialXDomain,
() => props.initialXDomains,
() => props.displayMode,
@@ -324,5 +368,5 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
editorSeriesOptions.value = []
})
return { goToPage, handleDataReferenceChange }
return { goToPage, handleBeforeDataReferenceChange, handleDataReferenceChange }
}

View File

@@ -62,9 +62,14 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie
})
}
export function usePreparedWaveformSeries(data: () => WaveformData, onDataChange: () => void) {
export function usePreparedWaveformSeries(
data: () => WaveformData,
onBeforeDataChange: () => void,
onDataChange: () => void,
) {
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(prepareWaveformSeries(data()))
watch(data, (nextData) => {
onBeforeDataChange()
preparedSeries.value = prepareWaveformSeries(nextData)
onDataChange()
})

View File

@@ -1,4 +1,4 @@
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
import type { DisplayTrack, TrackLayout } from '../core/types'
@@ -11,6 +11,7 @@ import type {
import type { AnnotationSeriesCandidate } from '../annotation'
import { tryReleasePointerCapture } from './pointerCapture'
import { transitionViewportInteraction } from './viewportInteractionState'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportContext {
props: ResolvedWaveformChartProps
emit: WaveformChartEmit
@@ -92,48 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
height: Math.abs(active.currentY - active.startY),
}
})
const transformForDomain = (
domain: [number, number],
baseDomain: [number, number],
width: number,
): ZoomTransform => {
const baseSpan = baseDomain[1] - baseDomain[0]
const span = domain[1] - domain[0]
if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
return zoomIdentity
}
const scale = baseSpan / span
const baseScale = scaleLinear(baseDomain, [0, width])
return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
}
const resolveMinimumZoomSpan = (boundary: [number, number]): number => {
const boundarySpan = Math.abs(boundary[1] - boundary[0])
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
const configured = props.minZoomSpan
if (Number.isFinite(configured) && (configured ?? 0) > 0) {
return Math.min(boundarySpan, configured as number)
}
return boundarySpan / 40
}
const constrainZoomDomain = (
domain: [number, number],
boundary: [number, number],
): [number, number] => {
const normalizedBoundary: [number, number] =
boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
const boundarySpan = normalizedBoundary[1] - normalizedBoundary[0]
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalizedBoundary
const requestedStart = Math.min(domain[0], domain[1])
const requestedEnd = Math.max(domain[0], domain[1])
const minimumSpan = resolveMinimumZoomSpan(normalizedBoundary)
const span = Math.max(minimumSpan, Math.min(boundarySpan, requestedEnd - requestedStart))
const center = (requestedStart + requestedEnd) / 2
const start = Math.max(
normalizedBoundary[0],
Math.min(center - span / 2, normalizedBoundary[1] - span),
)
return [start, start + span]
}
const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
const span = domain[1] - domain[0]
const boundarySpan = boundary[1] - boundary[0]
@@ -275,9 +234,14 @@ export function useWaveformViewport(context: ViewportContext) {
)
if (right - left < MINIMUM_SELECTION_SIZE) return
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
const groups = active.independent
? [track.seriesList]
: trackLayouts.value.filter((item) => item.hasVisibleSeries).map((item) => item.seriesList)
const xDomain = constrainZoomDomain(
[track.xScale.invert(left), track.xScale.invert(right)],
baseXDomain,
groups,
props,
)
if (active.independent) {
const next = [...independentTransforms.value]

View File

@@ -15,6 +15,12 @@ import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
import type { TrackLayout } from '../core/types'
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
import {
constrainZoomDomain,
resolveMinimumZoomSpan,
transformForDomain,
type ZoomSeriesGroup,
} from './zoomConstraints'
interface ZoomContext {
props: ResolvedWaveformChartProps
@@ -216,24 +222,38 @@ export function useWaveformZoom(context: ZoomContext) {
.forEach((overlay) => select(overlay).on('.zoom', null))
zoomBehaviors.clear()
}
const resolveMaximumZoomScale = (domain: [number, number]): number => {
const minZoomSpan = props.minZoomSpan
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) {
return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
}
const resolveMaximumZoomScale = (
domain: [number, number],
groups: readonly ZoomSeriesGroup[],
): number => {
const domainSpan = Math.abs(domain[1] - domain[0])
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
return Math.min(
ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)),
const minimumSpan = resolveMinimumZoomSpan(domain, groups, props)
return Math.max(
ZOOM_CONSTRAINTS.MIN_SCALE,
minimumSpan > 0 ? domainSpan / minimumSpan : ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
)
}
const canZoomTrack = (track: TrackLayout): boolean =>
hasMinimumVisibleXValues(
const constrainTransform = (
transform: ZoomTransform,
domain: [number, number],
width: number,
groups: readonly ZoomSeriesGroup[],
): ZoomTransform => {
const requested = transform.rescaleX(scaleLinear(domain, [0, width])).domain() as [
number,
number,
]
return transformForDomain(constrainZoomDomain(requested, domain, groups, props), domain, width)
}
const canZoomTrack = (track: TrackLayout): boolean => {
const minimum = Number(props.minVisiblePoints)
return hasMinimumVisibleXValues(
track.seriesList,
track.xScale.domain() as [number, number],
Number(props.minVisiblePoints),
Number.isFinite(minimum) && minimum > 0 ? Math.ceil(minimum) + 1 : minimum,
)
}
const canZoomSharedTracks = (): boolean => {
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
return tracks.length > 0 && tracks.every(canZoomTrack)
@@ -265,9 +285,15 @@ export function useWaveformZoom(context: ZoomContext) {
)
if (!overlay) return
const dataDomain = resolveInitialTrackDomain(track)
const groups = [track.seriesList]
const behavior = zoom<SVGRectElement, unknown>()
.filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
.filter((event) => {
const currentTrack =
trackLayouts.value.find((item) => item.index === track.index) ?? track
return canHandleWheelZoom(event, canZoomTrack(currentTrack))
})
.scaleExtent([1, resolveMaximumZoomScale(dataDomain, groups)])
.constrain((transform) => constrainTransform(transform, dataDomain, track.width, groups))
.extent([
[0, 0],
[track.width, track.height],
@@ -293,9 +319,15 @@ export function useWaveformZoom(context: ZoomContext) {
}
const overlay = sharedOverlayElement.value
if (!overlay) return
const groups = trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const behavior = zoom<SVGRectElement, unknown>()
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value, groups)])
.constrain((transform) =>
constrainTransform(transform, initialXDomain.value, innerWidth.value, groups),
)
.extent([
[0, 0],
[innerWidth.value, innerHeight.value],

View File

@@ -0,0 +1,167 @@
import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
import type { WaveformPoint } from '../../types'
import { ZOOM_CONSTRAINTS } from '../core/constants'
interface PointSeriesSource {
points: WaveformPoint[]
}
export type ZoomSeriesGroup = readonly PointSeriesSource[]
interface ZoomConstraintOptions {
minZoomSpan?: number
minVisiblePoints?: number
}
const xValueCache = new WeakMap<object, Map<string, number[]>>()
function normalizedBoundary(boundary: [number, number]): [number, number] {
return boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
}
function resolveRequiredPointCount(minimum: number | undefined): number {
return Number.isFinite(minimum) && (minimum ?? 0) > 0 ? Math.ceil(minimum as number) : 0
}
function uniqueXValues(group: ZoomSeriesGroup, boundary: [number, number]): number[] {
const boundaryKey = `${boundary[0]}\u0000${boundary[1]}`
const cached = xValueCache.get(group)?.get(boundaryKey)
if (cached) return cached
const values = new Set<number>()
for (const series of group) {
for (const point of series.points) {
if (point.x >= boundary[0] && point.x <= boundary[1]) values.add(point.x)
}
}
const sorted = Array.from(values).sort((left, right) => left - right)
const groupCache = xValueCache.get(group) ?? new Map<string, number[]>()
groupCache.set(boundaryKey, sorted)
xValueCache.set(group, groupCache)
return sorted
}
function minimumPointSpan(
groups: readonly ZoomSeriesGroup[],
boundary: [number, number],
required: number,
): number {
if (required <= 1) return 0
let minimumSpan = 0
for (const group of groups) {
const values = uniqueXValues(group, boundary)
if (values.length < required) return boundary[1] - boundary[0]
let groupSpan = Number.POSITIVE_INFINITY
for (let index = 0; index + required <= values.length; index += 1) {
groupSpan = Math.min(groupSpan, values[index + required - 1] - values[index])
}
const endpointTolerance =
Number.EPSILON * Math.max(1, Math.abs(boundary[0]), Math.abs(boundary[1])) * 16
minimumSpan = Math.max(minimumSpan, groupSpan + endpointTolerance)
}
return minimumSpan
}
export function resolveMinimumZoomSpan(
boundary: [number, number],
groups: readonly ZoomSeriesGroup[],
options: ZoomConstraintOptions,
): number {
const normalized = normalizedBoundary(boundary)
const boundarySpan = normalized[1] - normalized[0]
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
const configuredSpan =
Number.isFinite(options.minZoomSpan) && (options.minZoomSpan ?? 0) > 0
? Math.min(boundarySpan, options.minZoomSpan as number)
: 0
const required = resolveRequiredPointCount(options.minVisiblePoints)
const pointSpan = minimumPointSpan(groups, normalized, required)
if (configuredSpan > 0 || required > 0) {
return Math.max(configuredSpan, pointSpan)
}
return boundarySpan / ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
}
function expandToMinimumPoints(
domain: [number, number],
boundary: [number, number],
groups: readonly ZoomSeriesGroup[],
required: number,
): [number, number] {
if (required <= 0) return domain
const endpointTolerance =
Number.EPSILON * Math.max(1, Math.abs(boundary[0]), Math.abs(boundary[1])) * 16
let expanded = domain
for (const group of groups) {
const values = uniqueXValues(group, boundary)
if (values.length < required) return [...boundary]
const visibleCount = values.filter(
(value) => value >= expanded[0] && value <= expanded[1],
).length
if (visibleCount >= required) continue
let best: [number, number] | undefined
let bestSpan = Number.POSITIVE_INFINITY
let bestCenterDistance = Number.POSITIVE_INFINITY
const requestedCenter = (expanded[0] + expanded[1]) / 2
for (let index = 0; index + required <= values.length; index += 1) {
const candidate: [number, number] = [
Math.max(boundary[0], Math.min(expanded[0], values[index] - endpointTolerance)),
Math.min(
boundary[1],
Math.max(expanded[1], values[index + required - 1] + endpointTolerance),
),
]
const span = candidate[1] - candidate[0]
const centerDistance = Math.abs((candidate[0] + candidate[1]) / 2 - requestedCenter)
if (span < bestSpan || (span === bestSpan && centerDistance < bestCenterDistance)) {
best = candidate
bestSpan = span
bestCenterDistance = centerDistance
}
}
expanded = best ?? [...boundary]
}
return expanded
}
export function constrainZoomDomain(
domain: [number, number],
boundary: [number, number],
groups: readonly ZoomSeriesGroup[],
options: ZoomConstraintOptions,
): [number, number] {
const normalized = normalizedBoundary(boundary)
const boundarySpan = normalized[1] - normalized[0]
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalized
if (Math.abs(domain[1] - domain[0]) >= boundarySpan * (1 - 1e-12)) return normalized
const requested: [number, number] = [
Math.max(normalized[0], Math.min(domain[0], domain[1])),
Math.min(normalized[1], Math.max(domain[0], domain[1])),
]
const expanded = expandToMinimumPoints(
requested,
normalized,
groups,
resolveRequiredPointCount(options.minVisiblePoints),
)
const minimumSpan = resolveMinimumZoomSpan(normalized, groups, options)
const span = Math.max(minimumSpan, Math.min(boundarySpan, expanded[1] - expanded[0]))
const center = (expanded[0] + expanded[1]) / 2
const start = Math.max(normalized[0], Math.min(center - span / 2, normalized[1] - span))
return [start, start + span]
}
export function transformForDomain(
domain: [number, number],
baseDomain: [number, number],
width: number,
): ZoomTransform {
const baseSpan = baseDomain[1] - baseDomain[0]
const span = domain[1] - domain[0]
if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
return zoomIdentity
}
const scale = baseSpan / span
const baseScale = scaleLinear(baseDomain, [0, width])
return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
}

View File

@@ -0,0 +1,248 @@
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart'
const regularPoints = Array.from({ length: 1_200 }, (_, index) => ({ x: index, y: index % 11 }))
function visibleXCount(domain: [number, number], values = regularPoints): number {
return new Set(
values.filter((point) => point.x >= domain[0] && point.x <= domain[1]).map((point) => point.x),
).size
}
function prepareOverlay(wrapper: VueWrapper, selector: string) {
const overlay = wrapper.get(selector)
const width = Number(overlay.attributes('width'))
const height = Number(overlay.attributes('height'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
return { overlay, width, height }
}
async function dispatchWheel(
overlay: ReturnType<VueWrapper['get']>,
width: number,
height: number,
deltaY: number,
) {
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY,
clientX: width / 2,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
function dispatchBox(
overlay: ReturnType<VueWrapper['get']>,
startX: number,
endX: number,
height: number,
pointerId: number,
) {
for (const [type, clientX] of [
['pointerdown', startX],
['pointermove', endX],
['pointerup', endX],
] as const) {
const event = new MouseEvent(type, {
button: 0,
clientX,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(event, 'pointerId', { value: pointerId })
overlay.element.dispatchEvent(event)
}
}
describe('WaveformChart point-aware zoom constraints', () => {
it('wheel-zooms 1,200 samples to two distinct X values, stops, and still zooms out', async () => {
const wrapper = await mountSizedChart(
{ kind: 'points', points: regularPoints },
{ minVisiblePoints: 2 },
)
const { overlay, width, height } = prepareOverlay(
wrapper,
'.waveform-chart__overlay--independent',
)
await dispatchWheel(overlay, width, height, -4_000)
await dispatchWheel(overlay, width, height, -4_000)
const twoPointDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(visibleXCount(twoPointDomain)).toBe(2)
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(overlay, width, height, -1_000)
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
await dispatchWheel(overlay, width, height, 4_000)
const partiallyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(partiallyZoomedOut[1] - partiallyZoomedOut[0]).toBeGreaterThan(
twoPointDomain[1] - twoPointDomain[0],
)
await dispatchWheel(overlay, width, height, 4_000)
const fullyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(fullyZoomedOut).toEqual([0, 1_199])
})
it('keeps two fractional samples when the wheel focus falls between sample positions', async () => {
const points = Array.from({ length: 1_000 }, (_, index) => ({
x: -5 + (index * 10) / 999,
y: index,
}))
const wrapper = await mountSizedChart(
{ kind: 'points', points },
{ minVisiblePoints: 2, initialXDomain: [-5, 5] },
)
const { overlay, width, height } = prepareOverlay(
wrapper,
'.waveform-chart__overlay--independent',
)
for (let index = 0; index < 3; index += 1) {
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4_000,
clientX: width * 0.5032,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(visibleXCount(domain, points)).toBe(2)
})
it('uses repeated box zooms to reach exactly two distinct X values', async () => {
const wrapper = await mountSizedChart(
{ kind: 'points', points: regularPoints },
{ minVisiblePoints: 2 },
)
const { overlay, width, height } = prepareOverlay(
wrapper,
'.waveform-chart__overlay--independent',
)
for (let index = 0; index < 3; index += 1) {
dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, index + 1)
await flushPromises()
}
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(visibleXCount(domain)).toBe(2)
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, 10)
await flushPromises()
const constrainedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
expect(visibleXCount(constrainedDomain)).toBe(2)
})
it('retains the default 40x maximum when no explicit limit is configured', async () => {
const wrapper = await mountSizedChart({ kind: 'points', points: regularPoints })
const { overlay, width, height } = prepareOverlay(
wrapper,
'.waveform-chart__overlay--independent',
)
await dispatchWheel(overlay, width, height, -4_000)
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(domain[1] - domain[0]).toBeCloseTo(1_199 / 40)
})
it('retains two endpoint samples while replacing an independent data window', async () => {
const sourcePoints = Array.from({ length: 1_000 }, (_, index) => ({
x: -5 + (index * 10) / 999,
y: index,
}))
const wrapper = await mountSizedChart(
{ kind: 'points', points: sourcePoints },
{ minVisiblePoints: 2, initialXDomain: [-5, 5] },
)
const { overlay, width, height } = prepareOverlay(
wrapper,
'.waveform-chart__overlay--independent',
)
for (let index = 0; index < 10; index += 1) {
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -1_000,
clientX: width * 0.5032,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
if ((wrapper.emitted('zoom-change')?.length ?? 0) === eventCount) break
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
const windowPoints = sourcePoints.filter(
(point) => point.x >= domain[0] && point.x <= domain[1],
)
await wrapper.setProps({ data: { kind: 'points', points: windowPoints } })
await flushPromises()
}
const finalDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
const endpointDomain = wrapper
.findAll('.waveform-chart__axis-endpoint')
.slice(0, 2)
.map((endpoint) => Number(endpoint.text()) / 1_000) as [number, number]
expect(visibleXCount(finalDomain, sourcePoints)).toBe(2)
expect(visibleXCount(endpointDomain, sourcePoints)).toBe(2)
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(overlay, width, height, -1_000)
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
})
it('recomputes irregular point constraints for shared tracks after data replacement', async () => {
const createData = (offset: number) => ({
kind: 'series' as const,
series: [
{
id: 'first',
name: 'First',
data: {
kind: 'points' as const,
points: [0, 1, 4, 20].map((x) => ({ x: x + offset, y: x })),
},
},
{
id: 'second',
name: 'Second',
data: {
kind: 'points' as const,
points: [0, 3, 9, 20].map((x) => ({ x: x + offset, y: x })),
},
},
],
})
const wrapper = await mountSizedChart(createData(0), {
displayMode: 'separated',
minVisiblePoints: 2,
})
const { overlay, width, height } = prepareOverlay(wrapper, '.waveform-chart__overlay')
await dispatchWheel(overlay, width, height, -4_000)
await wrapper.setProps({ data: createData(100) })
await flushPromises()
await dispatchWheel(overlay, width, height, 4_000)
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(domain).toEqual([100, 120])
})
})

View File

@@ -22,8 +22,7 @@ defineExpose({ resetViewport })
v-model:annotations="model.annotations"
v-model:hidden-series-ids="model.hiddenSeriesIds"
:data="model.data"
:min-zoom-span="model.minZoomSpan"
:min-visible-points="5"
:min-visible-points="2"
:initial-x-domain="model.initialXDomain"
:display-mode="model.displayMode"
:overlay-mode="model.overlayMode"

View File

@@ -86,7 +86,6 @@ export interface DemoControlPanelModel {
export interface DemoChartModel {
data: WaveformData
minZoomSpan?: number
initialXDomain?: [number, number]
displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode