Files
waveform-analysis/src/components/core/useWaveformChartLifecycle.ts

307 lines
9.1 KiB
TypeScript
Raw Normal View History

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<HTMLDivElement | undefined>
titleMeasureElement: Ref<HTMLSpanElement | undefined>
resizeObserver: ShallowRef<ResizeObserver | undefined>
observedWidth: Ref<number>
observedHeight: Ref<number>
measuredTitleWidth: Ref<number>
measuredTitleHeight: Ref<number>
resolvedTitleText: ComputedRef<string>
titleAreaReserved: ComputedRef<boolean>
titleMeasureStyle: ComputedRef<object>
spacePressed: Ref<boolean>
pointerInsideChart: Ref<boolean>
currentPage: Ref<number>
pageCount: ComputedRef<number>
pagedTracks: ComputedRef<Array<DisplayTrack | undefined>>
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
chartSeries: ComputedRef<DisplaySeries[]>
chartTracks: ComputedRef<DisplayTrack[]>
innerWidth: ComputedRef<number>
innerHeight: ComputedRef<number>
activeInteractionMode: ComputedRef<string | undefined>
hiddenSeriesIdSet: ComputedRef<Set<string>>
internalHiddenSeriesIds: Ref<Set<string>>
independentTransforms: ShallowRef<ZoomTransform[]>
independentYDomains: Ref<Record<number, [number, number]>>
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
clearHover: () => void
cancelAnnotation: () => void
configureZoom: () => void
resetViewport: () => 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,
clearHover,
cancelAnnotation,
configureZoom,
resetViewport,
cancelPendingHover,
clearZoomBindings,
} = context
function handleInteractionKeyDown(event: KeyboardEvent) {
if (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,
() => 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(
() => 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 }
}