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)],
|
||||
[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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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],
|
||||
|
||||
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: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"
|
||||
|
||||
@@ -86,7 +86,6 @@ export interface DemoControlPanelModel {
|
||||
|
||||
export interface DemoChartModel {
|
||||
data: WaveformData
|
||||
minZoomSpan?: number
|
||||
initialXDomain?: [number, number]
|
||||
displayMode: WaveformDisplayMode
|
||||
overlayMode: WaveformOverlayMode
|
||||
|
||||
Reference in New Issue
Block a user