import { pointer, type ScaleLinear } from 'd3' import type { ComputedRef, Ref } from 'vue' import type { WaveformAnnotation, WaveformInteractionMode } from '../data/types' import { findClosestTrackAtPointer } from '../core/layout' import type { DisplaySeries, TrackLayout } from '../core/types' import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes' import { ANNOTATION_AMBIGUITY_DISTANCE, ANNOTATION_HIT_RADIUS, findAnnotationSeriesCandidates, interpolateAnnotationPoint, } from './markup' import type { AnnotationEditorAnchor, AnnotationHit, AnnotationSeriesCandidate, AnnotationTrackLayout, } from './types' import type { useWaveformAnnotationInteraction } from './useWaveformAnnotationInteraction' interface AnnotationContext { props: ResolvedWaveformChartProps emit: WaveformChartEmit container: Ref chartSeries: ComputedRef hiddenSeriesIdSet: ComputedRef> internalHiddenSeriesIds: Ref> annotationInteraction: ReturnType editorSeriesOptions: Ref trackLayouts: ComputedRef annotationLayoutsForTrack: (track: TrackLayout) => AnnotationTrackLayout[] resolveSeriesYScale: (track: TrackLayout, seriesId: string) => ScaleLinear chartWidth: ComputedRef chartHeight: ComputedRef innerWidth: ComputedRef resolvedChartLeftMargin: ComputedRef titleAreaHeight: ComputedRef chartTopMargin: ComputedRef activeInteractionMode: ComputedRef } interface AnnotationCandidateContext { candidates: AnnotationSeriesCandidate[] editorAnchor: AnnotationEditorAnchor } let generatedAnnotationId = 0 function makeAnnotationId(): string { generatedAnnotationId += 1 if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } return `annotation-${Date.now()}-${generatedAnnotationId}` } export function useWaveformChartAnnotations(context: AnnotationContext) { const { props, emit, container, chartSeries, hiddenSeriesIdSet, internalHiddenSeriesIds, annotationInteraction, editorSeriesOptions, trackLayouts, annotationLayoutsForTrack, resolveSeriesYScale, chartWidth, chartHeight, innerWidth, resolvedChartLeftMargin, titleAreaHeight, chartTopMargin, activeInteractionMode, } = context function toggleSeriesVisibility(seriesId: string) { if (!chartSeries.value.some((series) => series.id === seriesId)) return const nextHiddenSeriesIds = new Set(hiddenSeriesIdSet.value) const visible = nextHiddenSeriesIds.has(seriesId) if (visible) nextHiddenSeriesIds.delete(seriesId) else nextHiddenSeriesIds.add(seriesId) const ids = Array.from(nextHiddenSeriesIds) if (props.hiddenSeriesIds === undefined) internalHiddenSeriesIds.value = nextHiddenSeriesIds emit('update:hidden-series-ids', ids) emit('series-visibility-change', { seriesId, visible, hiddenSeriesIds: ids }) } function resolvePointerEditorAnchor( event: MouseEvent, trackIndex?: number, ): AnnotationEditorAnchor { const overlay = event.currentTarget as SVGRectElement | null if (!overlay) return { x: chartWidth.value / 2, y: chartHeight.value / 2 } const [pointerX, pointerY] = pointer(event, overlay) const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex] return { x: resolvedChartLeftMargin.value + (track ? track.left + pointerX : pointerX), y: titleAreaHeight.value + chartTopMargin.value + (track ? track.top + pointerY : pointerY), } } function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor { const track = trackLayouts.value.find((item) => item.seriesList.some((series) => series.id === annotation.seriesId), ) return { x: track ? resolvedChartLeftMargin.value + track.left + track.xScale(annotation.x) : chartWidth.value / 2, y: track ? titleAreaHeight.value + chartTopMargin.value + track.top + resolveSeriesYScale(track, annotation.seriesId)(annotation.y) : chartHeight.value / 2, } } function beginCreate( hit: AnnotationHit, anchor: AnnotationEditorAnchor, candidates: AnnotationSeriesCandidate[], ) { annotationInteraction.openCreate(hit, makeAnnotationId, anchor) editorSeriesOptions.value = candidates const draft = annotationInteraction.editorDraft.value const track = trackLayouts.value.find((item) => item.index === hit.trackIndex) const series = track?.seriesList.find((item) => item.id === hit.seriesId) if (draft?.mode === 'add') { draft.annotation.style = { borderColor: series?.color || '#1677ff', textColor: '#333333', backgroundColor: 'rgba(255, 255, 255, 0.92)', } } } function changeDraftSeries(seriesId: string) { const draft = annotationInteraction.editorDraft.value const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId) const track = trackLayouts.value.find((item) => item.seriesList.some((series) => series.id === seriesId), ) const series = track?.seriesList.find((item) => item.id === seriesId) const point = series && draft ? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType) : null if (!draft || !candidate || !track || !point) return draft.annotation = { ...draft.annotation, seriesId, y: point.y, style: { ...draft.annotation.style, borderColor: candidate.color }, } } function cancelAnnotation() { annotationInteraction.closeEditor() editorSeriesOptions.value = [] } function resolveTrackAtPointer( pointerX: number, pointerY: number, trackIndex?: number, ): TrackLayout | undefined { if (trackIndex !== undefined) { const track = trackLayouts.value[trackIndex] return track?.hasVisibleSeries ? track : undefined } const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries) return findClosestTrackAtPointer(visibleTracks, pointerX, pointerY) } function resolveAnnotationCandidates( event: MouseEvent, trackIndex?: number, ): AnnotationCandidateContext | null { const overlay = event.currentTarget as SVGRectElement | null if (!overlay || !trackLayouts.value.length) return null const [pointerX, pointerY] = pointer(event, overlay) const sharedPointerY = trackIndex === undefined ? pointerY : pointerY + (trackLayouts.value[trackIndex]?.top ?? 0) const referenceTrack = resolveTrackAtPointer(pointerX, sharedPointerY, trackIndex) if (!referenceTrack) return null const clampedX = Math.max(0, Math.min(innerWidth.value, pointerX)) const localPointerX = Math.max( 0, Math.min(referenceTrack.width, clampedX - referenceTrack.left), ) const xValue = referenceTrack.xScale.invert(localPointerX) return { candidates: findAnnotationSeriesCandidates( annotationLayoutsForTrack(referenceTrack), xValue, localPointerX, sharedPointerY, ), editorAnchor: resolvePointerEditorAnchor(event, trackIndex), } } function nearbyCandidates(candidates: AnnotationSeriesCandidate[]): AnnotationSeriesCandidate[] { const closest = candidates[0] if (!closest || closest.distance > ANNOTATION_HIT_RADIUS) return [] return candidates.filter( (candidate) => candidate.distance <= ANNOTATION_HIT_RADIUS && candidate.distance - closest.distance < ANNOTATION_AMBIGUITY_DISTANCE, ) } function handleAnnotationClick(event: MouseEvent, trackIndex?: number) { if (!props.annotationsVisible || activeInteractionMode.value !== 'annotation') return const candidateContext = resolveAnnotationCandidates(event, trackIndex) if (!candidateContext) return const nearby = nearbyCandidates(candidateContext.candidates) if (!nearby.length) return event.preventDefault() event.stopPropagation() beginCreate(nearby[0], candidateContext.editorAnchor, candidateContext.candidates) } function handleNativeContextMenu(event: MouseEvent) { const target = event.target if ( target instanceof Element && target.closest('input, textarea, [contenteditable]:not([contenteditable="false"])') ) { return } event.preventDefault() } function handleAnnotationContextMenu(event: MouseEvent, trackIndex?: number) { if (!props.annotationsVisible) return event.preventDefault() event.stopPropagation() const candidateContext = resolveAnnotationCandidates(event, trackIndex) if (!candidateContext || !candidateContext.candidates.length) return const nearby = nearbyCandidates(candidateContext.candidates) beginCreate( nearby[0] ?? candidateContext.candidates[0], candidateContext.editorAnchor, candidateContext.candidates, ) } function handleExistingAnnotationContextMenu(annotationId: string, event: MouseEvent) { if (!props.annotationsVisible || !container.value) return editorSeriesOptions.value = [] const annotation = props.annotations.find((item) => item.id === annotationId) if (!annotation) return const bounds = container.value.getBoundingClientRect() const editorAnchor = { x: Math.max(0, Math.min(event.clientX - bounds.left, chartWidth.value)), y: Math.max(0, Math.min(event.clientY - bounds.top, chartHeight.value)), } annotationInteraction.openContextMenu({ annotationId, x: Math.max(4, Math.min(event.clientX - bounds.left, chartWidth.value - 120)), y: Math.max(4, Math.min(event.clientY - bounds.top, chartHeight.value - 110)), editorAnchor, }) } function handleAnnotationMove(annotationId: string, offsetX: number, offsetY: number) { const annotation = props.annotations.find((item) => item.id === annotationId) if (!annotation || !Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return emit( 'update:annotations', props.annotations.map((item) => item.id === annotationId ? { ...item, labelOffsetX: offsetX, labelOffsetY: offsetY } : item, ), ) } function editContextAnnotation() { const menu = annotationInteraction.contextMenu.value const annotation = props.annotations.find((item) => item.id === menu?.annotationId) if (!annotation) return const track = trackLayouts.value.find((item) => item.seriesList.some((series) => series.id === annotation.seriesId), ) editorSeriesOptions.value = track ? findAnnotationSeriesCandidates( annotationLayoutsForTrack(track), annotation.x, track.xScale(annotation.x), track.top + resolveSeriesYScale(track, annotation.seriesId)(annotation.y), ) : [] annotationInteraction.openEdit( annotation, menu?.editorAnchor ?? resolveAnnotationEditorAnchor(annotation), ) } function deleteContextAnnotation() { const annotationId = annotationInteraction.contextMenu.value?.annotationId const annotation = props.annotations.find((item) => item.id === annotationId) if (!annotation) return emit( 'update:annotations', props.annotations.filter((item) => item.id !== annotation.id), ) emit('annotation-delete', annotation) annotationInteraction.closeContextMenu() } function confirmAnnotation(annotation: WaveformAnnotation) { const draft = annotationInteraction.editorDraft.value if (!draft) return if (draft.mode === 'add') { emit('update:annotations', [...props.annotations, annotation]) emit('annotation-create', annotation) } else { emit( 'update:annotations', props.annotations.map((item) => (item.id === annotation.id ? annotation : item)), ) emit('annotation-update', annotation, draft.previous) } cancelAnnotation() } return { toggleSeriesVisibility, changeDraftSeries, cancelAnnotation, resolveTrackAtPointer, handleAnnotationClick, handleNativeContextMenu, handleAnnotationContextMenu, handleExistingAnnotationContextMenu, handleAnnotationMove, editContextAnnotation, deleteContextAnnotation, confirmAnnotation, } }