Files
waveform-analysis/src/components/annotation/useWaveformChartAnnotations.ts

347 lines
12 KiB
TypeScript
Raw Normal View History

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<HTMLDivElement | undefined>
chartSeries: ComputedRef<DisplaySeries[]>
hiddenSeriesIdSet: ComputedRef<Set<string>>
internalHiddenSeriesIds: Ref<Set<string>>
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
trackLayouts: ComputedRef<TrackLayout[]>
annotationLayoutsForTrack: (track: TrackLayout) => AnnotationTrackLayout[]
resolveSeriesYScale: (track: TrackLayout, seriesId: string) => ScaleLinear<number, number>
chartWidth: ComputedRef<number>
chartHeight: ComputedRef<number>
innerWidth: ComputedRef<number>
resolvedChartLeftMargin: ComputedRef<number>
titleAreaHeight: ComputedRef<number>
chartTopMargin: ComputedRef<number>
activeInteractionMode: ComputedRef<WaveformInteractionMode | undefined>
}
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,
}
}