Files
waveform-analysis/src/components/WaveformChart.vue

1580 lines
51 KiB
Vue
Raw Normal View History

2026-07-20 10:21:30 +08:00
<script setup lang="ts">
import { Pagination } from 'ant-design-vue'
import {
bisector,
pointer,
scaleLinear,
select,
zoom,
zoomIdentity,
type D3ZoomEvent,
type ZoomBehavior,
type ZoomTransform,
} from 'd3'
import { resolveWaveformRenderingOptions } from '../core'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
useId,
watch,
type CSSProperties,
} from 'vue'
2026-07-20 10:21:30 +08:00
import {
type WaveformAnnotation,
type WaveformData,
type WaveformDisplayMode,
type WaveformFrameStyle,
2026-07-20 10:21:30 +08:00
type WaveformInteractionMode,
type WaveformOverlayMode,
type WaveformLegendOptions,
type WaveformLegendOrientation,
type WaveformLegendPosition,
2026-07-20 10:21:30 +08:00
type WaveformPoint,
type WaveformRenderingOptions,
type WaveformTitleOptions,
2026-07-20 10:21:30 +08:00
} from './data/types'
import {
ANNOTATION_AMBIGUITY_DISTANCE,
ANNOTATION_HIT_RADIUS,
findAnnotationSeriesCandidates,
interpolateAnnotationPoint,
layoutAnnotations,
useWaveformAnnotationInteraction,
type AnnotationEditorAnchor,
WaveformAnnotationContextMenu,
WaveformAnnotationLayer,
WaveformAnnotationToolbar,
type AnnotationHit,
type AnnotationSeriesCandidate,
type AnnotationSeriesInfo,
type AnnotationTrackLayout,
} from './annotation'
import { WaveformTooltip } from './interaction'
import { WaveformTrack } from './rendering'
import {
channelColors,
margin as chartMargin,
minimumHeight as chartMinimumHeight,
} from './core/constants'
import {
getGridGap,
getPageCount,
getPageSize,
normalizeGridOptions,
paginateSeries,
resolveGridCellGeometry,
X_AXIS_BAND,
type WaveformGridOptions,
} from './core/grid'
import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types'
import {
buildTrackLayouts,
measureTrackYAxisClearance,
Y_AXIS_EXPONENT_GAP,
} from './core/layout'
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './core/title'
2026-07-20 10:21:30 +08:00
import { usePreparedWaveformSeries } from './core/useWaveformData'
import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue'
const props = withDefaults(
defineProps<{
data: WaveformData
displayMode?: WaveformDisplayMode
overlayMode?: WaveformOverlayMode
width?: number
2026-07-20 10:21:30 +08:00
height?: number
xLabel?: string
yLabel?: string
lineColor?: string
showTooltip?: boolean
zoomable?: boolean
timeUnit?: 's' | 'ms'
frameNumber?: string | number
frameStyle?: WaveformFrameStyle
2026-07-20 10:21:30 +08:00
annotations?: WaveformAnnotation[]
annotationsVisible?: boolean
interactionMode?: WaveformInteractionMode
showAnnotationToolbar?: boolean
grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions
title?: WaveformTitleOptions
legend?: WaveformLegendOptions
hiddenSeriesIds?: string[]
defaultHiddenSeriesIds?: string[]
2026-07-20 10:21:30 +08:00
}>(),
{
displayMode: 'independent',
overlayMode: 'single-axis',
2026-07-20 10:21:30 +08:00
yLabel: '幅值',
lineColor: '#0960bd',
showTooltip: true,
zoomable: true,
timeUnit: 'ms',
frameNumber: undefined,
annotations: () => [],
annotationsVisible: true,
interactionMode: undefined,
showAnnotationToolbar: false,
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
rendering: () => ({}),
legend: () => ({ position: 'top-right', orientation: 'auto' }),
defaultHiddenSeriesIds: () => [],
2026-07-20 10:21:30 +08:00
},
)
const emit = defineEmits<{
'point-hover': [point: WaveformPoint | null]
'zoom-change': [domain: [number, number]]
'update:annotations': [annotations: WaveformAnnotation[]]
'update:annotations-visible': [visible: boolean]
'update:interaction-mode': [mode: WaveformInteractionMode]
'update:hidden-series-ids': [ids: string[]]
'series-visibility-change': [
payload: {
seriesId: string
visible: boolean
hiddenSeriesIds: string[]
},
]
2026-07-20 10:21:30 +08:00
'annotation-create': [annotation: WaveformAnnotation]
'annotation-update': [annotation: WaveformAnnotation, previous: WaveformAnnotation]
'annotation-delete': [annotation: WaveformAnnotation]
'page-change': [page: number, pageCount: number]
}>()
const margin = chartMargin
const minimumHeight = chartMinimumHeight
const container = ref<HTMLDivElement>()
const svgElement = ref<SVGSVGElement>()
const titleMeasureElement = ref<HTMLSpanElement>()
2026-07-20 10:21:30 +08:00
const sharedOverlayElement = ref<SVGRectElement>()
const observedWidth = ref(0)
const observedHeight = ref(0)
const measuredTitleWidth = ref(0)
const measuredTitleHeight = ref(0)
2026-07-20 10:21:30 +08:00
const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
const independentTransforms = shallowRef<ZoomTransform[]>([])
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
const hoveredTrackIndex = ref<number | null>(null)
const hoverPosition = ref({ x: 0, y: 0 })
const currentPage = ref(1)
const resizeObserver = shallowRef<ResizeObserver>()
const zoomBehaviors = new Map<number | 'shared', ZoomBehavior<SVGRectElement, unknown>>()
const clipPathId = `${useId()}-waveform-clip`
const internalInteractionMode = ref<WaveformInteractionMode | undefined>(undefined)
const internalHiddenSeriesIds = ref(new Set(props.defaultHiddenSeriesIds))
2026-07-20 10:21:30 +08:00
const annotationInteraction = useWaveformAnnotationInteraction()
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
let generatedAnnotationId = 0
let synchronizingZoomTransform = false
let zoomAnimationFrame: number | null = null
let pendingSharedZoomTransform: ZoomTransform | null = null
const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
let hoverAnimationFrame: number | null = null
let pendingHoverUpdate: (() => void) | null = null
2026-07-20 10:21:30 +08:00
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
// 用于传递给 WaveformTooltip 的接口
interface TooltipSeriesPoint {
trackIndex: number
name: string
color: string
unit?: string
point: WaveformPoint
}
const fixedWidth = computed(() =>
Number.isFinite(props.width) ? Math.max(0, props.width ?? 0) : undefined,
)
const fixedHeight = computed(() =>
Number.isFinite(props.height) ? Math.max(minimumHeight, props.height ?? 0) : undefined,
)
const chartWidth = computed(() =>
observedWidth.value > 0 ? observedWidth.value : (fixedWidth.value ?? 0),
)
2026-07-20 10:21:30 +08:00
const chartHeight = computed(() =>
observedHeight.value > 0 ? observedHeight.value : (fixedHeight.value ?? minimumHeight),
2026-07-20 10:21:30 +08:00
)
const containerStyle = computed(() => ({
width: fixedWidth.value === undefined ? '100%' : `${fixedWidth.value}px`,
height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`,
}))
const legendPosition = computed<WaveformLegendPosition>(() => props.legend?.position ?? 'top-right')
const legendBackgroundColor = computed(
() => props.legend?.backgroundColor || 'rgba(255, 255, 255, 0.7)',
)
const legendInteractive = computed(() => props.legend?.interactive === true)
const hiddenSeriesIdSet = computed(() =>
props.hiddenSeriesIds === undefined
? internalHiddenSeriesIds.value
: new Set(props.hiddenSeriesIds),
)
const resolvedHiddenSeriesIds = computed(() => Array.from(hiddenSeriesIdSet.value))
const legendOrientation = computed<Exclude<WaveformLegendOrientation, 'auto'>>(() => {
const orientation = props.legend?.orientation ?? 'auto'
if (orientation !== 'auto') return orientation
return legendPosition.value === 'top' || legendPosition.value === 'bottom'
? 'horizontal'
: 'vertical'
})
const resolvedTitleText = computed(() => props.title?.text.trim() ?? '')
const titleVisible = computed(
() =>
Boolean(props.title) && props.title?.visible !== false && resolvedTitleText.value.length > 0,
)
const titleFontSize = computed(() => {
const fontSize = props.title?.textStyle?.fontSize
return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 ? (fontSize as number) : 14
})
const titleRotation = computed(() => {
const rotation = props.title?.textStyle?.rotation
return Number.isFinite(rotation) ? (rotation as number) : 0
})
const titleIsRotated = computed(() => {
const normalizedRotation = ((titleRotation.value % 360) + 360) % 360
return normalizedRotation > 1e-6 && Math.abs(normalizedRotation - 360) > 1e-6
})
const titlePresentationStyle = computed<CSSProperties>(() => ({
color: props.title?.textStyle?.color ?? '#1f2937',
fontSize: `${titleFontSize.value}px`,
fontFamily: props.title?.textStyle?.fontFamily || '"Microsoft YaHei", "微软雅黑", sans-serif',
fontWeight: props.title?.textStyle?.fontWeight ?? 400,
fontStyle: props.title?.textStyle?.fontStyle ?? 'normal',
textDecoration: props.title?.textStyle?.textDecoration ?? 'none',
letterSpacing: props.title?.textStyle?.letterSpacing ?? 'normal',
lineHeight: '1.2',
}))
const estimatedTitleWidth = computed(() => {
const letterSpacing = Number.parseFloat(props.title?.textStyle?.letterSpacing ?? '')
const spacingWidth = Number.isFinite(letterSpacing)
? Math.max(0, resolvedTitleText.value.length - 1) * letterSpacing
: 0
return Math.max(1, resolvedTitleText.value.length * titleFontSize.value * 0.62 + spacingWidth)
})
const titleAvailableWidth = computed(() => {
const measuredAvailableWidth = chartWidth.value - TITLE_AREA_HORIZONTAL_PADDING * 2
return measuredAvailableWidth > 0 ? measuredAvailableWidth : estimatedTitleWidth.value
})
const titleMeasureStyle = computed<CSSProperties>(() => ({
...titlePresentationStyle.value,
width: 'max-content',
maxWidth: titleIsRotated.value ? 'none' : `${titleAvailableWidth.value}px`,
whiteSpace: titleIsRotated.value ? 'nowrap' : 'normal',
overflowWrap: titleIsRotated.value ? 'normal' : 'anywhere',
}))
const titleLayout = computed(() =>
calculateRotatedTitleLayout({
naturalWidth: measuredTitleWidth.value || estimatedTitleWidth.value,
naturalHeight: measuredTitleHeight.value || titleFontSize.value * 1.2,
availableWidth: titleAvailableWidth.value,
rotation: titleRotation.value,
}),
)
const titleAreaHeight = computed(() => (titleVisible.value ? titleLayout.value.areaHeight : 0))
const drawingHeight = computed(() => Math.max(0, chartHeight.value - titleAreaHeight.value))
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom))
const titleAreaStyle = computed<CSSProperties>(() => ({
height: `${titleAreaHeight.value}px`,
justifyContent:
props.title?.align === 'left'
? 'flex-start'
: props.title?.align === 'right'
? 'flex-end'
: 'center',
}))
const titleVisualStyle = computed<CSSProperties>(() => ({
width: `${titleLayout.value.visualWidth}px`,
height: `${titleLayout.value.visualHeight}px`,
}))
const titleTextStyle = computed<CSSProperties>(() => ({
...titlePresentationStyle.value,
width: `${titleLayout.value.textWidth}px`,
minHeight: `${titleLayout.value.textHeight}px`,
textAlign: props.title?.align ?? 'center',
whiteSpace: titleIsRotated.value ? 'nowrap' : 'normal',
overflowWrap: titleIsRotated.value ? 'normal' : 'anywhere',
transform: `translate(-50%, -50%) rotate(${titleRotation.value}deg) scale(${titleLayout.value.scale})`,
}))
2026-07-20 10:21:30 +08:00
const chartSeries = computed<DisplaySeries[]>(() =>
preparedSeries.value.map((series, index: number): DisplaySeries => ({
...series,
color:
series.color ?? (index === 0 ? props.lineColor : channelColors[index % channelColors.length]),
})),
)
const chartTracks = computed<DisplayTrack[]>(() => {
const groupedSeries = new Map<string, DisplaySeries[]>()
chartSeries.value.forEach((series) => {
const trackId = series.trackId || series.id
const trackSeries = groupedSeries.get(trackId)
if (trackSeries) trackSeries.push(series)
else groupedSeries.set(trackId, [series])
})
return Array.from(groupedSeries, ([id, series]) => {
const visibleSeries = series.filter((item) => !hiddenSeriesIdSet.value.has(item.id))
return {
id,
series,
visibleSeries,
xDomain: paddedDomain(visibleSeries.flatMap((item) => item.xDomain)),
yDomain: paddedDomain(visibleSeries.flatMap((item) => item.yDomain)),
}
})
})
2026-07-20 10:21:30 +08:00
const gridOptions = computed(() => normalizeGridOptions(props.grid))
const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
const pageCount = computed(() => getPageCount(chartTracks.value.length, gridOptions.value))
const pagedTracks = computed(() =>
paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
2026-07-20 10:21:30 +08:00
)
const yAxisCharacterWidth = 7
const yAxisTickPadding = 7
const yAxisOuterPadding = 4
const yAxisLabelGap = 6
const yAxisLabelBandWidth = 24
const minimumPlotWidth = 120
const yAxisMetrics = computed(() => {
const axisText = chartTracks.value
.filter((track) => track.visibleSeries.length > 0)
.map((track) => {
const scale = scaleLinear(track.yDomain, [1, 0]).nice()
const [axisMin, axisMax] = scale.domain()
const values = scale.ticks(10)
return {
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
tickLabels: values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
}
})
const formattedTickLabels = axisText.flatMap(({ tickLabels }) => tickLabels)
2026-07-20 10:21:30 +08:00
const maximumCharacterCount = Math.max(1, ...formattedTickLabels.map((label) => label.length))
const tickTextWidth = maximumCharacterCount * yAxisCharacterWidth
const maximumExponentWidth = Math.max(
0,
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * yAxisCharacterWidth),
)
const exponentClearance = maximumExponentWidth
? maximumExponentWidth + Y_AXIS_EXPONENT_GAP
: 0
const tickClearance = tickTextWidth + yAxisTickPadding + exponentClearance + yAxisOuterPadding
const labelCenterX = -(
yAxisTickPadding +
tickTextWidth +
exponentClearance +
yAxisLabelGap +
yAxisLabelBandWidth / 2
)
2026-07-20 10:21:30 +08:00
const fullClearance =
tickTextWidth +
yAxisTickPadding +
exponentClearance +
yAxisLabelGap +
yAxisLabelBandWidth +
yAxisOuterPadding
2026-07-20 10:21:30 +08:00
return { tickClearance, fullClearance, labelCenterX }
})
const hasYAxisLabels = computed(() =>
chartTracks.value.some(
(track) =>
track.visibleSeries.length === 1 &&
Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel),
),
2026-07-20 10:21:30 +08:00
)
const hasVisibleWaveformData = computed(() =>
chartTracks.value.some((track) => track.visibleSeries.length > 0),
)
2026-07-20 10:21:30 +08:00
const chartLeftMargin = computed(() =>
Math.max(
margin.left,
hasYAxisLabels.value
? yAxisMetrics.value.fullClearance
: hasVisibleWaveformData.value
2026-07-20 10:21:30 +08:00
? yAxisMetrics.value.tickClearance
: 0,
),
)
const multiAxisClearance = computed(() =>
chartTracks.value.reduce(
(maximum, track) => {
const clearance = measureTrackYAxisClearance(track, props.overlayMode)
return {
left: Math.max(maximum.left, clearance.left),
right: Math.max(maximum.right, clearance.right),
}
},
{ left: 0, right: 0 },
),
)
const resolvedChartLeftMargin = computed(() =>
props.overlayMode === 'multi-axis'
? Math.max(chartLeftMargin.value, multiAxisClearance.value.left)
: chartLeftMargin.value,
)
const chartRightMargin = computed(() =>
props.overlayMode === 'multi-axis'
? Math.max(margin.right, multiAxisClearance.value.right)
: margin.right,
)
const innerWidth = computed(() =>
Math.max(0, chartWidth.value - resolvedChartLeftMargin.value - chartRightMargin.value),
)
2026-07-20 10:21:30 +08:00
const yAxisLayout = computed(() => {
const baseGap = getGridGap(props.displayMode)
const columnCount = gridOptions.value.columnCount
const hasMultipleColumns = columnCount > 1
const fullGap = Math.max(baseGap, yAxisMetrics.value.fullClearance)
const tickGap = Math.max(baseGap, yAxisMetrics.value.tickClearance)
const fullGapPlotWidth = (innerWidth.value - fullGap * Math.max(0, columnCount - 1)) / columnCount
const canReserveLabelClearance = fullGapPlotWidth >= minimumPlotWidth
return {
horizontalGap:
props.overlayMode === 'multi-axis' && hasMultipleColumns && hasVisibleWaveformData.value
? Math.max(baseGap, multiAxisClearance.value.left + multiAxisClearance.value.right)
: hasMultipleColumns && hasVisibleWaveformData.value
? hasYAxisLabels.value && canReserveLabelClearance
? fullGap
: tickGap
: baseGap,
hideSecondaryLabels:
props.overlayMode !== 'multi-axis' &&
hasMultipleColumns &&
hasYAxisLabels.value &&
!canReserveLabelClearance,
2026-07-20 10:21:30 +08:00
}
})
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 resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit}`)
const activeInteractionMode = computed(() => props.interactionMode ?? internalInteractionMode.value)
// 当 interactionMode 未定义或为 'zoom' 时启用缩放
const isZoomMode = computed(
() => 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(() =>
paddedDomain(
chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])),
),
2026-07-20 10:21:30 +08:00
)
const sharedZoomDomain = computed(
() =>
sharedTransform.value
.rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value]))
.domain() as [number, number],
)
const gridCells = computed(() => {
const cells = resolveGridCellGeometry(
innerWidth.value,
innerHeight.value,
gridOptions.value,
props.displayMode,
pagedTracks.value.map(Boolean),
2026-07-20 10:21:30 +08:00
yAxisLayout.value.horizontalGap,
)
return cells.map((cell, index) => ({ ...cell, series: pagedTracks.value[index] }))
2026-07-20 10:21:30 +08:00
})
const trackLayouts = computed<TrackLayout[]>(() =>
buildTrackLayouts({
cells: gridCells.value,
grid: gridOptions.value,
displayMode: props.displayMode,
overlayMode: props.overlayMode,
2026-07-20 10:21:30 +08:00
independentTransforms: independentTransforms.value,
sharedZoomDomain: sharedZoomDomain.value,
timeUnit: props.timeUnit,
rendering: renderingOptions.value,
hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX,
showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
2026-07-20 10:21:30 +08:00
}),
)
function annotationLayoutsForTrack(track: TrackLayout): AnnotationTrackLayout[] {
return track.seriesList.map((series) => ({
...track,
series,
yScale:
track.seriesPaths.find((seriesPath) => seriesPath.series.id === series.id)?.yScale ??
track.yScale,
}))
}
function resolveSeriesYScale(track: TrackLayout, seriesId: string) {
return (
track.seriesPaths.find((seriesPath) => seriesPath.series.id === seriesId)?.yScale ??
track.yScale
)
}
const annotationTrackLayouts = computed<AnnotationTrackLayout[]>(() =>
trackLayouts.value.flatMap(annotationLayoutsForTrack),
)
2026-07-20 10:21:30 +08:00
const renderedAnnotations = computed(() =>
props.annotationsVisible
? layoutAnnotations(
props.annotations,
annotationTrackLayouts.value,
2026-07-20 10:21:30 +08:00
innerWidth.value,
innerHeight.value,
)
: [],
)
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
const series = chartSeries.value.find((item) => item.id === seriesId)
return series
? {
id: series.id,
name: series.name.trim() || series.id,
color: series.color,
unit: series.unit,
}
: undefined
})
function resolveFrameNumber(trackIndex: number): string | number | undefined {
if (props.frameNumber === undefined || props.frameNumber === null) return undefined
if (chartTracks.value.length === 1) return props.frameNumber
2026-07-20 10:21:30 +08:00
return typeof props.frameNumber === 'number'
? props.frameNumber + trackIndex
: `${props.frameNumber}-${trackIndex + 1}`
}
function handleSharedZoom(event: D3ZoomEvent<SVGRectElement, unknown>) {
if (synchronizingZoomTransform) return
cancelPendingHover()
pendingSharedZoomTransform = event.transform
scheduleZoomCommit()
2026-07-20 10:21:30 +08:00
}
function handleIndependentZoom(event: D3ZoomEvent<SVGRectElement, unknown>, trackIndex: number) {
if (synchronizingZoomTransform) return
cancelPendingHover()
pendingIndependentZoomTransforms.set(trackIndex, event.transform)
scheduleZoomCommit()
}
function commitPendingZoom() {
if (pendingSharedZoomTransform) {
const transform = pendingSharedZoomTransform
pendingSharedZoomTransform = null
sharedTransform.value = transform
const domain = transform
.rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value]))
.domain()
emit('zoom-change', [domain[0], domain[1]])
}
if (!pendingIndependentZoomTransforms.size) return
2026-07-20 10:21:30 +08:00
const nextTransforms = [...independentTransforms.value]
const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
nextTransforms[trackIndex] = transform
})
pendingIndependentZoomTransforms.clear()
2026-07-20 10:21:30 +08:00
independentTransforms.value = nextTransforms
changedTrackIndexes.forEach((trackIndex) => {
const track = trackLayouts.value.find((item) => item.index === trackIndex)
if (!track) return
const domain = track.xScale.domain()
emit('zoom-change', [domain[0], domain[1]])
})
}
function scheduleZoomCommit() {
if (zoomAnimationFrame !== null) return
zoomAnimationFrame = requestAnimationFrame(() => {
zoomAnimationFrame = null
commitPendingZoom()
})
}
function flushPendingZoom() {
if (zoomAnimationFrame !== null) {
cancelAnimationFrame(zoomAnimationFrame)
zoomAnimationFrame = null
}
commitPendingZoom()
}
function cancelPendingZoom() {
pendingSharedZoomTransform = null
pendingIndependentZoomTransforms.clear()
if (zoomAnimationFrame === null) return
cancelAnimationFrame(zoomAnimationFrame)
zoomAnimationFrame = null
2026-07-20 10:21:30 +08:00
}
function clearZoomBindings() {
cancelPendingZoom()
2026-07-20 10:21:30 +08:00
const svg = svgElement.value
if (svg) {
const overlays = svg.querySelectorAll<SVGRectElement>('.waveform-chart__overlay')
overlays.forEach((overlay) => {
if (overlay) select(overlay).on('.zoom', null)
})
}
zoomBehaviors.clear()
}
function configureZoom() {
clearZoomBindings()
if (!props.zoomable || !isZoomMode.value || !hasChartArea.value || !trackLayouts.value.length)
return
if (props.displayMode === 'independent') {
trackLayouts.value.forEach((track) => {
const overlay = svgElement.value?.querySelector<SVGRectElement>(
`[data-independent-overlay-index="${track.index}"]`,
)
if (!overlay) return
const behavior = zoom<SVGRectElement, unknown>()
.scaleExtent([1, 40])
.extent([
[0, 0],
[track.width, track.height],
])
.translateExtent([
[0, 0],
[track.width, track.height],
])
.on('zoom', (event) => handleIndependentZoom(event, track.index))
.on('end', flushPendingZoom)
2026-07-20 10:21:30 +08:00
zoomBehaviors.set(track.index, behavior)
synchronizingZoomTransform = true
try {
select(overlay)
.call(behavior)
.call(behavior.transform, independentTransforms.value[track.index] ?? zoomIdentity)
} finally {
synchronizingZoomTransform = false
}
})
return
}
if (!sharedOverlayElement.value) return
const behavior = zoom<SVGRectElement, unknown>()
.scaleExtent([1, 40])
.extent([
[0, 0],
[innerWidth.value, innerHeight.value],
])
.translateExtent([
[0, 0],
[innerWidth.value, innerHeight.value],
])
.on('zoom', handleSharedZoom)
.on('end', flushPendingZoom)
2026-07-20 10:21:30 +08:00
zoomBehaviors.set('shared', behavior)
const overlay = sharedOverlayElement.value
if (overlay) {
synchronizingZoomTransform = true
try {
select(overlay).call(behavior).call(behavior.transform, sharedTransform.value)
} finally {
synchronizingZoomTransform = false
}
}
}
function cancelPendingHover() {
pendingHoverUpdate = null
if (hoverAnimationFrame === null) return
cancelAnimationFrame(hoverAnimationFrame)
hoverAnimationFrame = null
}
function scheduleHover(update: () => void) {
pendingHoverUpdate = update
if (hoverAnimationFrame !== null) return
hoverAnimationFrame = requestAnimationFrame(() => {
hoverAnimationFrame = null
const nextUpdate = pendingHoverUpdate
pendingHoverUpdate = null
nextUpdate?.()
})
}
function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean {
return (
hoveredSeriesPoints.value.length === nextPoints.length &&
nextPoints.every((point, index) => {
const current = hoveredSeriesPoints.value[index]
return (
current?.id === point.id &&
current.trackIndex === point.trackIndex &&
current.point === point.point
)
})
)
}
function commitHover(
nextPoints: HoveredSeriesPoint[],
trackIndex: number | null,
position: { x: number; y: number },
) {
if (!hoveredPointsMatch(nextPoints)) hoveredSeriesPoints.value = nextPoints
hoveredTrackIndex.value = trackIndex
hoverPosition.value = position
emit('point-hover', nextPoints[0]?.point ?? null)
}
2026-07-20 10:21:30 +08:00
function clearHover() {
cancelPendingHover()
2026-07-20 10:21:30 +08:00
hoveredSeriesPoints.value = []
hoveredTrackIndex.value = null
emit('point-hover', null)
}
function nearestPoint(series: DisplaySeries, xValue: number): WaveformPoint | undefined {
const index = bisector((point: WaveformPoint) => point.x).center(series.points, xValue)
return series.points[index]
}
function makeAnnotationId(): string {
generatedAnnotationId += 1
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return `annotation-${Date.now()}-${generatedAnnotationId}`
}
function setInteractionMode(mode: WaveformInteractionMode) {
if (props.interactionMode === undefined) internalInteractionMode.value = mode
annotationInteraction.closeContextMenu()
editorSeriesOptions.value = []
emit('update:interaction-mode', mode)
}
function setAnnotationsVisible(visible: boolean) {
annotationInteraction.closeContextMenu()
if (!visible) {
annotationInteraction.closeEditor()
editorSeriesOptions.value = []
}
emit('update:annotations-visible', visible)
}
function toggleSeriesVisibility(seriesId: string) {
if (!chartSeries.value.some((series) => series.id === seriesId)) return
const nextHiddenSeriesIds = new Set(hiddenSeriesIdSet.value)
const visible = nextHiddenSeriesIds.has(seriesId)
if (visible) nextHiddenSeriesIds.delete(seriesId)
else nextHiddenSeriesIds.add(seriesId)
const ids = Array.from(nextHiddenSeriesIds)
if (props.hiddenSeriesIds === undefined) internalHiddenSeriesIds.value = nextHiddenSeriesIds
emit('update:hidden-series-ids', ids)
emit('series-visibility-change', { seriesId, visible, hiddenSeriesIds: ids })
}
2026-07-20 10:21:30 +08:00
function resolvePointerEditorAnchor(
event: MouseEvent,
trackIndex?: number,
): AnnotationEditorAnchor {
const overlay = event.currentTarget as SVGRectElement | null
if (!overlay) return { x: chartWidth.value / 2, y: chartHeight.value / 2 }
2026-07-20 10:21:30 +08:00
const [pointerX, pointerY] = pointer(event, overlay)
const track = trackIndex === undefined ? undefined : trackLayouts.value[trackIndex]
return {
x: resolvedChartLeftMargin.value + (track ? track.left + pointerX : pointerX),
y: titleAreaHeight.value + margin.top + (track ? track.top + pointerY : pointerY),
2026-07-20 10:21:30 +08:00
}
}
function resolveAnnotationEditorAnchor(annotation: WaveformAnnotation): AnnotationEditorAnchor {
const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === annotation.seriesId),
)
2026-07-20 10:21:30 +08:00
return {
x: track
? resolvedChartLeftMargin.value + track.left + track.xScale(annotation.x)
: chartWidth.value / 2,
y: track
? titleAreaHeight.value +
margin.top +
track.top +
resolveSeriesYScale(track, annotation.seriesId)(annotation.y)
: chartHeight.value / 2,
2026-07-20 10:21:30 +08:00
}
}
function beginCreate(
hit: AnnotationHit,
anchor: AnnotationEditorAnchor,
candidates: AnnotationSeriesCandidate[],
) {
annotationInteraction.openCreate(hit, makeAnnotationId, anchor)
editorSeriesOptions.value = candidates
const draft = annotationInteraction.editorDraft.value
const track = trackLayouts.value.find((item) => item.index === hit.trackIndex)
const series = track?.seriesList.find((item) => item.id === hit.seriesId)
2026-07-20 10:21:30 +08:00
if (draft?.mode === 'add') {
draft.annotation.style = {
borderColor: series?.color || '#1677ff',
2026-07-20 10:21:30 +08:00
textColor: '#333333',
backgroundColor: 'rgba(255, 255, 255, 0.92)',
}
}
}
function changeDraftSeries(seriesId: string) {
const draft = annotationInteraction.editorDraft.value
const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId)
const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === seriesId),
)
const series = track?.seriesList.find((item) => item.id === seriesId)
2026-07-20 10:21:30 +08:00
const point =
series && draft
? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType)
: null
2026-07-20 10:21:30 +08:00
if (!draft || !candidate || !track || !point) return
draft.annotation = {
...draft.annotation,
seriesId,
y: point.y,
style: { ...draft.annotation.style, borderColor: candidate.color },
}
}
function cancelAnnotation() {
annotationInteraction.closeEditor()
editorSeriesOptions.value = []
}
interface AnnotationCandidateContext {
candidates: AnnotationSeriesCandidate[]
editorAnchor: AnnotationEditorAnchor
}
function resolveTrackAtPointer(
pointerX: number,
pointerY: number,
trackIndex?: number,
): TrackLayout | undefined {
if (trackIndex !== undefined) {
const track = trackLayouts.value[trackIndex]
return track?.hasVisibleSeries ? track : undefined
}
const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
if (!visibleTracks.length) return undefined
2026-07-20 10:21:30 +08:00
const distanceToTrack = (track: TrackLayout) => {
const xDistance =
pointerX < track.left
? track.left - pointerX
: pointerX > track.left + track.width
? pointerX - track.left - track.width
: 0
if (pointerY < track.top) return track.top - pointerY
if (pointerY > track.top + track.height) return pointerY - (track.top + track.height)
return xDistance
}
return visibleTracks.reduce((closest, candidate) => {
2026-07-20 10:21:30 +08:00
const distance = distanceToTrack(candidate)
const closestDistance = distanceToTrack(closest)
if (distance !== closestDistance) return distance < closestDistance ? candidate : closest
const centerDistance = Math.abs(pointerY - (candidate.top + candidate.height / 2))
const closestCenterDistance = Math.abs(pointerY - (closest.top + closest.height / 2))
return centerDistance < closestCenterDistance ? candidate : closest
})
}
function resolveAnnotationCandidates(
event: MouseEvent,
trackIndex?: number,
): AnnotationCandidateContext | null {
const overlay = event.currentTarget as SVGRectElement | null
if (!overlay || !trackLayouts.value.length) return null
const [pointerX, pointerY] = pointer(event, overlay)
const sharedPointerY =
trackIndex === undefined ? pointerY : pointerY + (trackLayouts.value[trackIndex]?.top ?? 0)
const referenceTrack = resolveTrackAtPointer(pointerX, sharedPointerY, trackIndex)
if (!referenceTrack) return null
const clampedX = Math.max(0, Math.min(innerWidth.value, pointerX))
const localPointerX = Math.max(0, Math.min(referenceTrack.width, clampedX - referenceTrack.left))
const xValue = referenceTrack.xScale.invert(localPointerX)
return {
candidates: findAnnotationSeriesCandidates(
annotationLayoutsForTrack(referenceTrack),
2026-07-20 10:21:30 +08:00
xValue,
localPointerX,
sharedPointerY,
),
editorAnchor: resolvePointerEditorAnchor(event, trackIndex),
}
}
function nearbyCandidates(candidates: AnnotationSeriesCandidate[]): AnnotationSeriesCandidate[] {
const closest = candidates[0]
if (!closest || closest.distance > ANNOTATION_HIT_RADIUS) return []
return candidates.filter(
(candidate) =>
candidate.distance <= ANNOTATION_HIT_RADIUS &&
candidate.distance - closest.distance < ANNOTATION_AMBIGUITY_DISTANCE,
)
}
function handleAnnotationClick(event: MouseEvent, trackIndex?: number) {
if (!props.annotationsVisible || activeInteractionMode.value !== 'annotation') return
const context = resolveAnnotationCandidates(event, trackIndex)
if (!context) return
const nearby = nearbyCandidates(context.candidates)
if (!nearby.length) return
event.preventDefault()
event.stopPropagation()
beginCreate(nearby[0], context.editorAnchor, context.candidates)
}
function handleNativeContextMenu(event: MouseEvent) {
const target = event.target
if (
target instanceof Element &&
target.closest('input, textarea, [contenteditable]:not([contenteditable="false"])')
) {
return
}
event.preventDefault()
}
2026-07-20 10:21:30 +08:00
function handleAnnotationContextMenu(event: MouseEvent, trackIndex?: number) {
if (!props.annotationsVisible) return
event.preventDefault()
event.stopPropagation()
const context = resolveAnnotationCandidates(event, trackIndex)
if (!context || !context.candidates.length) {
return
}
const nearby = nearbyCandidates(context.candidates)
const initial = nearby[0] ?? context.candidates[0]
beginCreate(initial, context.editorAnchor, context.candidates)
}
function handleExistingAnnotationContextMenu(annotationId: string, event: MouseEvent) {
if (!props.annotationsVisible || !container.value) return
editorSeriesOptions.value = []
const annotation = props.annotations.find((item) => item.id === annotationId)
if (!annotation) return
const bounds = container.value.getBoundingClientRect()
const editorAnchor = {
x: Math.max(0, Math.min(event.clientX - bounds.left, chartWidth.value)),
2026-07-20 10:21:30 +08:00
y: Math.max(0, Math.min(event.clientY - bounds.top, chartHeight.value)),
}
annotationInteraction.openContextMenu({
annotationId,
x: Math.max(4, Math.min(event.clientX - bounds.left, chartWidth.value - 120)),
2026-07-20 10:21:30 +08:00
y: Math.max(4, Math.min(event.clientY - bounds.top, chartHeight.value - 110)),
editorAnchor,
})
}
function editContextAnnotation() {
const context = annotationInteraction.contextMenu.value
const annotationId = context?.annotationId
const annotation = props.annotations.find((item) => item.id === annotationId)
if (annotation) {
const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === annotation.seriesId),
)
2026-07-20 10:21:30 +08:00
editorSeriesOptions.value = track
? findAnnotationSeriesCandidates(
annotationLayoutsForTrack(track),
2026-07-20 10:21:30 +08:00
annotation.x,
track.xScale(annotation.x),
track.top + resolveSeriesYScale(track, annotation.seriesId)(annotation.y),
2026-07-20 10:21:30 +08:00
)
: []
annotationInteraction.openEdit(
annotation,
context?.editorAnchor ?? resolveAnnotationEditorAnchor(annotation),
)
}
}
function deleteContextAnnotation() {
const annotationId = annotationInteraction.contextMenu.value?.annotationId
const annotation = props.annotations.find((item) => item.id === annotationId)
if (!annotation) return
emit(
'update:annotations',
props.annotations.filter((item) => item.id !== annotation.id),
)
emit('annotation-delete', annotation)
annotationInteraction.closeContextMenu()
}
function confirmAnnotation(annotation: WaveformAnnotation) {
const draft = annotationInteraction.editorDraft.value
if (!draft) return
if (draft.mode === 'add') {
emit('update:annotations', [...props.annotations, annotation])
emit('annotation-create', annotation)
} else {
emit(
'update:annotations',
props.annotations.map((item) => (item.id === annotation.id ? annotation : item)),
)
emit('annotation-update', annotation, draft.previous)
}
cancelAnnotation()
}
function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
const overlay = event.currentTarget as SVGRectElement | null
if (!overlay) return
2026-07-20 10:21:30 +08:00
const [pointerX, pointerY] = pointer(event, overlay)
scheduleHover(() => {
const track = trackLayouts.value[trackIndex]
if (!track) return
const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
const nextPoints = track.seriesList.flatMap((series) => {
const point = nearestPoint(series, xValue)
return point ? [{ ...series, trackIndex, point }] : []
})
commitHover(nextPoints, trackIndex, {
x: resolvedChartLeftMargin.value + track.left + pointerX,
y: titleAreaHeight.value + margin.top + track.top + pointerY,
})
})
2026-07-20 10:21:30 +08:00
}
function handleSharedPointerMove(event: PointerEvent) {
if (!sharedOverlayElement.value || !trackLayouts.value.length) return
const [pointerX, pointerY] = pointer(event, sharedOverlayElement.value)
scheduleHover(() => {
const referenceTrack = resolveTrackAtPointer(pointerX, pointerY) ?? trackLayouts.value[0]
if (!referenceTrack) return
const localPointerX = Math.max(
0,
Math.min(referenceTrack.width, pointerX - referenceTrack.left),
)
const xValue = referenceTrack.xScale.invert(localPointerX)
const nextPoints = trackLayouts.value.flatMap((track) =>
track.seriesList.flatMap((series) => {
const point = nearestPoint(series, xValue)
return point ? [{ ...series, trackIndex: track.index, point }] : []
}),
)
commitHover(nextPoints, null, {
x: resolvedChartLeftMargin.value + pointerX,
y: titleAreaHeight.value + margin.top + pointerY,
})
})
2026-07-20 10:21:30 +08:00
}
function resetViewport() {
cancelPendingZoom()
2026-07-20 10:21:30 +08:00
sharedTransform.value = zoomIdentity
independentTransforms.value = chartTracks.value.map(() => zoomIdentity)
2026-07-20 10:21:30 +08:00
clearHover()
editorSeriesOptions.value = []
void nextTick(configureZoom)
}
function goToPage(page: number) {
const nextPage = Math.min(pageCount.value, Math.max(1, Math.floor(page)))
if (nextPage === currentPage.value) return
currentPage.value = nextPage
clearHover()
annotationInteraction.closeContextMenu()
cancelAnnotation()
if (props.displayMode === 'independent') {
independentTransforms.value = pagedTracks.value.map(() => zoomIdentity)
2026-07-20 10:21:30 +08:00
}
void nextTick(configureZoom)
emit('page-change', nextPage, pageCount.value)
}
watch(
[
innerWidth,
innerHeight,
() => props.zoomable,
() => props.displayMode,
() => chartTracks.value.length,
2026-07-20 10:21:30 +08:00
() => currentPage.value,
() => gridOptions.value.rowCount,
() => gridOptions.value.columnCount,
activeInteractionMode,
],
async () => {
await nextTick()
configureZoom()
},
{ immediate: true },
)
function handleDataReferenceChange() {
const previousPage = currentPage.value
currentPage.value = 1
resetViewport()
if (previousPage !== 1) emit('page-change', 1, pageCount.value)
}
watch(
() => props.displayMode,
() => {
const previousPage = currentPage.value
currentPage.value = 1
resetViewport()
if (previousPage !== 1) emit('page-change', 1, pageCount.value)
},
)
watch([pageCount, () => props.grid?.rowCount, () => props.grid?.columnCount], () => {
const previousPage = currentPage.value
if (currentPage.value > pageCount.value) {
currentPage.value = pageCount.value
} else if (currentPage.value !== 1) {
currentPage.value = 1
}
if (previousPage !== currentPage.value) {
emit('page-change', currentPage.value, pageCount.value)
}
clearHover()
void nextTick(configureZoom)
})
watch(activeInteractionMode, () => {
editorSeriesOptions.value = []
})
watch(
() => chartSeries.value.map((series) => series.id).join('\u0000'),
() => {
if (props.hiddenSeriesIds !== undefined) return
const availableIds = new Set(chartSeries.value.map((series) => series.id))
const retainedIds = new Set(
Array.from(internalHiddenSeriesIds.value).filter((seriesId) => availableIds.has(seriesId)),
)
if (
retainedIds.size !== internalHiddenSeriesIds.value.size ||
Array.from(retainedIds).some((seriesId) => !internalHiddenSeriesIds.value.has(seriesId))
) {
internalHiddenSeriesIds.value = retainedIds
}
},
{ immediate: true },
)
watch(
() =>
chartTracks.value
.flatMap((track) => track.visibleSeries.map((series) => series.id))
.join('\u0000'),
() => {
clearHover()
editorSeriesOptions.value = []
const draftSeriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
if (draftSeriesId && hiddenSeriesIdSet.value.has(draftSeriesId)) {
annotationInteraction.closeEditor()
}
const contextAnnotationId = annotationInteraction.contextMenu.value?.annotationId
const contextAnnotation = props.annotations.find((item) => item.id === contextAnnotationId)
if (contextAnnotation && hiddenSeriesIdSet.value.has(contextAnnotation.seriesId)) {
annotationInteraction.closeContextMenu()
}
void nextTick(configureZoom)
},
)
2026-07-20 10:21:30 +08:00
watch(
() => props.annotationsVisible,
(visible) => {
if (!visible) {
annotationInteraction.closeContextMenu()
cancelAnnotation()
}
},
)
watch(
() => props.annotations,
(annotations) => {
const menuId = annotationInteraction.contextMenu.value?.annotationId
if (menuId && !annotations.some((item) => item.id === menuId)) {
annotationInteraction.closeContextMenu()
}
const draft = annotationInteraction.editorDraft.value
if (draft?.mode === 'edit' && !annotations.some((item) => item.id === draft.annotation.id)) {
annotationInteraction.closeEditor()
}
},
{ deep: true },
)
function measureTitle() {
if (!titleVisible.value || !titleMeasureElement.value) {
measuredTitleWidth.value = 0
measuredTitleHeight.value = 0
return
}
const bounds = titleMeasureElement.value.getBoundingClientRect()
measuredTitleWidth.value = titleMeasureElement.value.scrollWidth || bounds.width
measuredTitleHeight.value = titleMeasureElement.value.scrollHeight || bounds.height
}
watch(
[resolvedTitleText, titleVisible, titleMeasureStyle],
async () => {
measuredTitleWidth.value = 0
measuredTitleHeight.value = 0
await nextTick()
measureTitle()
},
{ immediate: true },
)
2026-07-20 10:21:30 +08:00
onMounted(() => {
if (!container.value) return
resizeObserver.value = new ResizeObserver(([entry]) => {
observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0)
void nextTick(measureTitle)
2026-07-20 10:21:30 +08:00
})
resizeObserver.value.observe(container.value)
})
onBeforeUnmount(() => {
cancelPendingHover()
2026-07-20 10:21:30 +08:00
resizeObserver.value?.disconnect()
clearZoomBindings()
editorSeriesOptions.value = []
})
</script>
<template>
<div
ref="container"
class="waveform-chart"
:class="[
`waveform-chart--${displayMode}`,
`waveform-chart--interaction-${activeInteractionMode}`,
]"
:style="containerStyle"
2026-07-20 10:21:30 +08:00
:data-display-mode="displayMode"
:data-interaction-mode="activeInteractionMode"
:data-overlay-mode="overlayMode"
:data-chart-left-margin="resolvedChartLeftMargin"
:data-title-area-height="titleAreaHeight"
@contextmenu.capture="handleNativeContextMenu"
2026-07-20 10:21:30 +08:00
>
<div
v-if="titleVisible"
class="waveform-chart__title-area"
:style="titleAreaStyle"
role="heading"
aria-level="2"
>
<span
ref="titleMeasureElement"
class="waveform-chart__title-measure"
:style="titleMeasureStyle"
aria-hidden="true"
>
{{ resolvedTitleText }}
</span>
<span class="waveform-chart__title-visual" :style="titleVisualStyle">
<span
class="waveform-chart__title-text"
:style="titleTextStyle"
:data-title-scale="titleLayout.scale"
:data-title-wrapped="titleLayout.wrapped || undefined"
>
{{ resolvedTitleText }}
</span>
</span>
</div>
2026-07-20 10:21:30 +08:00
<svg
ref="svgElement"
class="waveform-chart__svg"
:width="chartWidth"
:height="drawingHeight"
2026-07-20 10:21:30 +08:00
role="img"
:aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'"
>
<defs>
<clipPath
v-for="track in trackLayouts"
:id="`${clipPathId}-${track.index}`"
:key="`${clipPathId}-${track.index}`"
clipPathUnits="userSpaceOnUse"
>
<rect :width="track.width" :height="track.height" />
</clipPath>
</defs>
<g :transform="`translate(${resolvedChartLeftMargin}, ${margin.top})`">
<g v-if="displayMode !== 'compact'" class="waveform-chart__grid-slots" aria-hidden="true">
2026-07-20 10:21:30 +08:00
<g
v-for="cell in gridCells"
:key="`grid-slot-${cell.slotIndex}`"
:transform="`translate(${cell.left}, ${cell.top})`"
>
<rect
v-if="!cell.series"
class="waveform-chart__grid-slot-placeholder"
:width="cell.width"
:height="cell.cellHeight"
/>
</g>
</g>
<rect
v-if="displayMode !== 'independent' && trackLayouts.length && hasVisibleWaveformData"
ref="sharedOverlayElement"
class="waveform-chart__overlay waveform-chart__overlay--shared"
:class="{
'is-zoomable': zoomable && isZoomMode,
'is-annotating': activeInteractionMode === 'annotation',
}"
:width="innerWidth"
:height="innerHeight"
@pointermove="handleSharedPointerMove"
@pointerleave="clearHover"
@click="handleAnnotationClick"
@contextmenu="handleAnnotationContextMenu"
/>
2026-07-20 10:21:30 +08:00
<!-- 轨道渲染 -->
<WaveformTrack
v-for="track in trackLayouts"
:key="`${track.index}-${track.series.name}`"
:track="track"
:clip-path-id="clipPathId"
:inner-width="innerWidth"
:show-tooltip="showTooltip"
:zoomable="zoomable"
:display-mode="displayMode"
:interaction-mode="activeInteractionMode"
:frame-number="resolveFrameNumber(track.index)"
:frame-style="frameStyle"
2026-07-20 10:21:30 +08:00
:time-unit="timeUnit"
:y-label="yLabel"
:legend-position="legendPosition"
:legend-orientation="legendOrientation"
:legend-background-color="legendBackgroundColor"
:legend-interactive="legendInteractive"
:hidden-series-ids="resolvedHiddenSeriesIds"
2026-07-20 10:21:30 +08:00
: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"
2026-07-20 10:21:30 +08:00
/>
<WaveformAnnotationLayer
:annotations="renderedAnnotations"
:visible="annotationsVisible"
@contextmenu="handleExistingAnnotationContextMenu"
/>
<text
v-if="resolvedXLabel"
class="waveform-chart__label"
:x="innerWidth / 2"
:y="xAxisTitleY"
text-anchor="middle"
>
{{ resolvedXLabel }}
</text>
</g>
<text
v-if="hasChartArea && !hasWaveformData"
class="waveform-chart__empty"
:x="chartWidth / 2"
:y="drawingHeight / 2"
2026-07-20 10:21:30 +08:00
text-anchor="middle"
>
暂无有效波形数据
</text>
</svg>
<Pagination
v-if="gridOptions.showPagination && pageCount > 1"
class="waveform-chart__pagination"
aria-label="波形分页"
:current="currentPage"
:page-size="getPageSize(gridOptions)"
:total="chartTracks.length"
2026-07-20 10:21:30 +08:00
:show-size-changer="false"
:show-quick-jumper="false"
@change="goToPage"
/>
<WaveformAnnotationToolbar
v-if="showAnnotationToolbar"
:interaction-mode="activeInteractionMode"
:annotations-visible="annotationsVisible"
@update:interaction-mode="setInteractionMode"
@update:annotations-visible="setAnnotationsVisible"
/>
<WaveformAnnotationEditor
v-if="annotationInteraction.editorDraft.value"
:annotation="annotationInteraction.editorDraft.value.annotation"
:mode="annotationInteraction.editorDraft.value.mode"
:series="editorSeries"
:series-options="editorSeriesOptions"
:time-unit="timeUnit"
@confirm="confirmAnnotation"
@cancel="cancelAnnotation"
@series-change="changeDraftSeries"
/>
<WaveformAnnotationContextMenu
:visible="annotationInteraction.contextMenu.value !== null"
:x="annotationInteraction.contextMenu.value?.x || 0"
:y="annotationInteraction.contextMenu.value?.y || 0"
:can-edit="Boolean(annotationInteraction.contextMenu.value?.annotationId)"
@edit="editContextAnnotation"
@delete="deleteContextAnnotation"
@close="annotationInteraction.closeContextMenu"
/>
<!-- Tooltip -->
<WaveformTooltip
:visible="showTooltip && hoveredPoint !== null"
:position="hoverPosition"
:time-unit="timeUnit"
:hovered-point="hoveredPoint"
:series-points="tooltipSeriesPoints"
:container-width="chartWidth"
2026-07-20 10:21:30 +08:00
:container-height="chartHeight"
/>
</div>
</template>
<style scoped>
.waveform-chart {
box-sizing: border-box;
2026-07-20 10:21:30 +08:00
position: relative;
min-width: 0;
min-height: 180px;
2026-07-20 10:21:30 +08:00
overflow: hidden;
color: #475467;
background: #fff;
border: 1px solid rgb(0 0 0 / 8%);
border-radius: 6px;
}
.waveform-chart__pagination {
position: absolute;
right: 10px;
bottom: 6px;
z-index: 2;
}
.waveform-chart__title-area {
position: relative;
display: flex;
width: 100%;
min-width: 0;
align-items: center;
padding: 0 24px;
overflow: hidden;
background: #fff;
}
.waveform-chart__title-measure {
position: absolute;
display: block;
visibility: hidden;
pointer-events: none;
}
.waveform-chart__title-visual {
position: relative;
flex: 0 0 auto;
min-width: 0;
overflow: visible;
}
.waveform-chart__title-text {
position: absolute;
top: 50%;
left: 50%;
display: block;
overflow: visible;
line-height: 1.2;
transform-origin: center;
}
2026-07-20 10:21:30 +08:00
.waveform-chart__pagination :deep(.ant-pagination-item),
.waveform-chart__pagination :deep(.ant-pagination-prev .ant-pagination-item-link),
.waveform-chart__pagination :deep(.ant-pagination-next .ant-pagination-item-link) {
background: #fff;
border-color: #d9d9d9;
}
.waveform-chart__pagination :deep(.ant-pagination-item-active) {
border-color: #1677ff;
}
.waveform-chart__grid-slot-placeholder {
fill: #fafbfc;
stroke: #e4e7ec;
stroke-dasharray: 4 4;
pointer-events: none;
}
.waveform-chart__svg {
display: block;
max-width: 100%;
}
.waveform-chart__overlay {
fill: transparent;
cursor: crosshair;
touch-action: none;
}
.waveform-chart__overlay.is-zoomable {
cursor: grab;
}
.waveform-chart__overlay.is-zoomable:active {
cursor: grabbing;
}
.waveform-chart__overlay.is-annotating {
cursor: crosshair;
}
.waveform-chart__label,
.waveform-chart__empty {
fill: #595959;
font-size: 12px;
}
</style>