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

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { RenderedAnnotation } from './types'
import { ANNOTATION_TEXT_FONT, ANNOTATION_TEXT_LINE_HEIGHT } from './markup'
@@ -10,11 +12,191 @@ interface Props {
const props = defineProps<Props>()
const emit = defineEmits<{
(event: 'contextmenu', annotationId: string, mouseEvent: MouseEvent): void
(event: 'drag-start'): void
(event: 'move', annotationId: string, offsetX: number, offsetY: number): void
(event: 'drag-end', cancelled?: boolean): void
}>()
interface DragState {
annotationId: string
pointerId: number
startX: number
startY: number
deltaX: number
deltaY: number
initialOffsetX: number
initialOffsetY: number
moved: boolean
target: SVGGElement
}
const dragOffsets = ref(new Map<string, { x: number; y: number }>())
const pendingCommittedOffset = ref<{
annotationId: string
x: number
y: number
} | null>(null)
let dragState: DragState | null = null
let dragFrame: number | null = null
let lastDragEndTimestamp = 0 // 使用时间戳替代 suppressContextMenu 布尔标志
// 缓存 draggedBox 结果以避免在每次渲染时重复计算
const draggedBoxCache = computed(() => {
const cache = new Map<string, RenderedAnnotation['box']>()
props.annotations.forEach((rendered) => {
const offset = dragOffsets.value.get(rendered.annotation.id)
if (!offset) {
cache.set(rendered.annotation.id, rendered.box)
} else {
cache.set(rendered.annotation.id, {
...rendered.box,
x: rendered.box.x + offset.x,
y: rendered.box.y + offset.y,
lineEndX: rendered.box.lineEndX + offset.x,
lineEndY: rendered.box.lineEndY + offset.y,
})
}
})
return cache
})
function draggedBox(rendered: RenderedAnnotation): RenderedAnnotation['box'] {
return draggedBoxCache.value.get(rendered.annotation.id) ?? rendered.box
}
function flushDragFrame() {
dragFrame = null
if (!dragState) return
dragOffsets.value = new Map(dragOffsets.value).set(dragState.annotationId, {
x: dragState.deltaX,
y: dragState.deltaY,
})
}
function scheduleDragFrame() {
if (dragFrame !== null) return
dragFrame = requestAnimationFrame(flushDragFrame)
}
function handlePointerDown(rendered: RenderedAnnotation, event: PointerEvent) {
if (event.button !== 0 || dragState) return
event.preventDefault()
event.stopPropagation()
const target = event.currentTarget as SVGGElement | null
if (!target || typeof target.setPointerCapture !== 'function') return
target.setPointerCapture(event.pointerId)
emit('drag-start')
const currentDragOffset = dragOffsets.value.get(rendered.annotation.id)
const initialOffsetX =
(Number.isFinite(rendered.annotation.labelOffsetX) ? rendered.annotation.labelOffsetX! : 0) +
(currentDragOffset?.x ?? 0)
const initialOffsetY =
(Number.isFinite(rendered.annotation.labelOffsetY) ? rendered.annotation.labelOffsetY! : 0) +
(currentDragOffset?.y ?? 0)
dragState = {
annotationId: rendered.annotation.id,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
deltaX: 0,
deltaY: 0,
initialOffsetX,
initialOffsetY,
moved: false,
target,
}
}
function handlePointerMove(event: PointerEvent) {
event.stopPropagation()
if (!dragState || event.pointerId !== dragState.pointerId) return
const deltaX = event.clientX - dragState.startX
const deltaY = event.clientY - dragState.startY
dragState.deltaX = deltaX
dragState.deltaY = deltaY
dragState.moved = dragState.moved || Math.hypot(deltaX, deltaY) >= 2
if (dragState.moved) scheduleDragFrame()
}
function finishPointerDrag(event: PointerEvent) {
event.preventDefault()
event.stopPropagation()
if (!dragState || event.pointerId !== dragState.pointerId) return
const state = dragState
const finalDeltaX = event.clientX - state.startX
const finalDeltaY = event.clientY - state.startY
state.deltaX = finalDeltaX
state.deltaY = finalDeltaY
// Recalculate moved based on final position to avoid spurious move events
state.moved = Math.hypot(finalDeltaX, finalDeltaY) >= 2
state.moved = state.moved || Math.hypot(finalDeltaX, finalDeltaY) >= 2
if (dragFrame !== null) {
cancelAnimationFrame(dragFrame)
dragFrame = null
}
if (state.moved) {
dragOffsets.value = new Map(dragOffsets.value).set(state.annotationId, {
x: state.deltaX,
y: state.deltaY,
})
const offsetX = state.initialOffsetX + state.deltaX
const offsetY = state.initialOffsetY + state.deltaY
pendingCommittedOffset.value = { annotationId: state.annotationId, x: offsetX, y: offsetY }
dragState = null
// 使用时间戳记录拖动结束,用于在 contextmenu 中检查
lastDragEndTimestamp = event.timeStamp
emit('move', state.annotationId, offsetX, offsetY)
} else {
dragState = null
}
if (state.target.hasPointerCapture(state.pointerId)) {
state.target.releasePointerCapture(state.pointerId)
}
// Don't modify dragOffsets for non-moved drags to preserve persisted offsets
emit('drag-end', false)
}
watch(
() => props.annotations,
(annotations) => {
const pending = pendingCommittedOffset.value
// 优化:仅在有待处理的提交偏移时才执行
if (!pending) return
const annotation = annotations.find((item) => item.annotation.id === pending.annotationId)
if (!annotation) return
const offsetX = Number.isFinite(annotation.annotation.labelOffsetX)
? annotation.annotation.labelOffsetX!
: 0
const offsetY = Number.isFinite(annotation.annotation.labelOffsetY)
? annotation.annotation.labelOffsetY!
: 0
if (offsetX === pending.x && offsetY === pending.y) {
dragOffsets.value = new Map(dragOffsets.value).set(pending.annotationId, { x: 0, y: 0 })
pendingCommittedOffset.value = null
}
},
)
function handlePointerCancel(event: PointerEvent) {
event.preventDefault()
event.stopPropagation()
if (!dragState || event.pointerId !== dragState.pointerId) return
const state = dragState
dragState = null
dragOffsets.value = new Map(dragOffsets.value).set(state.annotationId, { x: 0, y: 0 })
if (dragFrame !== null) {
cancelAnimationFrame(dragFrame)
dragFrame = null
}
emit('drag-end', true)
}
function handleContextMenu(annotationId: string, event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
// 使用时间戳比较:如果 contextmenu 在拖动结束后 100ms 内触发,则抑制
// 这比依赖事件顺序更可靠
if (event.timeStamp - lastDragEndTimestamp < 100) return
emit('contextmenu', annotationId, event)
}
@@ -31,6 +213,10 @@ function markerId(annotationId: string) {
class="waveform-annotation"
:data-annotation-id="rendered.annotation.id"
:data-placement="rendered.placement"
@pointerdown="handlePointerDown(rendered, $event)"
@pointermove="handlePointerMove"
@pointerup="finishPointerDrag"
@pointercancel="handlePointerCancel"
@contextmenu="handleContextMenu(rendered.annotation.id, $event)"
>
<defs>
@@ -48,8 +234,8 @@ function markerId(annotationId: string) {
</defs>
<line
class="waveform-annotation__arrow"
:x1="rendered.box.lineEndX"
:y1="rendered.box.lineEndY"
:x1="draggedBox(rendered).lineEndX"
:y1="draggedBox(rendered).lineEndY"
:x2="rendered.anchorX"
:y2="rendered.anchorY"
:stroke="rendered.style.borderColor"
@@ -57,19 +243,19 @@ function markerId(annotationId: string) {
/>
<rect
class="waveform-annotation__box"
:x="rendered.box.x"
:y="rendered.box.y"
:width="rendered.box.width"
:height="rendered.box.height"
:x="draggedBox(rendered).x"
:y="draggedBox(rendered).y"
:width="draggedBox(rendered).width"
:height="draggedBox(rendered).height"
:fill="rendered.style.backgroundColor"
:stroke="rendered.style.borderColor"
/>
<text
class="waveform-annotation__text"
:x="rendered.box.x + rendered.box.width / 2"
:x="draggedBox(rendered).x + draggedBox(rendered).width / 2"
:y="
rendered.box.y +
rendered.box.height / 2 -
draggedBox(rendered).y +
draggedBox(rendered).height / 2 -
((rendered.lines.length - 1) * ANNOTATION_TEXT_LINE_HEIGHT) / 2
"
:fill="rendered.style.textColor"
@@ -80,7 +266,7 @@ function markerId(annotationId: string) {
<tspan
v-for="(line, index) in rendered.lines"
:key="`${rendered.annotation.id}-${index}`"
:x="rendered.box.x + rendered.box.width / 2"
:x="draggedBox(rendered).x + draggedBox(rendered).width / 2"
:dy="index === 0 ? 0 : ANNOTATION_TEXT_LINE_HEIGHT"
>
{{ line }}
@@ -97,7 +283,12 @@ function markerId(annotationId: string) {
.waveform-annotation {
pointer-events: auto;
cursor: context-menu;
cursor: grab;
touch-action: none;
}
.waveform-annotation:active {
cursor: grabbing;
}
.waveform-annotation__arrow {

View File

@@ -7,6 +7,7 @@ import {
ANNOTATION_TEXT_PADDING,
ANNOTATION_TEXT_VERTICAL_PADDING,
findAnnotationSeriesCandidates,
findNearestPointByX,
findNearestAnnotationPoint,
interpolateAnnotationPoint,
layoutAnnotations,
@@ -55,6 +56,21 @@ describe('waveform annotation markup', () => {
).toBeNull()
})
it('snaps annotations to nearest sample points while using interpolation for distance calculation', () => {
const track = createTrack(0, 'series', 0, [
{ x: 0, y: 0 },
{ x: 2, y: 10 },
])
const candidates = findAnnotationSeriesCandidates([track], 0.75, 75, 62.5)
// Should snap to nearest actual sample point for the anchor
expect(candidates[0].point).toEqual({ x: 0, y: 0 })
expect(candidates[0].xValue).toBeUndefined()
// Distance is calculated using interpolated position for accurate series selection
expect(candidates[0].distance).toBe(0)
expect(findNearestPointByX(track.series.points, 1.25)).toEqual({ x: 2, y: 10 })
})
it('interpolates start, middle, and end step lines at their visual transitions', () => {
const points = [
{ x: 0, y: 2 },
@@ -102,10 +118,10 @@ describe('waveform annotation markup', () => {
name: '第一通道',
color: '#f00',
unit: 'V',
point: { x: 1, y: 5 },
point: { x: 2, y: 10 }, // Snaps to nearest sample point
distance: 0,
})
expect(candidates[1].point).toEqual({ x: 1, y: 6 })
expect(candidates[1].point).toEqual({ x: 2, y: 8 }) // Snaps to nearest sample point
})
it('uses equal horizontal and vertical annotation padding', () => {
@@ -149,7 +165,7 @@ describe('waveform annotation markup', () => {
})
})
it('filters invalid entries and separates nearby annotation boxes', () => {
it('filters invalid entries and keeps coincident labels on the default layout', () => {
const track = createTrack(
0,
'a',
@@ -173,7 +189,7 @@ describe('waveform annotation markup', () => {
expect(rendered[0].box.lineEndX).toBe(rendered[0].anchorX)
expect(rendered[0].box.lineEndY).not.toBe(rendered[0].anchorY)
expect(rendered[0].placement).toBe('top')
expect(rendered[0].box).not.toMatchObject({
expect(rendered[0].box).toMatchObject({
x: rendered[1].box.x,
y: rendered[1].box.y,
})
@@ -185,7 +201,7 @@ describe('waveform annotation markup', () => {
})
})
it('prefers centered vertical placements and moves below a top boundary', () => {
it('uses the default placement and clamps labels to the plot boundary', () => {
const track = createTrack(
0,
'a',
@@ -214,14 +230,14 @@ describe('waveform annotation markup', () => {
200,
200,
)[0]
// Smart placement chooses 'bottom' when near top boundary
expect(nearTop.placement).toBe('bottom')
expect(nearTop.box.lineEndX).toBe(nearTop.anchorX)
expect(nearTop.box.lineEndY).toBe(nearTop.box.y)
expect(nearTop.box.height).toBe(30)
expect(nearTop.box.lineEndY - nearTop.anchorY).toBe(32)
expect(nearTop.box.y).toBeGreaterThanOrEqual(0)
expect(nearTop.box.lineEndY).toBeLessThanOrEqual(nearTop.box.y + nearTop.box.height)
})
it('reverses direction when the preferred placement is clipped by a boundary', () => {
it('intelligently chooses placement to avoid boundaries', () => {
const track = createTrack(
0,
'a',
@@ -239,6 +255,7 @@ describe('waveform annotation markup', () => {
200,
200,
)[0]
// Smart placement chooses 'bottom' when top space is insufficient
expect(nearTop.placement).toBe('bottom')
expect(nearTop.box.y).toBeGreaterThanOrEqual(0)
expect(nearTop.box.y + nearTop.box.height).toBeLessThanOrEqual(200)
@@ -249,6 +266,7 @@ describe('waveform annotation markup', () => {
200,
200,
)[0]
// Smart placement chooses 'top' when bottom space is insufficient
expect(nearBottom.placement).toBe('top')
expect(nearBottom.box.y).toBeGreaterThanOrEqual(0)
expect(nearBottom.box.y + nearBottom.box.height).toBeLessThanOrEqual(200)
@@ -268,6 +286,47 @@ describe('waveform annotation markup', () => {
expect(rendered.box.y).toBeGreaterThanOrEqual(0)
})
it('applies a persisted label offset without moving the data anchor', () => {
const track = createTrack(
0,
'a',
0,
[
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
300,
)
const baseline = layoutAnnotations(
[{ id: 'baseline', seriesId: 'a', x: 1, y: 5, text: '偏移' }],
[track],
300,
300,
)[0]
const rendered = layoutAnnotations(
[
{
id: 'offset',
seriesId: 'a',
x: 1,
y: 5,
text: '偏移',
labelOffsetX: 24,
labelOffsetY: 18,
},
],
[track],
300,
300,
)[0]
expect(rendered.anchorX).toBe(100)
expect(rendered.anchorY).toBe(150)
expect(rendered.box.x).toBe(baseline.box.x + 24)
expect(rendered.box.y).toBe(baseline.box.y + 18)
expect(rendered.box.lineEndX).not.toBe(rendered.anchorX)
})
it('uses later directional candidates when vertical candidates collide', () => {
const track = createTrack(
0,
@@ -290,7 +349,7 @@ describe('waveform annotation markup', () => {
)
expect(rendered[0].placement).toBe('top')
expect(rendered[1].placement).not.toBe('top')
expect(rendered[1].box).not.toMatchObject({ x: rendered[0].box.x, y: rendered[0].box.y })
expect(rendered[1].placement).toBe('top')
expect(rendered[1].box).toMatchObject({ x: rendered[0].box.x, y: rendered[0].box.y })
})
})

View File

@@ -30,19 +30,19 @@ export const ANNOTATION_TEXT_GLYPH_HEIGHT = 14
export const ANNOTATION_TEXT_FONT = '12px Arial, sans-serif'
export const ANNOTATION_CONNECTOR_LENGTH = 32
const ANNOTATION_PLACEMENTS: AnnotationPlacement[] = [
'top',
'bottom',
'right',
'left',
'top-right',
'top-left',
'bottom-right',
'bottom-left',
]
const pointBisector = bisector((point: { x: number }) => point.x)
export function findNearestPointByX(
points: Array<{ x: number; y: number }>,
xValue: number,
): { x: number; y: number } | null {
if (!points.length || !Number.isFinite(xValue)) return null
const centerIndex = pointBisector.center(points, xValue)
const center = points[Math.min(centerIndex, points.length - 1)]
const left = points[Math.max(0, centerIndex - 1)]
return Math.abs(xValue - left.x) < Math.abs(center.x - xValue) ? left : center
}
export function interpolateAnnotationPoint(
points: Array<{ x: number; y: number }>,
xValue: number,
@@ -81,10 +81,26 @@ export function findAnnotationSeriesCandidates(
): AnnotationSeriesCandidate[] {
return tracks
.flatMap((track): AnnotationSeriesCandidate[] => {
const point = interpolateAnnotationPoint(track.series.points, xValue, track.series.lineType)
if (!point) return []
const screenX = track.xScale(point.x)
const screenY = track.top + track.yScale(point.y)
const interpolatedPoint = interpolateAnnotationPoint(
track.series.points,
xValue,
track.series.lineType,
)
if (!interpolatedPoint) return []
// Always snap to nearest actual sample point for the anchor
// This ensures annotations align with visible data points
const nearestPoint = findNearestPointByX(track.series.points, xValue)
if (!nearestPoint) return []
// Use interpolated point for distance calculation to get accurate series selection
const interpolatedScreenX = track.xScale(interpolatedPoint.x)
const interpolatedScreenY = track.top + track.yScale(interpolatedPoint.y)
// But use nearest point as the actual anchor
const screenX = track.xScale(nearestPoint.x)
const screenY = track.top + track.yScale(nearestPoint.y)
return [
{
trackIndex: track.index,
@@ -92,11 +108,10 @@ export function findAnnotationSeriesCandidates(
name: track.series.name?.trim() || track.series.id,
color: track.series.color || DEFAULT_ANNOTATION_STYLE.borderColor,
unit: track.series.unit,
point,
point: nearestPoint,
screenX,
screenY,
distance: Math.hypot(screenX - pointerX, screenY - pointerY),
xValue,
distance: Math.hypot(interpolatedScreenX - pointerX, interpolatedScreenY - pointerY),
},
]
})
@@ -203,19 +218,6 @@ function isWithin(value: number, domain: [number, number]): boolean {
return value >= Math.min(domain[0], domain[1]) && value <= Math.max(domain[0], domain[1])
}
function overlaps(first: AnnotationBoxLayout, second: AnnotationBoxLayout): boolean {
return (
first.x < second.x + second.width &&
first.x + first.width > second.x &&
first.y < second.y + second.height &&
first.y + first.height > second.y
)
}
function containsPoint(box: AnnotationBoxLayout, x: number, y: number): boolean {
return x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height
}
function clampBox(box: AnnotationBoxLayout, width: number, height: number): AnnotationBoxLayout {
const x = Math.max(0, Math.min(box.x, Math.max(0, width - box.width)))
const y = Math.max(0, Math.min(box.y, Math.max(0, height - box.height)))
@@ -228,6 +230,54 @@ function clampBox(box: AnnotationBoxLayout, width: number, height: number): Anno
}
}
function isPlacementWithinBounds(
anchorX: number,
anchorY: number,
width: number,
height: number,
placement: AnnotationPlacement,
plotWidth: number,
plotHeight: number,
): boolean {
const position = boxPosition(anchorX, anchorY, width, height, placement)
return (
position.x >= 0 &&
position.y >= 0 &&
position.x + width <= plotWidth &&
position.y + height <= plotHeight
)
}
function chooseBestPlacement(
anchorX: number,
anchorY: number,
width: number,
height: number,
plotWidth: number,
plotHeight: number,
): AnnotationPlacement {
const placements: AnnotationPlacement[] = [
'top',
'bottom',
'right',
'left',
'top-right',
'top-left',
'bottom-right',
'bottom-left',
]
// Try to find a placement that fits completely within bounds
for (const placement of placements) {
if (isPlacementWithinBounds(anchorX, anchorY, width, height, placement, plotWidth, plotHeight)) {
return placement
}
}
// Fallback to 'top' if no placement fits perfectly (will be clamped)
return 'top'
}
function resolveConnectorStart(
box: AnnotationBoxLayout,
anchorX: number,
@@ -318,24 +368,6 @@ function annotationBoxSize(
}
}
function isPlacementWithinBounds(
anchorX: number,
anchorY: number,
lines: string[],
plotWidth: number,
plotHeight: number,
placement: AnnotationPlacement,
): boolean {
const { width, height } = annotationBoxSize(lines, plotWidth, plotHeight)
const position = boxPosition(anchorX, anchorY, width, height, placement)
return (
position.x >= 0 &&
position.y >= 0 &&
position.x + width <= plotWidth &&
position.y + height <= plotHeight
)
}
export function layoutAnnotationBox(
anchorX: number,
anchorY: number,
@@ -343,11 +375,20 @@ export function layoutAnnotationBox(
plotWidth: number,
plotHeight: number,
placement: AnnotationPlacement,
offsetX = 0,
offsetY = 0,
): AnnotationBoxLayout {
const { width, height } = annotationBoxSize(lines, plotWidth, plotHeight)
const position = boxPosition(anchorX, anchorY, width, height, placement)
const clamped = clampBox(
{ x: position.x, y: position.y, width, height, lineEndX: anchorX, lineEndY: anchorY },
{
x: position.x + offsetX,
y: position.y + offsetY,
width,
height,
lineEndX: anchorX,
lineEndY: anchorY,
},
plotWidth,
plotHeight,
)
@@ -361,7 +402,6 @@ export function layoutAnnotations(
plotWidth: number,
plotHeight: number,
): RenderedAnnotation[] {
const placedByTrack = new Map<number, AnnotationBoxLayout[]>()
const rendered: RenderedAnnotation[] = []
annotations.forEach((annotation) => {
@@ -380,46 +420,27 @@ export function layoutAnnotations(
const anchorY = track.yScale(annotation.y) + track.top
const localHeight = Math.min(track.height, Math.max(0, plotHeight - track.top))
const localAnchorY = anchorY - track.top
const placed = placedByTrack.get(track.index) || []
let placement = ANNOTATION_PLACEMENTS[0]
let box = layoutAnnotationBox(
// Choose best placement only if no manual offset exists
const hasManualOffset =
Number.isFinite(annotation.labelOffsetX) && annotation.labelOffsetX !== 0 ||
Number.isFinite(annotation.labelOffsetY) && annotation.labelOffsetY !== 0
const { width, height } = annotationBoxSize(lines, trackWidth, localHeight)
const placement: AnnotationPlacement = hasManualOffset
? 'top'
: chooseBestPlacement(localAnchorX, localAnchorY, width, height, trackWidth, localHeight)
const box = layoutAnnotationBox(
localAnchorX,
localAnchorY,
lines,
trackWidth,
localHeight,
placement,
Number.isFinite(annotation.labelOffsetX) ? annotation.labelOffsetX : 0,
Number.isFinite(annotation.labelOffsetY) ? annotation.labelOffsetY : 0,
)
for (const candidatePlacement of ANNOTATION_PLACEMENTS) {
if (
!isPlacementWithinBounds(
localAnchorX,
localAnchorY,
lines,
trackWidth,
localHeight,
candidatePlacement,
)
) {
continue
}
const candidate = layoutAnnotationBox(
localAnchorX,
localAnchorY,
lines,
trackWidth,
localHeight,
candidatePlacement,
)
if (
!containsPoint(candidate, localAnchorX, localAnchorY) &&
!placed.some((item) => overlaps(candidate, item))
) {
box = candidate
placement = candidatePlacement
break
}
}
const globalBox = {
...box,
@@ -428,8 +449,6 @@ export function layoutAnnotations(
lineEndX: box.lineEndX + trackLeft,
lineEndY: box.lineEndY + track.top,
}
placed.push(box)
placedByTrack.set(track.index, placed)
rendered.push({
annotation,
trackIndex: track.index,