Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dcc27a88be | ||
|
|
faa9695bc0 | ||
|
|
f102e9c661 |
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waveform-analysis",
|
"name": "waveform-analysis",
|
||||||
"version": "0.1.18",
|
"version": "0.1.21",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/types/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
@@ -34,11 +34,14 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"d3": "^7.9.0",
|
||||||
|
"vue3-colorpicker": "^2.3.0",
|
||||||
|
"ant-design-vue": "4.2.6"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"ant-design-vue": ">=3.2.20 <4",
|
"ant-design-vue": ">=3.2.20 <4",
|
||||||
"d3": ">=7.9.0 <8",
|
"vue": ">=3.2.33 <4"
|
||||||
"vue": ">=3.2.33 <4",
|
|
||||||
"vue3-colorpicker": ">=2.3.0 <3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
@@ -47,8 +50,6 @@
|
|||||||
"@vitejs/plugin-vue": "6.0.8",
|
"@vitejs/plugin-vue": "6.0.8",
|
||||||
"@vitest/coverage-v8": "4.1.10",
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"@vue/test-utils": "2.4.11",
|
"@vue/test-utils": "2.4.11",
|
||||||
"ant-design-vue": "4.2.6",
|
|
||||||
"d3": "7.9.0",
|
|
||||||
"eslint": "10.7.0",
|
"eslint": "10.7.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-vue": "10.9.2",
|
"eslint-plugin-vue": "10.9.2",
|
||||||
@@ -60,7 +61,6 @@
|
|||||||
"vite": "8.1.5",
|
"vite": "8.1.5",
|
||||||
"vitest": "4.1.10",
|
"vitest": "4.1.10",
|
||||||
"vue": "3.5.40",
|
"vue": "3.5.40",
|
||||||
"vue-tsc": "3.3.7",
|
"vue-tsc": "3.3.7"
|
||||||
"vue3-colorpicker": "2.3.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -7,6 +7,16 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
|
dependencies:
|
||||||
|
ant-design-vue:
|
||||||
|
specifier: 4.2.6
|
||||||
|
version: 4.2.6(vue@3.5.40(typescript@6.0.3))
|
||||||
|
d3:
|
||||||
|
specifier: ^7.9.0
|
||||||
|
version: 7.9.0
|
||||||
|
vue3-colorpicker:
|
||||||
|
specifier: ^2.3.0
|
||||||
|
version: 2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@10.11.1(vue@3.5.40(typescript@6.0.3)))(gradient-parser@1.2.0)(lodash-es@4.18.1)(tinycolor2@1.6.0)(vue-types@4.2.1(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3))
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: 10.0.1
|
specifier: 10.0.1
|
||||||
@@ -26,12 +36,6 @@ importers:
|
|||||||
'@vue/test-utils':
|
'@vue/test-utils':
|
||||||
specifier: 2.4.11
|
specifier: 2.4.11
|
||||||
version: 2.4.11(@vue/compiler-dom@3.5.40)(@vue/server-renderer@3.5.40)(vue@3.5.40(typescript@6.0.3))
|
version: 2.4.11(@vue/compiler-dom@3.5.40)(@vue/server-renderer@3.5.40)(vue@3.5.40(typescript@6.0.3))
|
||||||
ant-design-vue:
|
|
||||||
specifier: 4.2.6
|
|
||||||
version: 4.2.6(vue@3.5.40(typescript@6.0.3))
|
|
||||||
d3:
|
|
||||||
specifier: 7.9.0
|
|
||||||
version: 7.9.0
|
|
||||||
eslint:
|
eslint:
|
||||||
specifier: 10.7.0
|
specifier: 10.7.0
|
||||||
version: 10.7.0
|
version: 10.7.0
|
||||||
@@ -68,9 +72,6 @@ importers:
|
|||||||
vue-tsc:
|
vue-tsc:
|
||||||
specifier: 3.3.7
|
specifier: 3.3.7
|
||||||
version: 3.3.7(typescript@6.0.3)
|
version: 3.3.7(typescript@6.0.3)
|
||||||
vue3-colorpicker:
|
|
||||||
specifier: 2.3.0
|
|
||||||
version: 2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@10.11.1(vue@3.5.40(typescript@6.0.3)))(gradient-parser@1.2.0)(lodash-es@4.18.1)(tinycolor2@1.6.0)(vue-types@4.2.1(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3))
|
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
|
|||||||
@@ -2332,6 +2332,44 @@ describe('WaveformChart', () => {
|
|||||||
expect(pendingAnimationFrameCount()).toBe(0)
|
expect(pendingAnimationFrameCount()).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('isolates hover rendering from the chart, track, and waveform path subtrees', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 5 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ grid: { rowCount: 1, columnCount: 1 } },
|
||||||
|
)
|
||||||
|
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 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const track = wrapper.getComponent({ name: 'WaveformTrack' })
|
||||||
|
const seriesLayer = wrapper.getComponent({ name: 'WaveformSeriesLayer' })
|
||||||
|
const chartUpdate = vi.spyOn(wrapper.vm.$, 'update')
|
||||||
|
const trackUpdate = vi.spyOn(track.vm.$, 'update')
|
||||||
|
const seriesLayerUpdate = vi.spyOn(seriesLayer.vm.$, 'update')
|
||||||
|
const pathBeforeHover = wrapper.get('.waveform-chart__line').element
|
||||||
|
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
|
||||||
|
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
|
||||||
|
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
|
||||||
|
expect(chartUpdate).not.toHaveBeenCalled()
|
||||||
|
expect(trackUpdate).not.toHaveBeenCalled()
|
||||||
|
expect(seriesLayerUpdate).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders reference grid styling and an optional frame watermark', async () => {
|
it('renders reference grid styling and an optional frame watermark', async () => {
|
||||||
const wrapper = await mountSizedChart(
|
const wrapper = await mountSizedChart(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
onBeforeUnmount,
|
onBeforeUnmount,
|
||||||
onMounted,
|
onMounted,
|
||||||
ref,
|
ref,
|
||||||
|
shallowReactive,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
watch,
|
watch,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
@@ -58,8 +59,8 @@ import {
|
|||||||
type AnnotationSeriesInfo,
|
type AnnotationSeriesInfo,
|
||||||
type AnnotationTrackLayout,
|
type AnnotationTrackLayout,
|
||||||
} from './annotation'
|
} from './annotation'
|
||||||
import { WaveformTooltip } from './interaction'
|
import { WaveformHoverHost } from './interaction'
|
||||||
import { WaveformLegend, WaveformTrack } from './rendering'
|
import { WaveformHoverLayer, WaveformLegend, WaveformTrack } from './rendering'
|
||||||
import {
|
import {
|
||||||
channelColors,
|
channelColors,
|
||||||
margin as chartMargin,
|
margin as chartMargin,
|
||||||
@@ -88,7 +89,13 @@ import {
|
|||||||
X_AXIS_BAND,
|
X_AXIS_BAND,
|
||||||
type WaveformGridOptions,
|
type WaveformGridOptions,
|
||||||
} from './core/grid'
|
} from './core/grid'
|
||||||
import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types'
|
import type {
|
||||||
|
DisplaySeries,
|
||||||
|
DisplayTrack,
|
||||||
|
HoveredSeriesPoint,
|
||||||
|
TrackLayout,
|
||||||
|
WaveformHoverState,
|
||||||
|
} from './core/types'
|
||||||
import {
|
import {
|
||||||
buildTrackLayouts,
|
buildTrackLayouts,
|
||||||
findClosestTrackAtPointer,
|
findClosestTrackAtPointer,
|
||||||
@@ -192,9 +199,11 @@ const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
|
|||||||
const independentTransforms = shallowRef<ZoomTransform[]>([])
|
const independentTransforms = shallowRef<ZoomTransform[]>([])
|
||||||
const sharedYDomains = ref<Record<string, [number, number]>>({})
|
const sharedYDomains = ref<Record<string, [number, number]>>({})
|
||||||
const independentYDomains = ref<Record<number, [number, number]>>({})
|
const independentYDomains = ref<Record<number, [number, number]>>({})
|
||||||
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
|
const hoverState = shallowReactive<WaveformHoverState>({
|
||||||
const hoveredTrackIndex = ref<number | null>(null)
|
points: [],
|
||||||
const hoverPosition = ref({ x: 0, y: 0 })
|
trackIndex: null,
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
})
|
||||||
const suppressHoverUntilMove = ref(false)
|
const suppressHoverUntilMove = ref(false)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const resizeObserver = shallowRef<ResizeObserver>()
|
const resizeObserver = shallowRef<ResizeObserver>()
|
||||||
@@ -269,15 +278,6 @@ function handleInteractionKeyUp(event: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 用于传递给 WaveformTooltip 的接口
|
|
||||||
interface TooltipSeriesPoint {
|
|
||||||
trackIndex: number
|
|
||||||
name: string
|
|
||||||
color: string
|
|
||||||
unit?: string
|
|
||||||
point: WaveformPoint
|
|
||||||
}
|
|
||||||
|
|
||||||
const fixedWidth = computed(() =>
|
const fixedWidth = computed(() =>
|
||||||
Number.isFinite(props.width) ? Math.max(0, props.width ?? 0) : undefined,
|
Number.isFinite(props.width) ? Math.max(0, props.width ?? 0) : undefined,
|
||||||
)
|
)
|
||||||
@@ -569,7 +569,6 @@ const yAxisLayout = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
const hasWaveformData = computed(() => chartSeries.value.length > 0)
|
const hasWaveformData = computed(() => chartSeries.value.length > 0)
|
||||||
const hoveredPoint = computed(() => hoveredSeriesPoints.value[0]?.point ?? null)
|
|
||||||
const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0)
|
const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0)
|
||||||
const resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit})`)
|
const resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit})`)
|
||||||
const activeInteractionMode = computed(() => props.interactionMode)
|
const activeInteractionMode = computed(() => props.interactionMode)
|
||||||
@@ -578,17 +577,6 @@ const isZoomMode = computed(
|
|||||||
() => activeInteractionMode.value === 'zoom' || activeInteractionMode.value === undefined,
|
() => activeInteractionMode.value === 'zoom' || activeInteractionMode.value === undefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
// 转换为 Tooltip 组件需要的格式
|
|
||||||
const tooltipSeriesPoints = computed<TooltipSeriesPoint[]>(() => {
|
|
||||||
return hoveredSeriesPoints.value.map((item) => ({
|
|
||||||
trackIndex: item.trackIndex,
|
|
||||||
name: item.name,
|
|
||||||
color: item.color,
|
|
||||||
unit: item.unit,
|
|
||||||
point: item.point,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const sharedXDomain = computed(() => {
|
const sharedXDomain = computed(() => {
|
||||||
const values: number[] = []
|
const values: number[] = []
|
||||||
chartTracks.value.forEach((track) => {
|
chartTracks.value.forEach((track) => {
|
||||||
@@ -986,9 +974,9 @@ function scheduleHover(update: () => void) {
|
|||||||
|
|
||||||
function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean {
|
function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean {
|
||||||
return (
|
return (
|
||||||
hoveredSeriesPoints.value.length === nextPoints.length &&
|
hoverState.points.length === nextPoints.length &&
|
||||||
nextPoints.every((point, index) => {
|
nextPoints.every((point, index) => {
|
||||||
const current = hoveredSeriesPoints.value[index]
|
const current = hoverState.points[index]
|
||||||
return (
|
return (
|
||||||
current?.id === point.id &&
|
current?.id === point.id &&
|
||||||
current.trackIndex === point.trackIndex &&
|
current.trackIndex === point.trackIndex &&
|
||||||
@@ -1003,20 +991,34 @@ function commitHover(
|
|||||||
trackIndex: number | null,
|
trackIndex: number | null,
|
||||||
position: { x: number; y: number },
|
position: { x: number; y: number },
|
||||||
) {
|
) {
|
||||||
if (!hoveredPointsMatch(nextPoints)) hoveredSeriesPoints.value = nextPoints
|
if (!hoveredPointsMatch(nextPoints)) hoverState.points = nextPoints
|
||||||
hoveredTrackIndex.value = trackIndex
|
hoverState.trackIndex = trackIndex
|
||||||
hoverPosition.value = position
|
hoverState.position = position
|
||||||
// Emit using the updated hoveredSeriesPoints to avoid race condition
|
emit('point-hover', hoverState.points[0]?.point ?? null)
|
||||||
emit('point-hover', hoveredSeriesPoints.value[0]?.point ?? null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearHover() {
|
function clearHover() {
|
||||||
cancelPendingHover()
|
cancelPendingHover()
|
||||||
hoveredSeriesPoints.value = []
|
hoverState.points = []
|
||||||
hoveredTrackIndex.value = null
|
hoverState.trackIndex = null
|
||||||
emit('point-hover', null)
|
emit('point-hover', null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createHoveredSeriesPoint(
|
||||||
|
series: DisplaySeries,
|
||||||
|
trackIndex: number,
|
||||||
|
point: WaveformPoint,
|
||||||
|
): HoveredSeriesPoint {
|
||||||
|
return {
|
||||||
|
id: series.id,
|
||||||
|
name: series.name,
|
||||||
|
color: series.color,
|
||||||
|
unit: series.unit,
|
||||||
|
trackIndex,
|
||||||
|
point,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function beginAnnotationDrag() {
|
function beginAnnotationDrag() {
|
||||||
suppressHoverUntilMove.value = true
|
suppressHoverUntilMove.value = true
|
||||||
clearHover()
|
clearHover()
|
||||||
@@ -1334,7 +1336,7 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
|
|||||||
const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
|
const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
|
||||||
const nextPoints = track.seriesList.flatMap((series) => {
|
const nextPoints = track.seriesList.flatMap((series) => {
|
||||||
const point = nearestPoint(series, xValue)
|
const point = nearestPoint(series, xValue)
|
||||||
return point ? [{ ...series, trackIndex, point }] : []
|
return point ? [createHoveredSeriesPoint(series, trackIndex, point)] : []
|
||||||
})
|
})
|
||||||
commitHover(nextPoints, trackIndex, {
|
commitHover(nextPoints, trackIndex, {
|
||||||
x: resolvedChartLeftMargin.value + track.left + pointerX,
|
x: resolvedChartLeftMargin.value + track.left + pointerX,
|
||||||
@@ -1364,7 +1366,7 @@ function handleSharedPointerMove(event: PointerEvent) {
|
|||||||
const nextPoints = trackLayouts.value.flatMap((track) =>
|
const nextPoints = trackLayouts.value.flatMap((track) =>
|
||||||
track.seriesList.flatMap((series) => {
|
track.seriesList.flatMap((series) => {
|
||||||
const point = nearestPoint(series, xValue)
|
const point = nearestPoint(series, xValue)
|
||||||
return point ? [{ ...series, trackIndex: track.index, point }] : []
|
return point ? [createHoveredSeriesPoint(series, track.index, point)] : []
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
commitHover(nextPoints, null, {
|
commitHover(nextPoints, null, {
|
||||||
@@ -1939,7 +1941,6 @@ onBeforeUnmount(() => {
|
|||||||
:track="track"
|
:track="track"
|
||||||
:clip-path-id="clipPathId"
|
:clip-path-id="clipPathId"
|
||||||
:inner-width="innerWidth"
|
:inner-width="innerWidth"
|
||||||
:show-tooltip="showTooltip"
|
|
||||||
:zoomable="zoomable"
|
:zoomable="zoomable"
|
||||||
:display-mode="displayMode"
|
:display-mode="displayMode"
|
||||||
:interaction-mode="activeInteractionMode"
|
:interaction-mode="activeInteractionMode"
|
||||||
@@ -1950,7 +1951,6 @@ onBeforeUnmount(() => {
|
|||||||
:zero-line="resolvedZeroLine"
|
:zero-line="resolvedZeroLine"
|
||||||
:time-unit="timeUnit"
|
:time-unit="timeUnit"
|
||||||
:y-label="yLabel"
|
:y-label="yLabel"
|
||||||
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
|
|
||||||
@pointer-move="handleIndependentPointerMove($event, track.index)"
|
@pointer-move="handleIndependentPointerMove($event, track.index)"
|
||||||
@pointer-down="beginViewportDrag($event, track.index, true)"
|
@pointer-down="beginViewportDrag($event, track.index, true)"
|
||||||
@pointer-up="finishViewportDrag"
|
@pointer-up="finishViewportDrag"
|
||||||
@@ -1960,6 +1960,13 @@ onBeforeUnmount(() => {
|
|||||||
@contextmenu="handleAnnotationContextMenu($event, track.index)"
|
@contextmenu="handleAnnotationContextMenu($event, track.index)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WaveformHoverLayer
|
||||||
|
:state="hoverState"
|
||||||
|
:tracks="trackLayouts"
|
||||||
|
:clip-path-id="clipPathId"
|
||||||
|
:visible="showTooltip"
|
||||||
|
/>
|
||||||
|
|
||||||
<rect
|
<rect
|
||||||
v-if="selectionBox && selection?.mode === 'box'"
|
v-if="selectionBox && selection?.mode === 'box'"
|
||||||
class="waveform-chart__zoom-selection"
|
class="waveform-chart__zoom-selection"
|
||||||
@@ -2061,13 +2068,10 @@ onBeforeUnmount(() => {
|
|||||||
@close="annotationInteraction.closeContextMenu"
|
@close="annotationInteraction.closeContextMenu"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Tooltip -->
|
<WaveformHoverHost
|
||||||
<WaveformTooltip
|
:state="hoverState"
|
||||||
:visible="showTooltip && hoveredPoint !== null"
|
:visible="showTooltip"
|
||||||
:position="hoverPosition"
|
|
||||||
:time-unit="timeUnit"
|
:time-unit="timeUnit"
|
||||||
:hovered-point="hoveredPoint"
|
|
||||||
:series-points="tooltipSeriesPoints"
|
|
||||||
:container-width="chartWidth"
|
:container-width="chartWidth"
|
||||||
:container-height="chartHeight"
|
:container-height="chartHeight"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -63,11 +63,21 @@ export interface WaveformYAxisLayout {
|
|||||||
/**
|
/**
|
||||||
* 悬浮的系列点
|
* 悬浮的系列点
|
||||||
*/
|
*/
|
||||||
export interface HoveredSeriesPoint extends DisplaySeries {
|
export interface HoveredSeriesPoint {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
unit?: string
|
||||||
|
color: string
|
||||||
trackIndex: number
|
trackIndex: number
|
||||||
point: WaveformPoint
|
point: WaveformPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WaveformHoverState {
|
||||||
|
points: HoveredSeriesPoint[]
|
||||||
|
trackIndex: number | null
|
||||||
|
position: { x: number; y: number }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 轨道布局
|
* 轨道布局
|
||||||
*/
|
*/
|
||||||
|
|||||||
28
src/components/interaction/WaveformHoverHost.vue
Normal file
28
src/components/interaction/WaveformHoverHost.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { WaveformHoverState } from '../core/types'
|
||||||
|
import WaveformTooltip from './WaveformTooltip.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
state: WaveformHoverState
|
||||||
|
visible: boolean
|
||||||
|
timeUnit: 's' | 'ms'
|
||||||
|
containerWidth: number
|
||||||
|
containerHeight: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const hoveredPoint = computed(() => props.state.points[0]?.point ?? null)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<WaveformTooltip
|
||||||
|
:visible="visible && hoveredPoint !== null"
|
||||||
|
:position="state.position"
|
||||||
|
:time-unit="timeUnit"
|
||||||
|
:hovered-point="hoveredPoint"
|
||||||
|
:series-points="state.points"
|
||||||
|
:container-width="containerWidth"
|
||||||
|
:container-height="containerHeight"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -66,6 +66,31 @@ describe('WaveformTooltip', () => {
|
|||||||
expect(wrapper.get('.waveform-tooltip__series small').text()).toBe('(+2 / -1)')
|
expect(wrapper.get('.waveform-tooltip__series small').text()).toBe('(+2 / -1)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps a formatted value and its unit in one value container', () => {
|
||||||
|
const hoveredPoint = { x: 1, y: -1405.4932 }
|
||||||
|
const wrapper = mount(WaveformTooltip, {
|
||||||
|
props: {
|
||||||
|
visible: true,
|
||||||
|
position: { x: 10, y: 10 },
|
||||||
|
timeUnit: 'ms',
|
||||||
|
hoveredPoint,
|
||||||
|
seriesPoints: [
|
||||||
|
{
|
||||||
|
trackIndex: 0,
|
||||||
|
name: 'ENG8KJXAc(10001)',
|
||||||
|
color: '#ffb43b',
|
||||||
|
unit: 'A',
|
||||||
|
point: hoveredPoint,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
containerWidth: 400,
|
||||||
|
containerHeight: 300,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A')
|
||||||
|
})
|
||||||
|
|
||||||
it('omits the error label when both resolved errors are zero', () => {
|
it('omits the error label when both resolved errors are zero', () => {
|
||||||
const point = { x: 1, y: 12 }
|
const point = { x: 1, y: 12 }
|
||||||
const wrapper = mount(WaveformTooltip, {
|
const wrapper = mount(WaveformTooltip, {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
>
|
>
|
||||||
<i :style="{ backgroundColor: seriesPoint.color }" />
|
<i :style="{ backgroundColor: seriesPoint.color }" />
|
||||||
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
|
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
|
||||||
<span>
|
<span class="waveform-tooltip__value">
|
||||||
{{ formatTooltipNumber(seriesPoint.point.y)
|
{{ formatTooltipNumber(seriesPoint.point.y)
|
||||||
}}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
|
}}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
|
||||||
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
|
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
|
||||||
@@ -116,7 +116,7 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
|
|
||||||
.waveform-tooltip__series {
|
.waveform-tooltip__series {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 8px auto minmax(0, 1fr);
|
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -134,6 +134,10 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.waveform-tooltip__value {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.waveform-tooltip__series small {
|
.waveform-tooltip__series small {
|
||||||
color: #667085;
|
color: #667085;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export { default as WaveformTooltip } from './WaveformTooltip.vue'
|
export { default as WaveformTooltip } from './WaveformTooltip.vue'
|
||||||
|
export { default as WaveformHoverHost } from './WaveformHoverHost.vue'
|
||||||
|
|||||||
60
src/components/rendering/WaveformHoverLayer.vue
Normal file
60
src/components/rendering/WaveformHoverLayer.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { HoveredSeriesPoint, TrackLayout, WaveformHoverState } from '../core/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
state: WaveformHoverState
|
||||||
|
tracks: TrackLayout[]
|
||||||
|
clipPathId: string
|
||||||
|
visible: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
interface Crosshair {
|
||||||
|
point: HoveredSeriesPoint
|
||||||
|
track: TrackLayout
|
||||||
|
x: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const crosshairs = computed<Crosshair[]>(() => {
|
||||||
|
if (!props.visible) return []
|
||||||
|
|
||||||
|
const pointByTrack = new Map<number, HoveredSeriesPoint>()
|
||||||
|
props.state.points.forEach((point) => {
|
||||||
|
if (!pointByTrack.has(point.trackIndex)) pointByTrack.set(point.trackIndex, point)
|
||||||
|
})
|
||||||
|
|
||||||
|
return props.tracks.flatMap((track) => {
|
||||||
|
const point = pointByTrack.get(track.index)
|
||||||
|
return point && !track.isEmpty && track.hasVisibleSeries
|
||||||
|
? [{ point, track, x: track.xScale(point.point.x) }]
|
||||||
|
: []
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<g class="waveform-chart__hover-layer" pointer-events="none" aria-hidden="true">
|
||||||
|
<g
|
||||||
|
v-for="crosshair in crosshairs"
|
||||||
|
:key="crosshair.track.index"
|
||||||
|
class="waveform-track__crosshair waveform-chart__crosshair"
|
||||||
|
:clip-path="`url(#${clipPathId}-${crosshair.track.index})`"
|
||||||
|
:transform="`translate(${crosshair.track.left ?? 0}, ${crosshair.track.top})`"
|
||||||
|
>
|
||||||
|
<line :x1="crosshair.x" :x2="crosshair.x" y1="0" :y2="crosshair.track.height" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.waveform-track__crosshair {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-track__crosshair line {
|
||||||
|
stroke: #57617b;
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke-dasharray: 4 3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,12 +4,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
|
|||||||
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
|
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
|
||||||
import type { WaveformAxesOptions, WaveformFrameStyle, WaveformZeroLineOptions } from '../../types'
|
import type { WaveformAxesOptions, WaveformFrameStyle, WaveformZeroLineOptions } from '../../types'
|
||||||
import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types'
|
import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types'
|
||||||
import type {
|
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
|
||||||
DisplaySeries,
|
|
||||||
HoveredSeriesPoint,
|
|
||||||
TrackLayout,
|
|
||||||
WaveformYAxisLayout,
|
|
||||||
} from '../core/types'
|
|
||||||
import WaveformSeriesLayer from './WaveformSeriesLayer.vue'
|
import WaveformSeriesLayer from './WaveformSeriesLayer.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,8 +14,6 @@ interface Props {
|
|||||||
clipPathId: string
|
clipPathId: string
|
||||||
/** 内部宽度 */
|
/** 内部宽度 */
|
||||||
innerWidth: number
|
innerWidth: number
|
||||||
/** 是否显示 tooltip */
|
|
||||||
showTooltip: boolean
|
|
||||||
/** 是否可缩放 */
|
/** 是否可缩放 */
|
||||||
zoomable: boolean
|
zoomable: boolean
|
||||||
/** 显示模式 */
|
/** 显示模式 */
|
||||||
@@ -35,8 +28,6 @@ interface Props {
|
|||||||
axes?: WaveformAxesOptions
|
axes?: WaveformAxesOptions
|
||||||
/** 时间单位 */
|
/** 时间单位 */
|
||||||
timeUnit: 's' | 'ms'
|
timeUnit: 's' | 'ms'
|
||||||
/** 悬浮点(用于显示十字线) */
|
|
||||||
hoveredPoint?: HoveredSeriesPoint
|
|
||||||
/** Y 轴标签回退值 */
|
/** Y 轴标签回退值 */
|
||||||
yLabel?: string
|
yLabel?: string
|
||||||
/** Hide visual aids while keeping chart interaction active. */
|
/** Hide visual aids while keeping chart interaction active. */
|
||||||
@@ -110,20 +101,6 @@ function shouldShowYAxisLabel(trackHeight: number, trackIndex: number): boolean
|
|||||||
return trackIndex % labelSpacing === 0
|
return trackIndex % labelSpacing === 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function crosshairX(): number {
|
|
||||||
return props.hoveredPoint && props.hoveredPoint.trackIndex === props.track.index
|
|
||||||
? props.track.xScale(props.hoveredPoint.point.x)
|
|
||||||
: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasCrosshair(): boolean {
|
|
||||||
return (
|
|
||||||
props.showTooltip &&
|
|
||||||
props.hoveredPoint !== undefined &&
|
|
||||||
props.hoveredPoint.trackIndex === props.track.index
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function zeroLineY(axis: WaveformYAxisLayout): number | null {
|
function zeroLineY(axis: WaveformYAxisLayout): number | null {
|
||||||
const [minimum, maximum] = axis.scale.domain()
|
const [minimum, maximum] = axis.scale.domain()
|
||||||
if (!props.zeroLine.visible || minimum > 0 || maximum < 0) return null
|
if (!props.zeroLine.visible || minimum > 0 || maximum < 0) return null
|
||||||
@@ -472,15 +449,6 @@ watch(
|
|||||||
<!-- 波形系列隔离在静态子组件中,避免 hover 更新遍历大量 SVG 节点。 -->
|
<!-- 波形系列隔离在静态子组件中,避免 hover 更新遍历大量 SVG 节点。 -->
|
||||||
<WaveformSeriesLayer :track="track" :clip-path-id="clipPathId" />
|
<WaveformSeriesLayer :track="track" :clip-path-id="clipPathId" />
|
||||||
|
|
||||||
<!-- 十字线 -->
|
|
||||||
<g
|
|
||||||
v-if="!track.isEmpty && track.hasVisibleSeries && hasCrosshair()"
|
|
||||||
class="waveform-track__crosshair waveform-chart__crosshair"
|
|
||||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
|
||||||
>
|
|
||||||
<line :x1="crosshairX()" :x2="crosshairX()" y1="0" :y2="track.height" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- 交互覆盖层(仅在独立模式下) -->
|
<!-- 交互覆盖层(仅在独立模式下) -->
|
||||||
<rect
|
<rect
|
||||||
v-if="!track.isEmpty && track.hasVisibleSeries && displayMode === 'independent'"
|
v-if="!track.isEmpty && track.hasVisibleSeries && displayMode === 'independent'"
|
||||||
@@ -587,16 +555,6 @@ watch(
|
|||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-track__crosshair {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.waveform-track__crosshair line {
|
|
||||||
stroke: #57617b;
|
|
||||||
stroke-width: 1;
|
|
||||||
stroke-dasharray: 4 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.waveform-track__zero-line {
|
.waveform-track__zero-line {
|
||||||
fill: none;
|
fill: none;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as WaveformTrack } from './WaveformTrack.vue'
|
export { default as WaveformTrack } from './WaveformTrack.vue'
|
||||||
export { default as WaveformLegend } from './WaveformLegend.vue'
|
export { default as WaveformLegend } from './WaveformLegend.vue'
|
||||||
|
export { default as WaveformHoverLayer } from './WaveformHoverLayer.vue'
|
||||||
export { waveformPointSymbolPath } from './seriesStyle'
|
export { waveformPointSymbolPath } from './seriesStyle'
|
||||||
|
|||||||
Reference in New Issue
Block a user