feat(annotation): add draggable label positioning
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user