feat(annotation): add draggable label positioning
This commit is contained in:
@@ -659,8 +659,8 @@ describe('WaveformChart', () => {
|
||||
expect(tracks[0].find('.waveform-chart__y-axis-label').exists()).toBe(false)
|
||||
expect(tracks[0].findAll('.waveform-chart__axis--y .tick').length).toBeGreaterThan(0)
|
||||
expect(tracks[1].get('.waveform-chart__y-axis-label').text()).toBe('BT1_2M')
|
||||
expect(tracks[1].find('.waveform-chart__legend').exists()).toBe(false)
|
||||
const legend = tracks[0].get('.waveform-chart__legend')
|
||||
expect(wrapper.findAll('.waveform-chart__legend')).toHaveLength(1)
|
||||
const legend = wrapper.get('.waveform-chart__legend')
|
||||
expect(legend.attributes('data-position')).toBe('top-right')
|
||||
expect(legend.attributes('data-orientation')).toBe('vertical')
|
||||
expect(legend.get('.waveform-legend__panel').attributes('style')).toContain(
|
||||
@@ -841,6 +841,11 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
|
||||
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
|
||||
const annotationLayer = wrapper.get('.waveform-annotation-layer').element
|
||||
const legendLayer = wrapper.get('.waveform-chart__legend-layer').element
|
||||
expect(
|
||||
annotationLayer.compareDocumentPosition(legendLayer) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy()
|
||||
const highLegendItem = wrapper.findAll('.waveform-chart__legend-item')[1]
|
||||
expect(highLegendItem.attributes('aria-pressed')).toBe('true')
|
||||
|
||||
@@ -2815,17 +2820,17 @@ describe('WaveformChart', () => {
|
||||
await textarea.setValue('右键标注')
|
||||
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click')
|
||||
|
||||
// Annotation snaps to nearest sample point (x=1, y=5)
|
||||
expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([
|
||||
{ seriesId: 'series-0', x: 0.5, y: 2.5 },
|
||||
{ seriesId: 'series-0', x: 1, y: 5 },
|
||||
])
|
||||
await wrapper.setProps({
|
||||
annotations: [{ id: 'right-click', seriesId: 'series-0', x: 0.5, y: 2.5, text: '右键标注' }],
|
||||
annotations: [{ id: 'right-click', seriesId: 'series-0', x: 1, y: 5, text: '右键标注' }],
|
||||
})
|
||||
expect(wrapper.find('.waveform-annotation__vertical-line').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-annotation__anchor').exists()).toBe(false)
|
||||
expect(wrapper.get('.waveform-annotation').attributes('data-placement')).toBe('top')
|
||||
expect(wrapper.get('.waveform-annotation__arrow').attributes('x1')).toBe(
|
||||
wrapper.get('.waveform-annotation__arrow').attributes('x2'),
|
||||
expect(wrapper.get('.waveform-annotation__arrow').attributes('x2')).not.toBe(
|
||||
String(overlayWidth / 2),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2890,7 +2895,7 @@ describe('WaveformChart', () => {
|
||||
).toEqual(['通道 B'])
|
||||
})
|
||||
|
||||
it('moves an annotation below the point when the top boundary is too close', async () => {
|
||||
it('intelligently chooses placement to avoid clipping at boundaries', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
@@ -2902,6 +2907,7 @@ describe('WaveformChart', () => {
|
||||
{ annotations: [{ id: 'top-edge', seriesId: 'series-0', x: 0.5, y: 5, text: '顶部标注' }] },
|
||||
)
|
||||
|
||||
// Smart placement chooses 'bottom' when annotation is near top boundary
|
||||
expect(wrapper.get('.waveform-annotation').attributes('data-placement')).toBe('bottom')
|
||||
expect(wrapper.get('.waveform-annotation__arrow').attributes('x1')).toBe(
|
||||
wrapper.get('.waveform-annotation__arrow').attributes('x2'),
|
||||
@@ -2951,6 +2957,129 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.emitted('annotation-delete')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('commits a dragged label offset once without changing its data anchor', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ annotations: [{ id: 'dragged', seriesId: 'series-0', x: 1, y: 5, text: '拖动' }] },
|
||||
)
|
||||
const annotation = wrapper.get('[data-annotation-id="dragged"]')
|
||||
const element = annotation.element as SVGElement & {
|
||||
setPointerCapture: (pointerId: number) => void
|
||||
releasePointerCapture: (pointerId: number) => void
|
||||
hasPointerCapture: (pointerId: number) => boolean
|
||||
}
|
||||
element.setPointerCapture = () => undefined
|
||||
element.releasePointerCapture = () => undefined
|
||||
element.hasPointerCapture = () => false
|
||||
|
||||
const dispatchPointer = (type: string, values: Record<string, number>) => {
|
||||
const event = new Event(type, { bubbles: true })
|
||||
Object.defineProperties(event, {
|
||||
button: { value: values.button ?? 0 },
|
||||
clientX: { value: values.clientX ?? 0 },
|
||||
clientY: { value: values.clientY ?? 0 },
|
||||
pointerId: { value: values.pointerId ?? 1 },
|
||||
})
|
||||
element.dispatchEvent(event)
|
||||
}
|
||||
|
||||
dispatchPointer('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
|
||||
expect(wrapper.emitted('update:annotations')).toBeUndefined()
|
||||
dispatchPointer('pointermove', { clientX: 130, clientY: 120, pointerId: 1 })
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:annotations')).toBeUndefined()
|
||||
dispatchPointer('pointermove', { clientX: 140, clientY: 130, pointerId: 1 })
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('update:annotations')).toBeUndefined()
|
||||
const boxBeforeUp = {
|
||||
x: wrapper.get('.waveform-annotation__box').attributes('x'),
|
||||
y: wrapper.get('.waveform-annotation__box').attributes('y'),
|
||||
}
|
||||
dispatchPointer('pointerup', { clientX: 140, clientY: 130, pointerId: 1 })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-annotation__box').attributes('x')).toBe(boxBeforeUp.x)
|
||||
expect(wrapper.get('.waveform-annotation__box').attributes('y')).toBe(boxBeforeUp.y)
|
||||
|
||||
const updated = wrapper.emitted('update:annotations')?.at(-1)?.[0] as
|
||||
Array<{ x: number; y: number; labelOffsetX?: number; labelOffsetY?: number }> | undefined
|
||||
expect(updated).toMatchObject([{ x: 1, y: 5, labelOffsetX: 40, labelOffsetY: 30 }])
|
||||
expect(wrapper.emitted('update:annotations')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('hides the tooltip through a label drag until the next real hover move', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 5 },
|
||||
],
|
||||
},
|
||||
{ annotations: [{ id: 'dragged', seriesId: 'series-0', x: 1, y: 5, text: '拖动' }] },
|
||||
)
|
||||
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
|
||||
})
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
|
||||
const annotation = wrapper.get('[data-annotation-id="dragged"]')
|
||||
const element = annotation.element as SVGElement & {
|
||||
setPointerCapture: (pointerId: number) => void
|
||||
releasePointerCapture: (pointerId: number) => void
|
||||
hasPointerCapture: (pointerId: number) => boolean
|
||||
}
|
||||
element.setPointerCapture = () => undefined
|
||||
element.releasePointerCapture = () => undefined
|
||||
element.hasPointerCapture = () => false
|
||||
const dispatchPointer = (type: string, values: Record<string, number>) => {
|
||||
const event = new Event(type, { bubbles: true })
|
||||
Object.defineProperties(event, {
|
||||
button: { value: values.button ?? 0 },
|
||||
clientX: { value: values.clientX ?? 0 },
|
||||
clientY: { value: values.clientY ?? 0 },
|
||||
pointerId: { value: values.pointerId ?? 1 },
|
||||
})
|
||||
element.dispatchEvent(event)
|
||||
}
|
||||
|
||||
dispatchPointer('pointerdown', { button: 0, clientX: 100, clientY: 100, pointerId: 1 })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
|
||||
dispatchPointer('pointermove', { clientX: 130, clientY: 120, pointerId: 1 })
|
||||
dispatchPointer('pointerup', { clientX: 130, clientY: 120, pointerId: 1 })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth / 2, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
|
||||
|
||||
overlay.element.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('controls visibility and interaction mode while filtering unknown series', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,18 +3,13 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { axisBottom, axisLeft, axisRight, select } from 'd3'
|
||||
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
|
||||
import type { WaveformFrameStyle } from '../../types'
|
||||
import type {
|
||||
WaveformDisplayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformLegendPosition,
|
||||
} from '../data/types'
|
||||
import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types'
|
||||
import type {
|
||||
DisplaySeries,
|
||||
HoveredSeriesPoint,
|
||||
TrackLayout,
|
||||
WaveformYAxisLayout,
|
||||
} from '../core/types'
|
||||
import WaveformLegend from './WaveformLegend.vue'
|
||||
import WaveformSeriesLayer from './WaveformSeriesLayer.vue'
|
||||
|
||||
interface Props {
|
||||
@@ -42,16 +37,6 @@ interface Props {
|
||||
hoveredPoint?: HoveredSeriesPoint
|
||||
/** Y 轴标签回退值 */
|
||||
yLabel?: string
|
||||
/** 多曲线图例位置 */
|
||||
legendPosition?: WaveformLegendPosition
|
||||
/** 多曲线图例排列方向 */
|
||||
legendOrientation?: 'horizontal' | 'vertical'
|
||||
/** 多曲线图例背景颜色 */
|
||||
legendBackgroundColor?: string
|
||||
/** 图例是否允许切换曲线显隐 */
|
||||
legendInteractive?: boolean
|
||||
/** 当前隐藏的系列 ID */
|
||||
hiddenSeriesIds?: string[]
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -59,16 +44,10 @@ interface Emits {
|
||||
(e: 'pointer-leave'): void
|
||||
(e: 'click', event: MouseEvent): void
|
||||
(e: 'contextmenu', event: MouseEvent): void
|
||||
(e: 'series-visibility-toggle', seriesId: string): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
interactionMode: 'zoom',
|
||||
legendPosition: 'top-right',
|
||||
legendOrientation: 'vertical',
|
||||
legendBackgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
legendInteractive: false,
|
||||
hiddenSeriesIds: () => [],
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
@@ -446,18 +425,6 @@ watch(
|
||||
暂无可见曲线
|
||||
</text>
|
||||
|
||||
<WaveformLegend
|
||||
v-if="!track.isEmpty && track.legendSeries.length > 1"
|
||||
:series="track.legendSeries"
|
||||
:position="legendPosition"
|
||||
:orientation="legendOrientation"
|
||||
:background-color="legendBackgroundColor"
|
||||
:interactive="legendInteractive"
|
||||
:hidden-series-ids="hiddenSeriesIds"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
@toggle="emit('series-visibility-toggle', $event)"
|
||||
/>
|
||||
</g>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user