feat(chart): emit zoom completion payload
This commit is contained in:
43
src/App.vue
43
src/App.vue
@@ -16,6 +16,7 @@ import {
|
||||
type WaveformOverlayMode,
|
||||
type WaveformSeries,
|
||||
type WaveformTitleOptions,
|
||||
type WaveformZoomEndPayload,
|
||||
} from './components'
|
||||
import chartWaveformsJson from './data/chartWaveforms.json'
|
||||
import demoWaveformsJson from './data/demoWaveforms.json'
|
||||
@@ -182,10 +183,49 @@ const frameOneSeries = waveformSeries.filter(
|
||||
(series) => series.id === frameOneTrackId || series.trackId === frameOneTrackId,
|
||||
)
|
||||
const remainingSeries = waveformSeries.filter((series) => !frameOneSeries.includes(series))
|
||||
const chartData: WaveformData = {
|
||||
const fullChartData: WaveformData = {
|
||||
kind: 'series',
|
||||
series: [...frameOneSeries, ...basicCurveDemoSeries, ...stepDemoSeries, ...remainingSeries],
|
||||
}
|
||||
const chartData = ref<WaveformData>(fullChartData)
|
||||
let zoomRequestSequence = 0
|
||||
|
||||
function filterWaveformData(data: WaveformData, start: number, end: number): WaveformData {
|
||||
const lower = Math.min(start, end)
|
||||
const upper = Math.max(start, end)
|
||||
if (data.kind === 'samples') return data
|
||||
if (data.kind === 'points') {
|
||||
return {
|
||||
kind: 'points',
|
||||
points: data.points.filter((point) => point.x >= lower && point.x <= upper),
|
||||
}
|
||||
}
|
||||
return {
|
||||
kind: 'series',
|
||||
series: data.series.map((series) => ({
|
||||
...series,
|
||||
data:
|
||||
series.data.kind === 'points'
|
||||
? {
|
||||
kind: 'points',
|
||||
points: series.data.points.filter((point) => point.x >= lower && point.x <= upper),
|
||||
}
|
||||
: series.data,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function handleZoomEnd(payload: WaveformZoomEndPayload) {
|
||||
// Demo-only sequence number cancellation. Production code should use AbortController
|
||||
// to cancel in-flight requests when a newer zoom gesture arrives.
|
||||
const requestSequence = ++zoomRequestSequence
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 80))
|
||||
if (requestSequence !== zoomRequestSequence) return
|
||||
|
||||
// Demo-only stand-in for the backend response. Production code should replace this
|
||||
// with a request using payload.start/payload.end and the optional channel metadata.
|
||||
chartData.value = filterWaveformData(fullChartData, payload.start, payload.end)
|
||||
}
|
||||
const titleOptions = computed<WaveformTitleOptions>(() => ({
|
||||
visible: titleVisible.value,
|
||||
text: titleText.value,
|
||||
@@ -512,6 +552,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
||||
v-model:annotations-visible="annotationsVisible"
|
||||
v-model:interaction-mode="interactionMode"
|
||||
v-model:hidden-series-ids="hiddenSeriesIds"
|
||||
@zoom-end="handleZoomEnd"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { flushAnimationFrames, pendingAnimationFrameCount, resizeObservers } from '../test/setup'
|
||||
import WaveformChart from './WaveformChart.vue'
|
||||
@@ -2085,6 +2085,82 @@ describe('WaveformChart', () => {
|
||||
expect(endpoints()[1]).toBe(initialEndpoints[1])
|
||||
})
|
||||
|
||||
it('emits one zoom-end payload after a shared zoom gesture completes', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2, y: 1 },
|
||||
],
|
||||
})
|
||||
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
|
||||
})
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: -4000,
|
||||
clientX: overlayWidth / 2,
|
||||
clientY: 145,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
expect(wrapper.emitted('zoom-end')).toBeUndefined()
|
||||
flushAnimationFrames()
|
||||
// Wait for zoom-end debounce (internal throttle + flush)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flushPromises()
|
||||
|
||||
const endEvents = wrapper.emitted('zoom-end') ?? []
|
||||
expect(endEvents).toHaveLength(1)
|
||||
const payload = endEvents[0]?.[0] as { start: number; end: number }
|
||||
expect(payload.start).toBeGreaterThanOrEqual(0)
|
||||
expect(payload.end).toBeLessThanOrEqual(2)
|
||||
expect(payload.start).toBeLessThan(payload.end)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('includes track and series IDs in independent zoom-end payloads', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||
displayMode: 'independent',
|
||||
grid: { rowCount: 1, columnCount: 2 },
|
||||
})
|
||||
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 260 }),
|
||||
})
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: -4000,
|
||||
clientX: overlayWidth / 2,
|
||||
clientY: 130,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flushPromises()
|
||||
|
||||
const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as
|
||||
{ trackIndex: number; seriesIds: string[] } | undefined
|
||||
expect(payload).toMatchObject({ trackIndex: 0, seriesIds: ['channel-0'] })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilds cached domains only when the data reference changes', async () => {
|
||||
const firstData: WaveformData = {
|
||||
kind: 'points',
|
||||
@@ -2105,6 +2181,51 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
})
|
||||
|
||||
it('keeps controlled annotations when replacing the loaded data window', async () => {
|
||||
const annotations = [
|
||||
{ id: 'window-note', seriesId: 'series-0', x: 0.5, y: 0.5, text: '窗口标注' },
|
||||
]
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
},
|
||||
{ annotations },
|
||||
)
|
||||
expect(wrapper.find('[data-annotation-id="window-note"]').exists()).toBe(true)
|
||||
|
||||
await wrapper.setProps({
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 2, y: 0 },
|
||||
{ x: 3, y: 1 },
|
||||
],
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-annotation-id="window-note"]').exists()).toBe(false)
|
||||
expect(annotations).toEqual([
|
||||
{ id: 'window-note', seriesId: 'series-0', x: 0.5, y: 0.5, text: '窗口标注' },
|
||||
])
|
||||
|
||||
await wrapper.setProps({
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[data-annotation-id="window-note"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders named multi-channel paths as independent tracks by default', async () => {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'series',
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
type WaveformPoint,
|
||||
type WaveformRenderingOptions,
|
||||
type WaveformTitleOptions,
|
||||
type WaveformZoomEndPayload,
|
||||
} from './data/types'
|
||||
import {
|
||||
ANNOTATION_AMBIGUITY_DISTANCE,
|
||||
@@ -128,6 +129,7 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
'point-hover': [point: WaveformPoint | null]
|
||||
'zoom-change': [domain: [number, number]]
|
||||
'zoom-end': [payload: WaveformZoomEndPayload]
|
||||
'update:annotations': [annotations: WaveformAnnotation[]]
|
||||
'update:annotations-visible': [visible: boolean]
|
||||
'update:interaction-mode': [mode: WaveformInteractionMode]
|
||||
@@ -173,6 +175,7 @@ let generatedAnnotationId = 0
|
||||
let synchronizingZoomTransform = false
|
||||
let pendingSharedZoomTransform: ZoomTransform | null = null
|
||||
const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
|
||||
const lastZoomedTrackIndexes = new Set<number>()
|
||||
const zoomThrottle = useAnimationFrameThrottle()
|
||||
const hoverThrottle = useAnimationFrameThrottle()
|
||||
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
|
||||
@@ -586,20 +589,24 @@ function commitPendingZoom() {
|
||||
emit('zoom-change', [domain[0], domain[1]])
|
||||
}
|
||||
|
||||
if (!pendingIndependentZoomTransforms.size) return
|
||||
const nextTransforms = [...independentTransforms.value]
|
||||
const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
|
||||
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
|
||||
nextTransforms[trackIndex] = transform
|
||||
})
|
||||
pendingIndependentZoomTransforms.clear()
|
||||
independentTransforms.value = nextTransforms
|
||||
changedTrackIndexes.forEach((trackIndex) => {
|
||||
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||
if (!track) return
|
||||
const domain = track.xScale.domain()
|
||||
emit('zoom-change', [domain[0], domain[1]])
|
||||
})
|
||||
if (pendingIndependentZoomTransforms.size) {
|
||||
const nextTransforms = [...independentTransforms.value]
|
||||
const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
|
||||
// Clear stale track indexes before recording the new batch
|
||||
lastZoomedTrackIndexes.clear()
|
||||
changedTrackIndexes.forEach((trackIndex) => lastZoomedTrackIndexes.add(trackIndex))
|
||||
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
|
||||
nextTransforms[trackIndex] = transform
|
||||
})
|
||||
pendingIndependentZoomTransforms.clear()
|
||||
independentTransforms.value = nextTransforms
|
||||
changedTrackIndexes.forEach((trackIndex) => {
|
||||
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||
if (!track) return
|
||||
const domain = track.xScale.domain()
|
||||
emit('zoom-change', [domain[0], domain[1]])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleZoomCommit() {
|
||||
@@ -609,11 +616,35 @@ function scheduleZoomCommit() {
|
||||
function flushPendingZoom() {
|
||||
zoomThrottle.flush()
|
||||
commitPendingZoom()
|
||||
emitZoomEnd()
|
||||
}
|
||||
|
||||
function emitZoomEnd() {
|
||||
if (props.displayMode === 'independent') {
|
||||
lastZoomedTrackIndexes.forEach((trackIndex) => {
|
||||
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||
if (!track) return
|
||||
const domain = track.xScale.domain() as [number, number]
|
||||
emit('zoom-end', {
|
||||
start: domain[0],
|
||||
end: domain[1],
|
||||
trackIndex,
|
||||
seriesIds: track.seriesList.map((series) => series.id),
|
||||
})
|
||||
})
|
||||
lastZoomedTrackIndexes.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const domain = sharedZoomDomain.value
|
||||
emit('zoom-end', { start: domain[0], end: domain[1] })
|
||||
}
|
||||
|
||||
function cancelPendingZoom() {
|
||||
// Clear all pending zoom state to prevent stale emissions
|
||||
pendingSharedZoomTransform = null
|
||||
pendingIndependentZoomTransforms.clear()
|
||||
lastZoomedTrackIndexes.clear()
|
||||
zoomThrottle.cancel()
|
||||
}
|
||||
|
||||
|
||||
@@ -269,7 +269,9 @@ function chooseBestPlacement(
|
||||
|
||||
// Try to find a placement that fits completely within bounds
|
||||
for (const placement of placements) {
|
||||
if (isPlacementWithinBounds(anchorX, anchorY, width, height, placement, plotWidth, plotHeight)) {
|
||||
if (
|
||||
isPlacementWithinBounds(anchorX, anchorY, width, height, placement, plotWidth, plotHeight)
|
||||
) {
|
||||
return placement
|
||||
}
|
||||
}
|
||||
@@ -423,8 +425,8 @@ export function layoutAnnotations(
|
||||
|
||||
// Choose best placement only if no manual offset exists
|
||||
const hasManualOffset =
|
||||
Number.isFinite(annotation.labelOffsetX) && annotation.labelOffsetX !== 0 ||
|
||||
Number.isFinite(annotation.labelOffsetY) && annotation.labelOffsetY !== 0
|
||||
(Number.isFinite(annotation.labelOffsetX) && annotation.labelOffsetX !== 0) ||
|
||||
(Number.isFinite(annotation.labelOffsetY) && annotation.labelOffsetY !== 0)
|
||||
|
||||
const { width, height } = annotationBoxSize(lines, trackWidth, localHeight)
|
||||
const placement: AnnotationPlacement = hasManualOffset
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformZoomEndPayload,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type {
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformZoomEndPayload,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
|
||||
@@ -424,7 +424,6 @@ watch(
|
||||
>
|
||||
暂无可见曲线
|
||||
</text>
|
||||
|
||||
</g>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bisector } from 'd3'
|
||||
|
||||
import type { WaveformPoint, WaveformRenderingOptions } from '../types'
|
||||
import type { WaveformPoint, WaveformRenderingOptions } from '@/types'
|
||||
|
||||
export interface ResolvedWaveformRenderingOptions {
|
||||
downsample: boolean
|
||||
|
||||
@@ -13,6 +13,7 @@ export type {
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformZoomEndPayload,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
|
||||
@@ -26,6 +26,14 @@ export type WaveformOverlayMode = 'single-axis' | 'multi-axis'
|
||||
/** 标注工具模式 */
|
||||
export type WaveformInteractionMode = 'zoom' | 'annotation'
|
||||
|
||||
/** Describes the X-axis viewport after a zoom gesture completes. */
|
||||
export interface WaveformZoomEndPayload {
|
||||
start: number
|
||||
end: number
|
||||
trackIndex?: number
|
||||
seriesIds?: string[]
|
||||
}
|
||||
|
||||
/** 标注颜色样式 */
|
||||
export interface WaveformAnnotationStyle {
|
||||
borderColor?: string
|
||||
|
||||
@@ -8,6 +8,7 @@ export type {
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformZoomEndPayload,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
|
||||
Reference in New Issue
Block a user