fix(chart): honor minimum visible zoom points
This commit is contained in:
@@ -141,9 +141,6 @@ const [initialXMinimum, initialXMaximum] = initialXValues.reduce<[number, number
|
|||||||
([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)],
|
([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)],
|
||||||
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY],
|
[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 =
|
const initialXDomainValue: [number, number] | undefined =
|
||||||
Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum)
|
Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum)
|
||||||
? [initialXMinimum, initialXMaximum]
|
? [initialXMinimum, initialXMaximum]
|
||||||
@@ -353,7 +350,6 @@ const controlPanelModel = reactive({
|
|||||||
|
|
||||||
const chartModel = reactive({
|
const chartModel = reactive({
|
||||||
data: displayChartData,
|
data: displayChartData,
|
||||||
minZoomSpan,
|
|
||||||
initialXDomain,
|
initialXDomain,
|
||||||
displayMode,
|
displayMode,
|
||||||
overlayMode,
|
overlayMode,
|
||||||
|
|||||||
@@ -81,9 +81,11 @@ export function useWaveformChartController(
|
|||||||
const selection = shallowRef<ViewportSelectionState | null>(null)
|
const selection = shallowRef<ViewportSelectionState | null>(null)
|
||||||
const spacePressed = ref(false)
|
const spacePressed = ref(false)
|
||||||
const pointerInsideChart = ref(false)
|
const pointerInsideChart = ref(false)
|
||||||
|
let handleBeforeDataReferenceChange: () => void = () => undefined
|
||||||
let handleDataReferenceChange: () => void = () => undefined
|
let handleDataReferenceChange: () => void = () => undefined
|
||||||
const preparedSeries = usePreparedWaveformSeries(
|
const preparedSeries = usePreparedWaveformSeries(
|
||||||
() => props.data,
|
() => props.data,
|
||||||
|
() => handleBeforeDataReferenceChange(),
|
||||||
() => handleDataReferenceChange(),
|
() => handleDataReferenceChange(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,6 +129,7 @@ export function useWaveformChartController(
|
|||||||
const {
|
const {
|
||||||
chartSeries,
|
chartSeries,
|
||||||
chartTracks,
|
chartTracks,
|
||||||
|
trackLayouts,
|
||||||
gridOptions,
|
gridOptions,
|
||||||
pageCount,
|
pageCount,
|
||||||
pagedTracks,
|
pagedTracks,
|
||||||
@@ -138,7 +141,6 @@ export function useWaveformChartController(
|
|||||||
initialXDomain,
|
initialXDomain,
|
||||||
sharedZoomDomain,
|
sharedZoomDomain,
|
||||||
resolveInitialTrackDomain,
|
resolveInitialTrackDomain,
|
||||||
trackLayouts,
|
|
||||||
annotationLayoutsForTrack,
|
annotationLayoutsForTrack,
|
||||||
resolveSeriesYScale,
|
resolveSeriesYScale,
|
||||||
} = layout
|
} = layout
|
||||||
@@ -254,6 +256,7 @@ export function useWaveformChartController(
|
|||||||
gridOptions,
|
gridOptions,
|
||||||
chartSeries,
|
chartSeries,
|
||||||
chartTracks,
|
chartTracks,
|
||||||
|
trackLayouts,
|
||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
@@ -261,6 +264,7 @@ export function useWaveformChartController(
|
|||||||
internalHiddenSeriesIds,
|
internalHiddenSeriesIds,
|
||||||
independentTransforms,
|
independentTransforms,
|
||||||
independentYDomains,
|
independentYDomains,
|
||||||
|
resolveInitialTrackDomain,
|
||||||
annotationInteraction,
|
annotationInteraction,
|
||||||
editorSeriesOptions,
|
editorSeriesOptions,
|
||||||
isPresentationMode,
|
isPresentationMode,
|
||||||
@@ -272,6 +276,7 @@ export function useWaveformChartController(
|
|||||||
cancelPendingHover: hover.cancelPendingHover,
|
cancelPendingHover: hover.cancelPendingHover,
|
||||||
clearZoomBindings: zoom.clearZoomBindings,
|
clearZoomBindings: zoom.clearZoomBindings,
|
||||||
})
|
})
|
||||||
|
handleBeforeDataReferenceChange = lifecycle.handleBeforeDataReferenceChange
|
||||||
handleDataReferenceChange = lifecycle.handleDataReferenceChange
|
handleDataReferenceChange = lifecycle.handleDataReferenceChange
|
||||||
|
|
||||||
return reactive({
|
return reactive({
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
|
|
||||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||||
import type { useWaveformAnnotationInteraction } 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 type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
|
||||||
|
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
|
||||||
|
|
||||||
interface LifecycleContext {
|
interface LifecycleContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
@@ -35,6 +36,7 @@ interface LifecycleContext {
|
|||||||
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
|
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
|
||||||
chartSeries: ComputedRef<DisplaySeries[]>
|
chartSeries: ComputedRef<DisplaySeries[]>
|
||||||
chartTracks: ComputedRef<DisplayTrack[]>
|
chartTracks: ComputedRef<DisplayTrack[]>
|
||||||
|
trackLayouts: ComputedRef<TrackLayout[]>
|
||||||
innerWidth: ComputedRef<number>
|
innerWidth: ComputedRef<number>
|
||||||
innerHeight: ComputedRef<number>
|
innerHeight: ComputedRef<number>
|
||||||
activeInteractionMode: ComputedRef<string | undefined>
|
activeInteractionMode: ComputedRef<string | undefined>
|
||||||
@@ -42,6 +44,7 @@ interface LifecycleContext {
|
|||||||
internalHiddenSeriesIds: Ref<Set<string>>
|
internalHiddenSeriesIds: Ref<Set<string>>
|
||||||
independentTransforms: ShallowRef<ZoomTransform[]>
|
independentTransforms: ShallowRef<ZoomTransform[]>
|
||||||
independentYDomains: Ref<Record<number, [number, number]>>
|
independentYDomains: Ref<Record<number, [number, number]>>
|
||||||
|
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
|
||||||
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
|
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
|
||||||
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
|
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
|
||||||
isPresentationMode: ComputedRef<boolean>
|
isPresentationMode: ComputedRef<boolean>
|
||||||
@@ -87,6 +90,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
gridOptions,
|
gridOptions,
|
||||||
chartSeries,
|
chartSeries,
|
||||||
chartTracks,
|
chartTracks,
|
||||||
|
trackLayouts,
|
||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
@@ -94,6 +98,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
internalHiddenSeriesIds,
|
internalHiddenSeriesIds,
|
||||||
independentTransforms,
|
independentTransforms,
|
||||||
independentYDomains,
|
independentYDomains,
|
||||||
|
resolveInitialTrackDomain,
|
||||||
annotationInteraction,
|
annotationInteraction,
|
||||||
editorSeriesOptions,
|
editorSeriesOptions,
|
||||||
isPresentationMode,
|
isPresentationMode,
|
||||||
@@ -139,6 +144,27 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
emit('page-change', nextPage, pageCount.value)
|
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() {
|
function handleDataReferenceChange() {
|
||||||
if (props.displayMode === 'independent') {
|
if (props.displayMode === 'independent') {
|
||||||
const currentTransforms = independentTransforms.value
|
const currentTransforms = independentTransforms.value
|
||||||
@@ -148,7 +174,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
}
|
}
|
||||||
clearHover()
|
clearHover()
|
||||||
editorSeriesOptions.value = []
|
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(
|
watch(
|
||||||
@@ -158,6 +201,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
() => props.zoomable,
|
() => props.zoomable,
|
||||||
isPresentationMode,
|
isPresentationMode,
|
||||||
() => props.minZoomSpan,
|
() => props.minZoomSpan,
|
||||||
|
() => props.minVisiblePoints,
|
||||||
() => props.initialXDomain,
|
() => props.initialXDomain,
|
||||||
() => props.initialXDomains,
|
() => props.initialXDomains,
|
||||||
() => props.displayMode,
|
() => props.displayMode,
|
||||||
@@ -324,5 +368,5 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
editorSeriesOptions.value = []
|
editorSeriesOptions.value = []
|
||||||
})
|
})
|
||||||
|
|
||||||
return { goToPage, handleDataReferenceChange }
|
return { goToPage, handleBeforeDataReferenceChange, handleDataReferenceChange }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()))
|
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(prepareWaveformSeries(data()))
|
||||||
watch(data, (nextData) => {
|
watch(data, (nextData) => {
|
||||||
|
onBeforeDataChange()
|
||||||
preparedSeries.value = prepareWaveformSeries(nextData)
|
preparedSeries.value = prepareWaveformSeries(nextData)
|
||||||
onDataChange()
|
onDataChange()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||||
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
||||||
import type { DisplayTrack, TrackLayout } from '../core/types'
|
import type { DisplayTrack, TrackLayout } from '../core/types'
|
||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||||
import { tryReleasePointerCapture } from './pointerCapture'
|
import { tryReleasePointerCapture } from './pointerCapture'
|
||||||
import { transitionViewportInteraction } from './viewportInteractionState'
|
import { transitionViewportInteraction } from './viewportInteractionState'
|
||||||
|
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
|
||||||
interface ViewportContext {
|
interface ViewportContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
emit: WaveformChartEmit
|
emit: WaveformChartEmit
|
||||||
@@ -92,48 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
height: Math.abs(active.currentY - active.startY),
|
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 clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
|
||||||
const span = domain[1] - domain[0]
|
const span = domain[1] - domain[0]
|
||||||
const boundarySpan = boundary[1] - boundary[0]
|
const boundarySpan = boundary[1] - boundary[0]
|
||||||
@@ -275,9 +234,14 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
)
|
)
|
||||||
if (right - left < MINIMUM_SELECTION_SIZE) return
|
if (right - left < MINIMUM_SELECTION_SIZE) return
|
||||||
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
|
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(
|
const xDomain = constrainZoomDomain(
|
||||||
[track.xScale.invert(left), track.xScale.invert(right)],
|
[track.xScale.invert(left), track.xScale.invert(right)],
|
||||||
baseXDomain,
|
baseXDomain,
|
||||||
|
groups,
|
||||||
|
props,
|
||||||
)
|
)
|
||||||
if (active.independent) {
|
if (active.independent) {
|
||||||
const next = [...independentTransforms.value]
|
const next = [...independentTransforms.value]
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
|
|||||||
import type { TrackLayout } from '../core/types'
|
import type { TrackLayout } from '../core/types'
|
||||||
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
|
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
|
||||||
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
||||||
|
import {
|
||||||
|
constrainZoomDomain,
|
||||||
|
resolveMinimumZoomSpan,
|
||||||
|
transformForDomain,
|
||||||
|
type ZoomSeriesGroup,
|
||||||
|
} from './zoomConstraints'
|
||||||
|
|
||||||
interface ZoomContext {
|
interface ZoomContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
@@ -216,24 +222,38 @@ export function useWaveformZoom(context: ZoomContext) {
|
|||||||
.forEach((overlay) => select(overlay).on('.zoom', null))
|
.forEach((overlay) => select(overlay).on('.zoom', null))
|
||||||
zoomBehaviors.clear()
|
zoomBehaviors.clear()
|
||||||
}
|
}
|
||||||
const resolveMaximumZoomScale = (domain: [number, number]): number => {
|
const resolveMaximumZoomScale = (
|
||||||
const minZoomSpan = props.minZoomSpan
|
domain: [number, number],
|
||||||
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) {
|
groups: readonly ZoomSeriesGroup[],
|
||||||
return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
|
): number => {
|
||||||
}
|
|
||||||
const domainSpan = Math.abs(domain[1] - domain[0])
|
const domainSpan = Math.abs(domain[1] - domain[0])
|
||||||
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
|
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
|
||||||
return Math.min(
|
const minimumSpan = resolveMinimumZoomSpan(domain, groups, props)
|
||||||
ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
|
return Math.max(
|
||||||
Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)),
|
ZOOM_CONSTRAINTS.MIN_SCALE,
|
||||||
|
minimumSpan > 0 ? domainSpan / minimumSpan : ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const canZoomTrack = (track: TrackLayout): boolean =>
|
const constrainTransform = (
|
||||||
hasMinimumVisibleXValues(
|
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.seriesList,
|
||||||
track.xScale.domain() as [number, number],
|
track.xScale.domain() as [number, number],
|
||||||
Number(props.minVisiblePoints),
|
Number.isFinite(minimum) && minimum > 0 ? Math.ceil(minimum) + 1 : minimum,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
const canZoomSharedTracks = (): boolean => {
|
const canZoomSharedTracks = (): boolean => {
|
||||||
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
||||||
return tracks.length > 0 && tracks.every(canZoomTrack)
|
return tracks.length > 0 && tracks.every(canZoomTrack)
|
||||||
@@ -265,9 +285,15 @@ export function useWaveformZoom(context: ZoomContext) {
|
|||||||
)
|
)
|
||||||
if (!overlay) return
|
if (!overlay) return
|
||||||
const dataDomain = resolveInitialTrackDomain(track)
|
const dataDomain = resolveInitialTrackDomain(track)
|
||||||
|
const groups = [track.seriesList]
|
||||||
const behavior = zoom<SVGRectElement, unknown>()
|
const behavior = zoom<SVGRectElement, unknown>()
|
||||||
.filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
|
.filter((event) => {
|
||||||
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
|
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([
|
.extent([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[track.width, track.height],
|
[track.width, track.height],
|
||||||
@@ -293,9 +319,15 @@ export function useWaveformZoom(context: ZoomContext) {
|
|||||||
}
|
}
|
||||||
const overlay = sharedOverlayElement.value
|
const overlay = sharedOverlayElement.value
|
||||||
if (!overlay) return
|
if (!overlay) return
|
||||||
|
const groups = trackLayouts.value
|
||||||
|
.filter((track) => track.hasVisibleSeries)
|
||||||
|
.map((track) => track.seriesList)
|
||||||
const behavior = zoom<SVGRectElement, unknown>()
|
const behavior = zoom<SVGRectElement, unknown>()
|
||||||
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
|
.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([
|
.extent([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[innerWidth.value, innerHeight.value],
|
[innerWidth.value, innerHeight.value],
|
||||||
|
|||||||
167
src/components/interaction/zoomConstraints.ts
Normal file
167
src/components/interaction/zoomConstraints.ts
Normal 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)
|
||||||
|
}
|
||||||
248
src/components/waveformChartCases/zoomPointConstraints.test.ts
Normal file
248
src/components/waveformChartCases/zoomPointConstraints.test.ts
Normal 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])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -22,8 +22,7 @@ defineExpose({ resetViewport })
|
|||||||
v-model:annotations="model.annotations"
|
v-model:annotations="model.annotations"
|
||||||
v-model:hidden-series-ids="model.hiddenSeriesIds"
|
v-model:hidden-series-ids="model.hiddenSeriesIds"
|
||||||
:data="model.data"
|
:data="model.data"
|
||||||
:min-zoom-span="model.minZoomSpan"
|
:min-visible-points="2"
|
||||||
:min-visible-points="5"
|
|
||||||
:initial-x-domain="model.initialXDomain"
|
:initial-x-domain="model.initialXDomain"
|
||||||
:display-mode="model.displayMode"
|
:display-mode="model.displayMode"
|
||||||
:overlay-mode="model.overlayMode"
|
:overlay-mode="model.overlayMode"
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ export interface DemoControlPanelModel {
|
|||||||
|
|
||||||
export interface DemoChartModel {
|
export interface DemoChartModel {
|
||||||
data: WaveformData
|
data: WaveformData
|
||||||
minZoomSpan?: number
|
|
||||||
initialXDomain?: [number, number]
|
initialXDomain?: [number, number]
|
||||||
displayMode: WaveformDisplayMode
|
displayMode: WaveformDisplayMode
|
||||||
overlayMode: WaveformOverlayMode
|
overlayMode: WaveformOverlayMode
|
||||||
|
|||||||
Reference in New Issue
Block a user