feat(annotation): add draggable label positioning

This commit is contained in:
李启源
2026-07-21 11:25:19 +08:00
parent a15df36e18
commit e5ce7375cb
16 changed files with 1329 additions and 388 deletions

View File

@@ -43,8 +43,8 @@ import {
import {
ANNOTATION_AMBIGUITY_DISTANCE,
ANNOTATION_HIT_RADIUS,
findAnnotationSeriesCandidates,
interpolateAnnotationPoint,
findAnnotationSeriesCandidates,
layoutAnnotations,
useWaveformAnnotationInteraction,
type AnnotationEditorAnchor,
@@ -57,7 +57,7 @@ import {
type AnnotationTrackLayout,
} from './annotation'
import { WaveformTooltip } from './interaction'
import { WaveformTrack } from './rendering'
import { WaveformLegend, WaveformTrack } from './rendering'
import {
channelColors,
margin as chartMargin,
@@ -160,6 +160,7 @@ const independentTransforms = shallowRef<ZoomTransform[]>([])
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
const hoveredTrackIndex = ref<number | null>(null)
const hoverPosition = ref({ x: 0, y: 0 })
const suppressHoverUntilMove = ref(false)
const currentPage = ref(1)
const resizeObserver = shallowRef<ResizeObserver>()
const zoomBehaviors = new Map<number | 'shared', ZoomBehavior<SVGRectElement, unknown>>()
@@ -719,7 +720,8 @@ function commitHover(
if (!hoveredPointsMatch(nextPoints)) hoveredSeriesPoints.value = nextPoints
hoveredTrackIndex.value = trackIndex
hoverPosition.value = position
emit('point-hover', nextPoints[0]?.point ?? null)
// Emit using the updated hoveredSeriesPoints to avoid race condition
emit('point-hover', hoveredSeriesPoints.value[0]?.point ?? null)
}
function clearHover() {
@@ -729,6 +731,28 @@ function clearHover() {
emit('point-hover', null)
}
function beginAnnotationDrag() {
suppressHoverUntilMove.value = true
clearHover()
}
function endAnnotationDrag(cancelled: boolean = false) {
// 如果是 cancel立即恢复悬停而不是等待下次移动
if (cancelled) {
suppressHoverUntilMove.value = false
} else {
suppressHoverUntilMove.value = true
}
clearHover()
}
function consumeHoverSuppression(): boolean {
if (!suppressHoverUntilMove.value) return false
suppressHoverUntilMove.value = false
clearHover()
return true
}
function nearestPoint(series: DisplaySeries, xValue: number): WaveformPoint | undefined {
const index = bisector((point: WaveformPoint) => point.x).center(series.points, xValue)
return series.points[index]
@@ -976,6 +1000,23 @@ function handleExistingAnnotationContextMenu(annotationId: string, event: MouseE
})
}
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 context = annotationInteraction.contextMenu.value
const annotationId = context?.annotationId
@@ -1028,6 +1069,7 @@ function confirmAnnotation(annotation: WaveformAnnotation) {
}
function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
if (consumeHoverSuppression()) return
const overlay = event.currentTarget as SVGRectElement | null
if (!overlay) return
const [pointerX, pointerY] = pointer(event, overlay)
@@ -1053,10 +1095,13 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
}
function handleSharedPointerMove(event: PointerEvent) {
if (consumeHoverSuppression()) return
if (!sharedOverlayElement.value || !trackLayouts.value.length) return
const [pointerX, pointerY] = pointer(event, sharedOverlayElement.value)
scheduleHover(() => {
const referenceTrack = resolveTrackAtPointer(pointerX, pointerY) ?? trackLayouts.value[0]
const resolvedTrack = resolveTrackAtPointer(pointerX, pointerY)
const fallbackTrack = trackLayouts.value.find((track) => track.hasVisibleSeries)
const referenceTrack = resolvedTrack ?? fallbackTrack
if (!referenceTrack) return
const localPointerX = Math.max(
0,
@@ -1370,25 +1415,45 @@ onBeforeUnmount(() => {
:frame-style="frameStyle"
:time-unit="timeUnit"
:y-label="yLabel"
:legend-position="legendPosition"
:legend-orientation="legendOrientation"
:legend-background-color="legendBackgroundColor"
:legend-interactive="legendInteractive"
:hidden-series-ids="resolvedHiddenSeriesIds"
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
@pointer-move="handleIndependentPointerMove($event, track.index)"
@pointer-leave="clearHover"
@click="handleAnnotationClick($event, track.index)"
@contextmenu="handleAnnotationContextMenu($event, track.index)"
@series-visibility-toggle="toggleSeriesVisibility"
/>
<WaveformAnnotationLayer
:annotations="renderedAnnotations"
:visible="annotationsVisible"
@contextmenu="handleExistingAnnotationContextMenu"
@drag-start="beginAnnotationDrag"
@move="handleAnnotationMove"
@drag-end="endAnnotationDrag"
/>
<g class="waveform-chart__legend-layer">
<g
v-for="track in trackLayouts"
:key="`legend-${track.index}-${track.series.name}`"
class="waveform-chart__legend-track"
:data-legend-track-index="track.index"
:transform="`translate(${track.left}, ${track.top})`"
>
<WaveformLegend
v-if="!track.isEmpty && track.legendSeries.length > 1"
:series="track.legendSeries"
:position="legendPosition"
:orientation="legendOrientation"
:background-color="legendBackgroundColor"
:interactive="legendInteractive"
:hidden-series-ids="resolvedHiddenSeriesIds"
:width="track.width ?? innerWidth"
:height="track.height"
@toggle="toggleSeriesVisibility"
/>
</g>
</g>
<text
v-if="resolvedXLabel"
class="waveform-chart__label"