Files
waveform-analysis/src/components/annotation/markup.test.ts

269 lines
7.5 KiB
TypeScript
Raw Normal View History

2026-07-20 10:21:30 +08:00
import { scaleLinear } from 'd3'
import { describe, expect, it } from 'vitest'
import type { WaveformAnnotation } from '../../types'
import {
ANNOTATION_TEXT_HORIZONTAL_PADDING,
ANNOTATION_TEXT_PADDING,
ANNOTATION_TEXT_VERTICAL_PADDING,
findAnnotationSeriesCandidates,
findNearestAnnotationPoint,
interpolateAnnotationPoint,
layoutAnnotations,
resolveAnnotationStyle,
wrapAnnotationText,
} from './markup'
import type { AnnotationTrackLayout } from './types'
const createTrack = (
index: number,
id: string,
top: number,
points: Array<{ x: number; y: number }>,
height = 100,
): AnnotationTrackLayout => ({
index,
series: { id, points },
top,
height,
xScale: scaleLinear([0, 2], [0, 200]),
yScale: scaleLinear([0, 10], [height, 0]),
})
describe('waveform annotation markup', () => {
it('interpolates a line value at the pointer X', () => {
expect(
interpolateAnnotationPoint(
[
{ x: 0, y: 0 },
{ x: 2, y: 10 },
],
1,
),
).toEqual({
2026-07-20 10:21:30 +08:00
x: 1,
y: 5,
})
expect(
interpolateAnnotationPoint(
[
{ x: 0, y: 0 },
{ x: 2, y: 10 },
],
3,
),
).toBeNull()
2026-07-20 10:21:30 +08:00
})
it('sorts line candidates by screen distance and keeps series metadata', () => {
const first = createTrack(0, 'first', 0, [
{ x: 0, y: 0 },
{ x: 2, y: 10 },
])
first.series.name = '第一通道'
first.series.color = '#f00'
first.series.unit = 'V'
const second = createTrack(1, 'second', 0, [
{ x: 0, y: 4 },
{ x: 2, y: 8 },
])
const candidates = findAnnotationSeriesCandidates([first, second], 1, 100, 50)
expect(candidates[0]).toMatchObject({
seriesId: 'first',
name: '第一通道',
color: '#f00',
unit: 'V',
point: { x: 1, y: 5 },
distance: 0,
})
expect(candidates[1].point).toEqual({ x: 1, y: 6 })
})
it('uses equal horizontal and vertical annotation padding', () => {
expect(ANNOTATION_TEXT_HORIZONTAL_PADDING).toBe(ANNOTATION_TEXT_PADDING)
expect(ANNOTATION_TEXT_VERTICAL_PADDING).toBe(ANNOTATION_TEXT_PADDING)
})
it('finds the closest visible sample across candidate tracks', () => {
const hit = findNearestAnnotationPoint(
[
createTrack(0, 'a', 0, [
{ x: 0, y: 1 },
{ x: 1, y: 2 },
]),
createTrack(1, 'b', 100, [
{ x: 0, y: 1 },
{ x: 1, y: 8 },
]),
],
100,
120,
12,
)
expect(hit).toMatchObject({ seriesId: 'b', trackIndex: 1 })
expect(hit?.point).toEqual({ x: 1, y: 8 })
})
it('returns no hit outside the configured screen radius', () => {
const hit = findNearestAnnotationPoint([createTrack(0, 'a', 0, [{ x: 1, y: 2 }])], 160, 10, 12)
expect(hit).toBeNull()
})
it('wraps labels by Unicode characters and resolves style defaults', () => {
expect(wrapAnnotationText('1234567890中文')).toEqual(['1234567890', '中文'])
expect(resolveAnnotationStyle({ textColor: '#ffffff' })).toEqual({
borderColor: '#1677ff',
textColor: '#ffffff',
backgroundColor: 'rgba(255, 255, 255, 0.92)',
})
})
it('filters invalid entries and separates nearby annotation boxes', () => {
const track = createTrack(
0,
'a',
0,
[
{ x: 0, y: 1 },
{ x: 1, y: 2 },
],
300,
)
2026-07-20 10:21:30 +08:00
const annotations: WaveformAnnotation[] = [
{ id: 'first', seriesId: 'a', x: 1, y: 2, text: '第一个标注' },
{ id: 'second', seriesId: 'a', x: 1, y: 2, text: '第二个标注' },
{ id: 'unknown', seriesId: 'missing', x: 1, y: 2, text: '不显示' },
{ id: 'invalid', seriesId: 'a', x: Number.NaN, y: 2, text: '不显示' },
]
const rendered = layoutAnnotations(annotations, [track], 300, 300)
expect(rendered.map((item) => item.annotation.id)).toEqual(['first', 'second'])
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({
x: rendered[1].box.x,
y: rendered[1].box.y,
})
rendered.forEach((item) => {
expect(item.box.x).toBeGreaterThanOrEqual(0)
expect(item.box.y).toBeGreaterThanOrEqual(0)
expect(item.box.x + item.box.width).toBeLessThanOrEqual(300)
expect(item.box.y + item.box.height).toBeLessThanOrEqual(300)
})
})
it('prefers centered vertical placements and moves below a top boundary', () => {
const track = createTrack(
0,
'a',
0,
[
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
200,
)
2026-07-20 10:21:30 +08:00
const centered = layoutAnnotations(
[{ id: 'centered', seriesId: 'a', x: 1, y: 5, text: '居中' }],
[track],
200,
200,
)[0]
expect(centered.placement).toBe('top')
expect(centered.box.x + centered.box.width / 2).toBe(centered.anchorX)
expect(centered.box.lineEndX).toBe(centered.anchorX)
expect(centered.box.lineEndY).toBe(centered.box.y + centered.box.height)
const nearTop = layoutAnnotations(
[{ id: 'top-edge', seriesId: 'a', x: 1, y: 9.5, text: '顶部' }],
[track],
200,
200,
)[0]
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)
})
it('reverses direction when the preferred placement is clipped by a boundary', () => {
const track = createTrack(
0,
'a',
0,
[
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
200,
)
2026-07-20 10:21:30 +08:00
const nearTop = layoutAnnotations(
[{ id: 'top-space', seriesId: 'a', x: 1, y: 7.5, text: '顶部空间不足' }],
[track],
200,
200,
)[0]
expect(nearTop.placement).toBe('bottom')
expect(nearTop.box.y).toBeGreaterThanOrEqual(0)
expect(nearTop.box.y + nearTop.box.height).toBeLessThanOrEqual(200)
const nearBottom = layoutAnnotations(
[{ id: 'bottom-space', seriesId: 'a', x: 1, y: 0.5, text: '底部空间不足' }],
[track],
200,
200,
)[0]
expect(nearBottom.placement).toBe('top')
expect(nearBottom.box.y).toBeGreaterThanOrEqual(0)
expect(nearBottom.box.y + nearBottom.box.height).toBeLessThanOrEqual(200)
})
it('keeps a clipped fallback when the plot is smaller than the annotation box', () => {
const track = createTrack(0, 'a', 0, [{ x: 1, y: 5 }])
const rendered = layoutAnnotations(
[{ id: 'tiny-plot', seriesId: 'a', x: 1, y: 5, text: '无法完整放置' }],
[track],
40,
20,
)[0]
expect(rendered).toBeDefined()
expect(rendered.box.x).toBeGreaterThanOrEqual(0)
expect(rendered.box.y).toBeGreaterThanOrEqual(0)
})
it('uses later directional candidates when vertical candidates collide', () => {
const track = createTrack(
0,
'a',
0,
[
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
200,
)
2026-07-20 10:21:30 +08:00
const rendered = layoutAnnotations(
[
{ id: 'one', seriesId: 'a', x: 1, y: 5, text: '同一位置一' },
{ id: 'two', seriesId: 'a', x: 1, y: 5, text: '同一位置二' },
],
[track],
200,
200,
)
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 })
})
})