import { zoomIdentity, type ZoomTransform } from 'd3' import { nextTick, onBeforeUnmount, onMounted, watch, type ComputedRef, type Ref, type ShallowRef, } from 'vue' import type { AnnotationSeriesCandidate } from '../annotation' import type { useWaveformAnnotationInteraction } from '../annotation' import type { DisplaySeries, DisplayTrack } from './types' import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes' interface LifecycleContext { props: ResolvedWaveformChartProps emit: WaveformChartEmit container: Ref titleMeasureElement: Ref resizeObserver: ShallowRef observedWidth: Ref observedHeight: Ref measuredTitleWidth: Ref measuredTitleHeight: Ref resolvedTitleText: ComputedRef titleAreaReserved: ComputedRef titleMeasureStyle: ComputedRef spacePressed: Ref pointerInsideChart: Ref currentPage: Ref pageCount: ComputedRef pagedTracks: ComputedRef> gridOptions: ComputedRef<{ rowCount: number; columnCount: number }> chartSeries: ComputedRef chartTracks: ComputedRef innerWidth: ComputedRef innerHeight: ComputedRef activeInteractionMode: ComputedRef hiddenSeriesIdSet: ComputedRef> internalHiddenSeriesIds: Ref> independentTransforms: ShallowRef independentYDomains: Ref> annotationInteraction: ReturnType editorSeriesOptions: Ref isPresentationMode: ComputedRef clearHover: () => void cancelAnnotation: () => void configureZoom: () => void resetViewport: () => void cancelViewportDrag: () => void cancelPendingHover: () => void clearZoomBindings: () => void } function isEditableTarget(target: EventTarget | null): boolean { return ( target instanceof Element && Boolean( target.closest( 'button, input, select, textarea, [contenteditable]:not([contenteditable="false"])', ), ) ) } export function useWaveformChartLifecycle(context: LifecycleContext) { const { props, emit, container, titleMeasureElement, resizeObserver, observedWidth, observedHeight, measuredTitleWidth, measuredTitleHeight, resolvedTitleText, titleAreaReserved, titleMeasureStyle, spacePressed, pointerInsideChart, currentPage, pageCount, pagedTracks, gridOptions, chartSeries, chartTracks, innerWidth, innerHeight, activeInteractionMode, hiddenSeriesIdSet, internalHiddenSeriesIds, independentTransforms, independentYDomains, annotationInteraction, editorSeriesOptions, isPresentationMode, clearHover, cancelAnnotation, configureZoom, resetViewport, cancelViewportDrag, cancelPendingHover, clearZoomBindings, } = context function handleInteractionKeyDown(event: KeyboardEvent) { if ( isPresentationMode.value || event.code !== 'Space' || !props.pannable || !pointerInsideChart.value ) { return } if (isEditableTarget(event.target)) return spacePressed.value = true event.preventDefault() } function handleInteractionKeyUp(event: KeyboardEvent) { if (event.code === 'Space') spacePressed.value = false } function goToPage(page: number) { const nextPage = Math.min(pageCount.value, Math.max(1, Math.floor(page))) if (nextPage === currentPage.value) return currentPage.value = nextPage clearHover() annotationInteraction.closeContextMenu() cancelAnnotation() if (props.displayMode === 'independent') { independentTransforms.value = pagedTracks.value.map(() => zoomIdentity) independentYDomains.value = {} } void nextTick(configureZoom) emit('page-change', nextPage, pageCount.value) } function handleDataReferenceChange() { if (props.displayMode === 'independent') { const currentTransforms = independentTransforms.value independentTransforms.value = chartTracks.value.map( (_track, index) => currentTransforms[index] ?? zoomIdentity, ) } clearHover() editorSeriesOptions.value = [] void nextTick(configureZoom) } watch( [ innerWidth, innerHeight, () => props.zoomable, isPresentationMode, () => props.minZoomSpan, () => props.initialXDomain, () => props.initialXDomains, () => props.displayMode, () => chartTracks.value.length, currentPage, () => gridOptions.value.rowCount, () => gridOptions.value.columnCount, activeInteractionMode, ], async () => { await nextTick() configureZoom() }, { immediate: true }, ) watch( () => props.displayMode, () => { const previousPage = currentPage.value currentPage.value = 1 resetViewport() if (previousPage !== 1) emit('page-change', 1, pageCount.value) }, ) watch([pageCount, () => props.grid?.rowCount, () => props.grid?.columnCount], () => { const previousPage = currentPage.value currentPage.value = currentPage.value > pageCount.value ? pageCount.value : currentPage.value !== 1 ? 1 : currentPage.value if (previousPage !== currentPage.value) { emit('page-change', currentPage.value, pageCount.value) } clearHover() void nextTick(configureZoom) }) watch(activeInteractionMode, () => { editorSeriesOptions.value = [] }) watch(isPresentationMode, (enabled) => { spacePressed.value = false if (!enabled) return cancelViewportDrag() clearZoomBindings() clearHover() annotationInteraction.closeContextMenu() cancelAnnotation() }) watch( () => chartSeries.value.map((series) => series.id).join('\u0000'), () => { if (props.hiddenSeriesIds !== undefined) return const availableIds = new Set(chartSeries.value.map((series) => series.id)) const retainedIds = new Set( Array.from(internalHiddenSeriesIds.value).filter((seriesId) => availableIds.has(seriesId)), ) if ( retainedIds.size !== internalHiddenSeriesIds.value.size || Array.from(internalHiddenSeriesIds.value).some((seriesId) => !retainedIds.has(seriesId)) ) { internalHiddenSeriesIds.value = retainedIds } }, { immediate: true }, ) watch( () => chartTracks.value .flatMap((track) => track.visibleSeries.map((series) => series.id)) .join('\u0000'), () => { clearHover() editorSeriesOptions.value = [] const draftSeriesId = annotationInteraction.editorDraft.value?.annotation.seriesId if ( draftSeriesId && (!chartSeries.value.some((series) => series.id === draftSeriesId) || hiddenSeriesIdSet.value.has(draftSeriesId)) ) { annotationInteraction.closeEditor() } const contextAnnotationId = annotationInteraction.contextMenu.value?.annotationId const contextAnnotation = props.annotations.find((item) => item.id === contextAnnotationId) if (contextAnnotation && hiddenSeriesIdSet.value.has(contextAnnotation.seriesId)) { annotationInteraction.closeContextMenu() } void nextTick(configureZoom) }, ) watch( () => props.annotationsVisible, (visible) => { if (!visible) { annotationInteraction.closeContextMenu() cancelAnnotation() } }, ) watch( () => props.annotations, (annotations) => { const menuId = annotationInteraction.contextMenu.value?.annotationId if (menuId && !annotations.some((item) => item.id === menuId)) { annotationInteraction.closeContextMenu() } const draft = annotationInteraction.editorDraft.value if (draft?.mode === 'edit' && !annotations.some((item) => item.id === draft.annotation.id)) { annotationInteraction.closeEditor() } }, { deep: true }, ) function measureTitle() { if (!titleAreaReserved.value || !titleMeasureElement.value) { measuredTitleWidth.value = 0 measuredTitleHeight.value = 0 return } const bounds = titleMeasureElement.value.getBoundingClientRect() measuredTitleWidth.value = titleMeasureElement.value.scrollWidth || bounds.width measuredTitleHeight.value = titleMeasureElement.value.scrollHeight || bounds.height } watch( [resolvedTitleText, titleAreaReserved, titleMeasureStyle], async () => { measuredTitleWidth.value = 0 measuredTitleHeight.value = 0 await nextTick() measureTitle() }, { immediate: true }, ) onMounted(() => { window.addEventListener('keydown', handleInteractionKeyDown) window.addEventListener('keyup', handleInteractionKeyUp) if (!container.value) return resizeObserver.value = new ResizeObserver(([entry]) => { observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0) observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0) void nextTick(measureTitle) }) resizeObserver.value.observe(container.value) }) onBeforeUnmount(() => { window.removeEventListener('keydown', handleInteractionKeyDown) window.removeEventListener('keyup', handleInteractionKeyUp) cancelPendingHover() resizeObserver.value?.disconnect() clearZoomBindings() editorSeriesOptions.value = [] }) return { goToPage, handleDataReferenceChange } }