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

@@ -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 })
})
})