diff --git a/src/App.vue b/src/App.vue index b03177d..6c7ae6c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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, diff --git a/src/components/core/useWaveformChartController.ts b/src/components/core/useWaveformChartController.ts index ef882d2..30287d9 100644 --- a/src/components/core/useWaveformChartController.ts +++ b/src/components/core/useWaveformChartController.ts @@ -81,9 +81,11 @@ export function useWaveformChartController( const selection = shallowRef(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({ diff --git a/src/components/core/useWaveformChartLifecycle.ts b/src/components/core/useWaveformChartLifecycle.ts index 777591e..821e533 100644 --- a/src/components/core/useWaveformChartLifecycle.ts +++ b/src/components/core/useWaveformChartLifecycle.ts @@ -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 chartTracks: ComputedRef + trackLayouts: ComputedRef innerWidth: ComputedRef innerHeight: ComputedRef activeInteractionMode: ComputedRef @@ -42,6 +44,7 @@ interface LifecycleContext { internalHiddenSeriesIds: Ref> independentTransforms: ShallowRef independentYDomains: Ref> + resolveInitialTrackDomain: (track: TrackLayout) => [number, number] annotationInteraction: ReturnType editorSeriesOptions: Ref isPresentationMode: ComputedRef @@ -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 } } diff --git a/src/components/core/useWaveformData.ts b/src/components/core/useWaveformData.ts index 3eb2b73..c4ffee0 100644 --- a/src/components/core/useWaveformData.ts +++ b/src/components/core/useWaveformData.ts @@ -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(prepareWaveformSeries(data())) watch(data, (nextData) => { + onBeforeDataChange() preparedSeries.value = prepareWaveformSeries(nextData) onDataChange() }) diff --git a/src/components/interaction/useWaveformViewport.ts b/src/components/interaction/useWaveformViewport.ts index 8c6b790..1aa0054 100644 --- a/src/components/interaction/useWaveformViewport.ts +++ b/src/components/interaction/useWaveformViewport.ts @@ -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] diff --git a/src/components/interaction/useWaveformZoom.ts b/src/components/interaction/useWaveformZoom.ts index e3bcc3a..b766748 100644 --- a/src/components/interaction/useWaveformZoom.ts +++ b/src/components/interaction/useWaveformZoom.ts @@ -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() - .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() .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], diff --git a/src/components/interaction/zoomConstraints.ts b/src/components/interaction/zoomConstraints.ts new file mode 100644 index 0000000..4c7f136 --- /dev/null +++ b/src/components/interaction/zoomConstraints.ts @@ -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>() + +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() + 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() + 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) +} diff --git a/src/components/waveformChartCases/zoomPointConstraints.test.ts b/src/components/waveformChartCases/zoomPointConstraints.test.ts new file mode 100644 index 0000000..c2b3ad9 --- /dev/null +++ b/src/components/waveformChartCases/zoomPointConstraints.test.ts @@ -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, + 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, + 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]) + }) +}) diff --git a/src/demo/DemoChartHost.vue b/src/demo/DemoChartHost.vue index dd0ee76..d75aa1a 100644 --- a/src/demo/DemoChartHost.vue +++ b/src/demo/DemoChartHost.vue @@ -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" diff --git a/src/demo/types.ts b/src/demo/types.ts index 2ad32c5..affb7ce 100644 --- a/src/demo/types.ts +++ b/src/demo/types.ts @@ -86,7 +86,6 @@ export interface DemoControlPanelModel { export interface DemoChartModel { data: WaveformData - minZoomSpan?: number initialXDomain?: [number, number] displayMode: WaveformDisplayMode overlayMode: WaveformOverlayMode