refactor(chart): enforce 400-line source limit
Some checks failed
Package component / package (push) Failing after 4m19s

This commit is contained in:
liqiyuan
2026-07-27 15:05:13 +08:00
parent dcc27a88be
commit 082462cf67
61 changed files with 9682 additions and 8407 deletions

View File

@@ -1,32 +1,8 @@
import {
curveStep,
curveStepAfter,
curveStepBefore,
line,
scaleLinear,
zoomIdentity,
type ZoomTransform,
} from 'd3'
import { scaleLinear } from 'd3'
import {
selectSeriesRenderPoints,
type ResolvedWaveformRenderingOptions,
} from '../../core/rendering'
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
import {
buildMinorTicks,
formatAxisTimeExponent,
formatEndpointTime,
formatScientificAxisExponent,
formatScientificAxisLabel,
paddedDomain,
} from '../../utils'
import {
getBottomRowCellIndexes,
type GridCellGeometry,
type NormalizedWaveformGridOptions,
} from './grid'
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
import type { WaveformOverlayMode } from '../../types'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
// 导出常量供外部使用
@@ -96,7 +72,7 @@ export function buildYAxisSeriesGroups(
return grouped
}
function axisTextMetrics(domain: [number, number]): {
export function axisTextMetrics(domain: [number, number]): {
exponentLabel: string | null
exponentWidth: number
tickTextWidth: number
@@ -157,10 +133,6 @@ export function measureTrackYAxisClearance(
)
}
interface SeriesGridCell extends GridCellGeometry {
series?: DisplayTrack
}
type PositionedTrack = Pick<TrackLayout, 'left' | 'top' | 'width' | 'height'>
export function findClosestTrackAtPointer<T extends PositionedTrack>(
@@ -204,210 +176,4 @@ export function findClosestTrackAtPointer<T extends PositionedTrack>(
return closestTrack
}
export interface BuildTrackLayoutsOptions {
cells: SeriesGridCell[]
grid: NormalizedWaveformGridOptions
displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode
independentTransforms: ZoomTransform[]
sharedZoomDomain: [number, number]
initialXDomain?: [number, number]
initialXDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms'
rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean
yAxisLabelX: number
showCompactEmptyTracks: boolean
}
export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) }))
const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
return visibleCells.flatMap((cell, index) => {
const isEmpty = !cell.series
if (isEmpty && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks)) return []
const emptySeries: DisplaySeries = {
id: `empty-grid-slot-${cell.slotIndex}`,
name: '',
color: 'transparent',
lineType: 'linear',
lineStyle: 'solid',
pointType: 'none',
errorBar: { visible: false, width: 1.5, capWidth: 8 },
points: [],
xDomain: [0, 1],
yDomain: [0, 1],
hasErrorPoints: false,
}
const displayTrack: DisplayTrack = cell.series ?? {
id: emptySeries.id,
series: [emptySeries],
visibleSeries: [emptySeries],
xDomain: emptySeries.xDomain,
yDomain: emptySeries.yDomain,
}
const hasVisibleSeries = !isEmpty && displayTrack.visibleSeries.length > 0
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
const baseXScale =
options.displayMode === 'independent'
? scaleLinear(
options.initialXDomains?.[displayTrack.id] ??
options.initialXDomains?.[series.id] ??
displayTrack.xDomain,
[0, cell.width],
)
: scaleLinear(options.sharedZoomDomain, [0, cell.width])
const transform =
options.displayMode === 'independent'
? (options.independentTransforms[index] ?? zoomIdentity)
: zoomIdentity
const xScale = transform.rescaleX(baseXScale)
const configuredYDomain = options.yDomains?.[displayTrack.id]
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode).map((group) => ({
...group,
domain: configuredYDomain ?? group.domain,
}))
const sideOffsets = { left: 0, right: 0 }
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
const scale = scaleLinear(group.domain, [cell.plotHeight, 0]).nice()
const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
const [axisStart, axisEnd] = scale.domain()
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
const visibleMajorTicks = showAxisEnd
? majorTicks
: majorTicks.filter((tick) => tick !== axisEnd)
const tickValues = Array.from(
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
)
const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics(group.domain)
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
const clearance =
tickTextWidth +
Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING
const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right
const exponentX =
x +
(group.side === 'left'
? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
: Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
const labelDistance =
tickTextWidth +
Y_AXIS_TICK_PADDING +
exponentClearance +
exponentWidth +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2
const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
sideOffsets[group.side] += clearance
return {
index: group.index,
side: group.side,
x,
labelX,
exponentX,
exponentLabel,
scale,
majorTicks,
minorTicks: buildMinorTicks(majorTicks),
tickValues,
seriesList: group.seriesList,
}
})
const yScale = yAxes[0]?.scale ?? scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0]).nice()
const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
const yMajorTicks = yAxes[0]?.majorTicks ?? []
const yAxisTickValues = yAxes[0]?.tickValues ?? []
const domain = xScale.domain() as [number, number]
const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit),
end: formatEndpointTime(domain[1], domain, options.timeUnit),
}
const xAxisExponent = formatAxisTimeExponent(domain, options.timeUnit)
const leftClearance = endpointLabels.start.length * 7 + 10
const rightClearance = endpointLabels.end.length * 7 + 10
const xAxisTickValues = xMajorTicks.filter((tick) => {
const position = xScale(tick)
return position > leftClearance && position < cell.width - rightClearance
})
const seriesPaths = displayTrack.visibleSeries.map((trackSeries) => {
const yAxis = yAxes.find((axis) =>
axis.seriesList.some((series) => series.id === trackSeries.id),
)
const seriesYScale = yAxis?.scale ?? yScale
const renderPoints = selectSeriesRenderPoints(
trackSeries.points,
domain,
cell.width,
options.rendering,
{
lineVisible: !isEmpty && trackSeries.lineType !== 'none',
pointVisible: trackSeries.pointType !== 'none',
errorBarVisible: trackSeries.errorBar.visible,
hasErrorPoints: trackSeries.hasErrorPoints,
},
)
const pathGenerator = line<WaveformPoint>()
.x((point) => xScale(point.x))
.y((point) => seriesYScale(point.y))
if (trackSeries.lineType === 'step-start') pathGenerator.curve(curveStepBefore)
if (trackSeries.lineType === 'step-middle') pathGenerator.curve(curveStep)
if (trackSeries.lineType === 'step-end' || trackSeries.lineType === 'step-after') {
pathGenerator.curve(curveStepAfter)
}
return {
series: trackSeries,
path: renderPoints.linePoints.length ? pathGenerator(renderPoints.linePoints) : null,
pointRenderPoints: renderPoints.pointRenderPoints,
errorBarRenderPoints: renderPoints.errorBarRenderPoints,
yScale: seriesYScale,
yAxisIndex: yAxis?.index ?? 0,
}
})
return {
id: displayTrack.id,
index,
series,
seriesList: displayTrack.visibleSeries,
legendSeries: displayTrack.series,
isEmpty,
hasVisibleSeries,
column: cell.column,
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
yAxisLabelX: options.yAxisLabelX,
left: cell.left,
top: cell.top,
width: cell.width,
height: cell.plotHeight,
xScale,
yScale,
yAxes,
xMajorTicks,
xMinorTicks: buildMinorTicks(xMajorTicks, 5, domain),
yMajorTicks,
yMinorTicks: yAxes[0]?.minorTicks ?? [],
yAxisTickValues,
xAxisTickValues,
endpointLabels,
xAxisExponent,
path: seriesPaths[0]?.path ?? null,
seriesPaths,
gridLines: options.grid.trackLines[displayTrack.id] ?? {
horizontal: true,
vertical: true,
},
showXAxis:
(isEmpty || hasVisibleSeries) &&
(options.displayMode === 'independent' ||
(options.displayMode === 'compact'
? cell.row === options.grid.rowCount - 1
: bottomCells.has(cell.slotIndex))),
}
})
}
export { buildTrackLayouts, type BuildTrackLayoutsOptions } from './trackLayoutBuilder'

View File

@@ -0,0 +1,242 @@
import {
curveStep,
curveStepAfter,
curveStepBefore,
line,
scaleLinear,
zoomIdentity,
type ZoomTransform,
} from 'd3'
import {
selectSeriesRenderPoints,
type ResolvedWaveformRenderingOptions,
} from '../../core/rendering'
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
import { buildMinorTicks, formatAxisTimeExponent, formatEndpointTime } from '../../utils'
import {
getBottomRowCellIndexes,
type GridCellGeometry,
type NormalizedWaveformGridOptions,
} from './grid'
import { axisTextMetrics, buildYAxisSeriesGroups } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
import { Y_AXIS_EXPONENT_GAP } from './constants'
import {
Y_AXIS_LABEL_BAND_WIDTH,
Y_AXIS_LABEL_GAP,
Y_AXIS_OUTER_PADDING,
Y_AXIS_TICK_PADDING,
} from './yAxisConstants'
interface SeriesGridCell extends GridCellGeometry {
series?: DisplayTrack
}
export interface BuildTrackLayoutsOptions {
cells: SeriesGridCell[]
grid: NormalizedWaveformGridOptions
displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode
independentTransforms: ZoomTransform[]
sharedZoomDomain: [number, number]
initialXDomain?: [number, number]
initialXDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms'
rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean
yAxisLabelX: number
showCompactEmptyTracks: boolean
}
export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) }))
const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
return visibleCells.flatMap((cell, index) => {
const isEmpty = !cell.series
if (isEmpty && (options.displayMode !== 'compact' || !options.showCompactEmptyTracks)) return []
const emptySeries: DisplaySeries = {
id: `empty-grid-slot-${cell.slotIndex}`,
name: '',
color: 'transparent',
lineType: 'linear',
lineStyle: 'solid',
pointType: 'none',
errorBar: { visible: false, width: 1.5, capWidth: 8 },
points: [],
xDomain: [0, 1],
yDomain: [0, 1],
hasErrorPoints: false,
}
const displayTrack: DisplayTrack = cell.series ?? {
id: emptySeries.id,
series: [emptySeries],
visibleSeries: [emptySeries],
xDomain: emptySeries.xDomain,
yDomain: emptySeries.yDomain,
}
const hasVisibleSeries = !isEmpty && displayTrack.visibleSeries.length > 0
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
const baseXScale =
options.displayMode === 'independent'
? scaleLinear(
options.initialXDomains?.[displayTrack.id] ??
options.initialXDomains?.[series.id] ??
displayTrack.xDomain,
[0, cell.width],
)
: scaleLinear(options.sharedZoomDomain, [0, cell.width])
const transform =
options.displayMode === 'independent'
? (options.independentTransforms[index] ?? zoomIdentity)
: zoomIdentity
const xScale = transform.rescaleX(baseXScale)
const configuredYDomain = options.yDomains?.[displayTrack.id]
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode).map((group) => ({
...group,
domain: configuredYDomain ?? group.domain,
}))
const sideOffsets = { left: 0, right: 0 }
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
const scale = scaleLinear(group.domain, [cell.plotHeight, 0]).nice()
const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
const [axisStart, axisEnd] = scale.domain()
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
const visibleMajorTicks = showAxisEnd
? majorTicks
: majorTicks.filter((tick) => tick !== axisEnd)
const tickValues = Array.from(
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
)
const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics(group.domain)
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
const clearance =
tickTextWidth +
Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING
const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right
const exponentX =
x +
(group.side === 'left'
? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
: Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
const labelDistance =
tickTextWidth +
Y_AXIS_TICK_PADDING +
exponentClearance +
exponentWidth +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2
const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
sideOffsets[group.side] += clearance
return {
index: group.index,
side: group.side,
x,
labelX,
exponentX,
exponentLabel,
scale,
majorTicks,
minorTicks: buildMinorTicks(majorTicks),
tickValues,
seriesList: group.seriesList,
}
})
const yScale = yAxes[0]?.scale ?? scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0]).nice()
const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
const yMajorTicks = yAxes[0]?.majorTicks ?? []
const yAxisTickValues = yAxes[0]?.tickValues ?? []
const domain = xScale.domain() as [number, number]
const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit),
end: formatEndpointTime(domain[1], domain, options.timeUnit),
}
const xAxisExponent = formatAxisTimeExponent(domain, options.timeUnit)
const leftClearance = endpointLabels.start.length * 7 + 10
const rightClearance = endpointLabels.end.length * 7 + 10
const xAxisTickValues = xMajorTicks.filter((tick) => {
const position = xScale(tick)
return position > leftClearance && position < cell.width - rightClearance
})
const seriesPaths = displayTrack.visibleSeries.map((trackSeries) => {
const yAxis = yAxes.find((axis) =>
axis.seriesList.some((series) => series.id === trackSeries.id),
)
const seriesYScale = yAxis?.scale ?? yScale
const renderPoints = selectSeriesRenderPoints(
trackSeries.points,
domain,
cell.width,
options.rendering,
{
lineVisible: !isEmpty && trackSeries.lineType !== 'none',
pointVisible: trackSeries.pointType !== 'none',
errorBarVisible: trackSeries.errorBar.visible,
hasErrorPoints: trackSeries.hasErrorPoints,
},
)
const pathGenerator = line<WaveformPoint>()
.x((point) => xScale(point.x))
.y((point) => seriesYScale(point.y))
if (trackSeries.lineType === 'step-start') pathGenerator.curve(curveStepBefore)
if (trackSeries.lineType === 'step-middle') pathGenerator.curve(curveStep)
if (trackSeries.lineType === 'step-end' || trackSeries.lineType === 'step-after') {
pathGenerator.curve(curveStepAfter)
}
return {
series: trackSeries,
path: renderPoints.linePoints.length ? pathGenerator(renderPoints.linePoints) : null,
pointRenderPoints: renderPoints.pointRenderPoints,
errorBarRenderPoints: renderPoints.errorBarRenderPoints,
yScale: seriesYScale,
yAxisIndex: yAxis?.index ?? 0,
}
})
return {
id: displayTrack.id,
index,
series,
seriesList: displayTrack.visibleSeries,
legendSeries: displayTrack.series,
isEmpty,
hasVisibleSeries,
column: cell.column,
showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0,
yAxisLabelX: options.yAxisLabelX,
left: cell.left,
top: cell.top,
width: cell.width,
height: cell.plotHeight,
xScale,
yScale,
yAxes,
xMajorTicks,
xMinorTicks: buildMinorTicks(xMajorTicks, 5, domain),
yMajorTicks,
yMinorTicks: yAxes[0]?.minorTicks ?? [],
yAxisTickValues,
xAxisTickValues,
endpointLabels,
xAxisExponent,
path: seriesPaths[0]?.path ?? null,
seriesPaths,
gridLines: options.grid.trackLines[displayTrack.id] ?? {
horizontal: true,
vertical: true,
},
showXAxis:
(isEmpty || hasVisibleSeries) &&
(options.displayMode === 'independent' ||
(options.displayMode === 'compact'
? cell.row === options.grid.rowCount - 1
: bottomCells.has(cell.slotIndex))),
}
})
}

View File

@@ -0,0 +1,290 @@
import { zoomIdentity, type ZoomTransform } from 'd3'
import {
reactive,
markRaw,
ref,
shallowReactive,
shallowRef,
toRefs,
watchEffect,
type ComponentPublicInstance,
type Ref,
} from 'vue'
import { useWaveformInstanceId } from '../../utils/waveformId'
import { useWaveformAnnotationInteraction, type AnnotationSeriesCandidate } from '../annotation'
import { useWaveformChartAnnotations } from '../annotation/useWaveformChartAnnotations'
import { useWaveformHover } from '../interaction/useWaveformHover'
import { useWaveformViewport } from '../interaction/useWaveformViewport'
import { useWaveformZoom } from '../interaction/useWaveformZoom'
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
import { margin } from './constants'
import { getPageSize } from './grid'
import type { WaveformHoverState } from './types'
import { useWaveformChartLifecycle } from './useWaveformChartLifecycle'
import { usePreparedWaveformSeries } from './useWaveformData'
import { useWaveformLayout } from './useWaveformLayout'
import { useWaveformPresentation } from './useWaveformPresentation'
import type {
ResolvedWaveformChartProps,
ViewportSelectionState,
WaveformChartEmit,
} from './waveformChartTypes'
function assignElement<T extends Element>(
target: Ref<T | undefined>,
element: Element | ComponentPublicInstance | null,
) {
target.value = element instanceof Element ? (element as T) : undefined
}
export function useWaveformChartController(
props: ResolvedWaveformChartProps,
emit: WaveformChartEmit,
) {
const container = ref<HTMLDivElement>()
const svgElement = ref<SVGSVGElement>()
const titleMeasureElement = ref<HTMLSpanElement>()
const sharedOverlayElement = ref<SVGRectElement>()
const observedWidth = ref(0)
const observedHeight = ref(0)
const measuredTitleWidth = ref(0)
const measuredTitleHeight = ref(0)
const paginationBandHeight = ref(0)
const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
const independentTransforms = shallowRef<ZoomTransform[]>([])
const sharedYDomains = ref<Record<string, [number, number]>>({})
const independentYDomains = ref<Record<number, [number, number]>>({})
const hoverState = shallowReactive<WaveformHoverState>({
points: [],
trackIndex: null,
position: { x: 0, y: 0 },
})
const suppressHoverUntilMove = ref(false)
const currentPage = ref(1)
const resizeObserver = shallowRef<ResizeObserver>()
const clipPathId = useWaveformInstanceId('waveform-clip')
const internalHiddenSeriesIds = ref(new Set(props.defaultHiddenSeriesIds))
const annotationInteraction = useWaveformAnnotationInteraction()
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
const hoverThrottle = useAnimationFrameThrottle()
const selection = ref<ViewportSelectionState | null>(null)
const spacePressed = ref(false)
const pointerInsideChart = ref(false)
let handleDataReferenceChange: () => void = () => undefined
const preparedSeries = usePreparedWaveformSeries(
() => props.data,
() => handleDataReferenceChange(),
)
const presentation = useWaveformPresentation({
props,
observedWidth,
observedHeight,
measuredTitleWidth,
measuredTitleHeight,
internalHiddenSeriesIds,
paginationBandHeight,
})
const {
chartWidth,
chartHeight,
isCleanView,
hiddenSeriesIdSet,
resolvedTitleText,
titleAreaReserved,
titleMeasureStyle,
titleAreaHeight,
chartTopMargin,
innerHeight,
} = presentation
const layout = useWaveformLayout({
props,
preparedSeries,
currentPage,
hiddenSeriesIdSet,
chartWidth,
innerHeight,
isCleanView,
sharedTransform,
independentTransforms,
sharedYDomains,
independentYDomains,
annotationInteraction: markRaw(annotationInteraction),
})
const {
chartSeries,
chartTracks,
gridOptions,
pageCount,
pagedTracks,
resolvedChartLeftMargin,
innerWidth,
hasChartArea,
activeInteractionMode,
isZoomMode,
initialXDomain,
sharedZoomDomain,
resolveInitialTrackDomain,
trackLayouts,
annotationLayoutsForTrack,
resolveSeriesYScale,
} = layout
watchEffect(() => {
paginationBandHeight.value =
gridOptions.value.showPagination && pageCount.value > 1 && chartWidth.value <= 520 ? 40 : 0
})
const zoom = useWaveformZoom({
props,
emit,
svgElement,
sharedOverlayElement,
sharedTransform,
independentTransforms,
trackLayouts,
initialXDomain,
sharedZoomDomain,
innerWidth,
innerHeight,
hasChartArea,
isZoomMode,
resolveInitialTrackDomain,
cancelPendingHover: () => hover.cancelPendingHover(),
})
const annotations = useWaveformChartAnnotations({
props,
emit,
container,
chartSeries,
hiddenSeriesIdSet,
internalHiddenSeriesIds,
annotationInteraction,
editorSeriesOptions,
trackLayouts,
annotationLayoutsForTrack,
resolveSeriesYScale,
chartWidth,
chartHeight,
innerWidth,
resolvedChartLeftMargin,
titleAreaHeight,
chartTopMargin,
activeInteractionMode,
})
const viewport = useWaveformViewport({
props,
emit,
selection,
spacePressed,
trackLayouts,
chartTracks,
initialXDomain,
innerWidth,
innerHeight,
sharedOverlayElement,
sharedTransform,
independentTransforms,
sharedYDomains,
independentYDomains,
isZoomMode,
editorSeriesOptions,
resolveInitialTrackDomain,
canZoomTrack: zoom.canZoomTrack,
canZoomSharedTracks: zoom.canZoomSharedTracks,
configureZoom: zoom.configureZoom,
cancelPendingZoom: zoom.cancelPendingZoom,
clearHover: () => hover.clearHover(),
resolveTrackAtPointer: annotations.resolveTrackAtPointer,
})
const hover = useWaveformHover({
emit,
hoverState,
suppressHoverUntilMove,
hoverThrottle,
selection,
trackLayouts,
innerWidth,
resolvedChartLeftMargin,
titleAreaHeight,
chartTopMargin,
sharedOverlayElement,
updateViewportDrag: viewport.updateViewportDrag,
resolveTrackAtPointer: annotations.resolveTrackAtPointer,
})
const lifecycle = useWaveformChartLifecycle({
props,
emit,
container,
titleMeasureElement,
resizeObserver,
observedWidth,
observedHeight,
measuredTitleWidth,
measuredTitleHeight,
resolvedTitleText,
titleAreaReserved,
titleMeasureStyle,
spacePressed,
pointerInsideChart,
currentPage,
pageCount,
pagedTracks,
gridOptions,
chartSeries,
chartTracks,
innerWidth,
innerHeight,
activeInteractionMode,
hiddenSeriesIdSet,
internalHiddenSeriesIds,
independentTransforms,
independentYDomains,
annotationInteraction,
editorSeriesOptions,
clearHover: hover.clearHover,
cancelAnnotation: annotations.cancelAnnotation,
configureZoom: zoom.configureZoom,
resetViewport: viewport.resetViewport,
cancelPendingHover: hover.cancelPendingHover,
clearZoomBindings: zoom.clearZoomBindings,
})
handleDataReferenceChange = lifecycle.handleDataReferenceChange
return reactive({
...toRefs(props),
...presentation,
...layout,
...annotations,
...viewport,
...hover,
margin,
getPageSize,
currentPage,
selection,
pointerInsideChart,
hoverState,
annotationInteraction,
editorDraft: annotationInteraction.editorDraft,
contextMenu: annotationInteraction.contextMenu,
editorSeriesOptions,
clipPathId,
goToPage: lifecycle.goToPage,
setContainer: (element: Element | ComponentPublicInstance | null) =>
assignElement(container, element),
setSvgElement: (element: Element | ComponentPublicInstance | null) =>
assignElement(svgElement, element),
setTitleMeasureElement: (element: Element | ComponentPublicInstance | null) =>
assignElement(titleMeasureElement, element),
setSharedOverlayElement: (element: Element | ComponentPublicInstance | null) =>
assignElement(sharedOverlayElement, element),
})
}
export type WaveformChartController = ReturnType<typeof useWaveformChartController>

View File

@@ -0,0 +1,306 @@
import { zoomIdentity, type ZoomTransform } from 'd3'
import {
nextTick,
onBeforeUnmount,
onMounted,
watch,
type ComputedRef,
type Ref,
type ShallowRef,
} from 'vue'
import type { AnnotationSeriesCandidate } from '../annotation'
import type { useWaveformAnnotationInteraction } from '../annotation'
import type { DisplaySeries, DisplayTrack } from './types'
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
interface LifecycleContext {
props: ResolvedWaveformChartProps
emit: WaveformChartEmit
container: Ref<HTMLDivElement | undefined>
titleMeasureElement: Ref<HTMLSpanElement | undefined>
resizeObserver: ShallowRef<ResizeObserver | undefined>
observedWidth: Ref<number>
observedHeight: Ref<number>
measuredTitleWidth: Ref<number>
measuredTitleHeight: Ref<number>
resolvedTitleText: ComputedRef<string>
titleAreaReserved: ComputedRef<boolean>
titleMeasureStyle: ComputedRef<object>
spacePressed: Ref<boolean>
pointerInsideChart: Ref<boolean>
currentPage: Ref<number>
pageCount: ComputedRef<number>
pagedTracks: ComputedRef<Array<DisplayTrack | undefined>>
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
chartSeries: ComputedRef<DisplaySeries[]>
chartTracks: ComputedRef<DisplayTrack[]>
innerWidth: ComputedRef<number>
innerHeight: ComputedRef<number>
activeInteractionMode: ComputedRef<string | undefined>
hiddenSeriesIdSet: ComputedRef<Set<string>>
internalHiddenSeriesIds: Ref<Set<string>>
independentTransforms: ShallowRef<ZoomTransform[]>
independentYDomains: Ref<Record<number, [number, number]>>
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
clearHover: () => void
cancelAnnotation: () => void
configureZoom: () => void
resetViewport: () => void
cancelPendingHover: () => void
clearZoomBindings: () => void
}
function isEditableTarget(target: EventTarget | null): boolean {
return (
target instanceof Element &&
Boolean(
target.closest(
'button, input, select, textarea, [contenteditable]:not([contenteditable="false"])',
),
)
)
}
export function useWaveformChartLifecycle(context: LifecycleContext) {
const {
props,
emit,
container,
titleMeasureElement,
resizeObserver,
observedWidth,
observedHeight,
measuredTitleWidth,
measuredTitleHeight,
resolvedTitleText,
titleAreaReserved,
titleMeasureStyle,
spacePressed,
pointerInsideChart,
currentPage,
pageCount,
pagedTracks,
gridOptions,
chartSeries,
chartTracks,
innerWidth,
innerHeight,
activeInteractionMode,
hiddenSeriesIdSet,
internalHiddenSeriesIds,
independentTransforms,
independentYDomains,
annotationInteraction,
editorSeriesOptions,
clearHover,
cancelAnnotation,
configureZoom,
resetViewport,
cancelPendingHover,
clearZoomBindings,
} = context
function handleInteractionKeyDown(event: KeyboardEvent) {
if (event.code !== 'Space' || !props.pannable || !pointerInsideChart.value) return
if (isEditableTarget(event.target)) return
spacePressed.value = true
event.preventDefault()
}
function handleInteractionKeyUp(event: KeyboardEvent) {
if (event.code === 'Space') spacePressed.value = false
}
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)
independentYDomains.value = {}
}
void nextTick(configureZoom)
emit('page-change', nextPage, pageCount.value)
}
function handleDataReferenceChange() {
if (props.displayMode === 'independent') {
const currentTransforms = independentTransforms.value
independentTransforms.value = chartTracks.value.map(
(_track, index) => currentTransforms[index] ?? zoomIdentity,
)
}
clearHover()
editorSeriesOptions.value = []
void nextTick(configureZoom)
}
watch(
[
innerWidth,
innerHeight,
() => props.zoomable,
() => props.minZoomSpan,
() => props.initialXDomain,
() => props.initialXDomains,
() => props.displayMode,
() => chartTracks.value.length,
currentPage,
() => gridOptions.value.rowCount,
() => gridOptions.value.columnCount,
activeInteractionMode,
],
async () => {
await nextTick()
configureZoom()
},
{ immediate: true },
)
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
currentPage.value =
currentPage.value > pageCount.value
? pageCount.value
: currentPage.value !== 1
? 1
: currentPage.value
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(internalHiddenSeriesIds.value).some((seriesId) => !retainedIds.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 &&
(!chartSeries.value.some((series) => series.id === 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)
},
)
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 (!titleAreaReserved.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, titleAreaReserved, titleMeasureStyle],
async () => {
measuredTitleWidth.value = 0
measuredTitleHeight.value = 0
await nextTick()
measureTitle()
},
{ immediate: true },
)
onMounted(() => {
window.addEventListener('keydown', handleInteractionKeyDown)
window.addEventListener('keyup', handleInteractionKeyUp)
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)
})
resizeObserver.value.observe(container.value)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleInteractionKeyDown)
window.removeEventListener('keyup', handleInteractionKeyUp)
cancelPendingHover()
resizeObserver.value?.disconnect()
clearZoomBindings()
editorSeriesOptions.value = []
})
return { goToPage, handleDataReferenceChange }
}

View File

@@ -0,0 +1,372 @@
import { scaleLinear, type ZoomTransform } from 'd3'
import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { resolveWaveformRenderingOptions } from '../../core'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
import {
layoutAnnotations,
type AnnotationSeriesInfo,
type AnnotationTrackLayout,
} from '../annotation'
import {
channelColors,
margin,
MINIMUM_PLOT_WIDTH,
Y_AXIS_CHARACTER_WIDTH,
Y_AXIS_LABEL_BAND_WIDTH,
Y_AXIS_LABEL_GAP,
Y_AXIS_OUTER_PADDING,
Y_AXIS_TICK_PADDING,
} from './constants'
import {
getGridGap,
getPageCount,
normalizeGridOptions,
paginateSeries,
resolveGridCellGeometry,
X_AXIS_BAND,
} from './grid'
import { buildTrackLayouts, measureTrackYAxisClearance, Y_AXIS_EXPONENT_GAP } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import type { PreparedWaveformSeries } from './useWaveformData'
import type { ResolvedWaveformChartProps } from './waveformChartTypes'
import type { useWaveformAnnotationInteraction } from '../annotation'
interface LayoutContext {
props: ResolvedWaveformChartProps
preparedSeries: ShallowRef<PreparedWaveformSeries[]>
currentPage: Ref<number>
hiddenSeriesIdSet: ComputedRef<Set<string>>
chartWidth: ComputedRef<number>
innerHeight: ComputedRef<number>
isCleanView: ComputedRef<boolean>
sharedTransform: ShallowRef<ZoomTransform>
independentTransforms: ShallowRef<ZoomTransform[]>
sharedYDomains: Ref<Record<string, [number, number]>>
independentYDomains: Ref<Record<number, [number, number]>>
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
}
export function useWaveformLayout(context: LayoutContext) {
const {
props,
preparedSeries,
currentPage,
hiddenSeriesIdSet,
chartWidth,
innerHeight,
isCleanView,
sharedTransform,
independentTransforms,
sharedYDomains,
independentYDomains,
annotationInteraction,
} = context
const chartSeries = computed<DisplaySeries[]>(() =>
preparedSeries.value.map((series, index): 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))
const xDomainValues: number[] = []
const yDomainValues: number[] = []
visibleSeries.forEach((item) => {
xDomainValues.push(item.xDomain[0], item.xDomain[1])
yDomainValues.push(item.yDomain[0], item.yDomain[1])
})
return {
id,
series,
visibleSeries,
xDomain: paddedDomain(xDomainValues),
yDomain: paddedDomain(yDomainValues),
}
})
})
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),
)
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()
return {
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
tickLabels: scale
.ticks(10)
.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
}
})
const maximumCharacters = Math.max(
1,
...axisText.flatMap(({ tickLabels }) => tickLabels).map((label) => label.length),
)
const tickTextWidth = maximumCharacters * Y_AXIS_CHARACTER_WIDTH
const maximumExponentWidth = Math.max(
0,
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * Y_AXIS_CHARACTER_WIDTH),
)
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP : 0
const tickClearance =
tickTextWidth + Y_AXIS_TICK_PADDING + exponentClearance + Y_AXIS_OUTER_PADDING
const labelCenterX = -(
Y_AXIS_TICK_PADDING +
tickTextWidth +
exponentClearance +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2
)
const fullClearance =
tickTextWidth +
Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING
return { tickClearance, fullClearance, labelCenterX }
})
const hasYAxisLabels = computed(() =>
chartTracks.value.some(
(track) =>
track.visibleSeries.length === 1 &&
Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel),
),
)
const hasVisibleWaveformData = computed(() =>
chartTracks.value.some((track) => track.visibleSeries.length > 0),
)
const chartLeftMargin = computed(() =>
Math.max(
margin.left,
hasYAxisLabels.value
? yAxisMetrics.value.fullClearance
: hasVisibleWaveformData.value
? 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),
)
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 plotWidth = (innerWidth.value - fullGap * Math.max(0, columnCount - 1)) / columnCount
const canReserveLabelClearance = plotWidth >= MINIMUM_PLOT_WIDTH
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,
}
})
const hasWaveformData = computed(() => chartSeries.value.length > 0)
const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0)
const resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit}`)
const activeInteractionMode = computed(() => props.interactionMode)
const isZoomMode = computed(
() => activeInteractionMode.value === 'zoom' || activeInteractionMode.value === undefined,
)
const sharedXDomain = computed(() => {
const values: number[] = []
chartTracks.value.forEach((track) => {
if (track.visibleSeries.length) values.push(track.xDomain[0], track.xDomain[1])
})
return paddedDomain(values)
})
const initialXDomain = computed<[number, number]>(() => {
const domain = props.initialXDomain
if (
domain &&
Number.isFinite(domain[0]) &&
Number.isFinite(domain[1]) &&
domain[0] !== domain[1]
) {
return domain[0] < domain[1] ? domain : [domain[1], domain[0]]
}
return sharedXDomain.value
})
const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => {
const configuredDomain =
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
props.initialXDomains?.[track.series.id] ??
props.initialXDomain
if (
configuredDomain &&
Number.isFinite(configuredDomain[0]) &&
Number.isFinite(configuredDomain[1]) &&
configuredDomain[0] !== configuredDomain[1]
) {
return configuredDomain[0] < configuredDomain[1]
? configuredDomain
: [configuredDomain[1], configuredDomain[0]]
}
return paddedDomain(track.seriesList.flatMap((series) => series.xDomain))
}
const sharedZoomDomain = computed(
() =>
sharedTransform.value
.rescaleX(scaleLinear(initialXDomain.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),
yAxisLayout.value.horizontalGap,
)
return cells.map((cell, index) => ({ ...cell, series: pagedTracks.value[index] }))
})
const trackLayouts = computed<TrackLayout[]>(() =>
buildTrackLayouts({
cells: gridCells.value,
grid: gridOptions.value,
displayMode: props.displayMode,
overlayMode: props.overlayMode,
independentTransforms: independentTransforms.value,
sharedZoomDomain: sharedZoomDomain.value,
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
initialXDomains: props.initialXDomains,
yDomains:
props.displayMode === 'independent'
? Object.fromEntries(
chartTracks.value.flatMap((track, index) => {
const domain = independentYDomains.value[index]
return domain ? [[track.id, domain]] : []
}),
)
: sharedYDomains.value,
timeUnit: props.timeUnit,
rendering: renderingOptions.value,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX,
showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
}),
)
const annotationLayoutsForTrack = (track: TrackLayout): AnnotationTrackLayout[] =>
track.seriesList.map((series) => ({
...track,
series,
yScale:
track.seriesPaths.find((seriesPath) => seriesPath.series.id === series.id)?.yScale ??
track.yScale,
}))
const resolveSeriesYScale = (track: TrackLayout, seriesId: string) =>
track.seriesPaths.find((seriesPath) => seriesPath.series.id === seriesId)?.yScale ??
track.yScale
const annotationTrackLayouts = computed<AnnotationTrackLayout[]>(() =>
trackLayouts.value.flatMap(annotationLayoutsForTrack),
)
const renderedAnnotations = computed(() =>
props.annotationsVisible
? layoutAnnotations(
props.annotations,
annotationTrackLayouts.value,
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
})
const resolveFrameNumber = (trackIndex: number): string | number | undefined => {
if (props.frameNumber === undefined || props.frameNumber === null) return undefined
if (chartTracks.value.length === 1) return props.frameNumber
return typeof props.frameNumber === 'number'
? props.frameNumber + trackIndex
: `${props.frameNumber}-${trackIndex + 1}`
}
return {
chartSeries,
chartTracks,
gridOptions,
pageCount,
pagedTracks,
resolvedChartLeftMargin,
innerWidth,
hasVisibleWaveformData,
hasWaveformData,
hasChartArea,
resolvedXLabel,
activeInteractionMode,
isZoomMode,
initialXDomain,
sharedZoomDomain,
resolveInitialTrackDomain,
gridCells,
trackLayouts,
annotationLayoutsForTrack,
resolveSeriesYScale,
annotationTrackLayouts,
renderedAnnotations,
xAxisTitleY,
editorSeries,
resolveFrameNumber,
}
}

View File

@@ -0,0 +1,201 @@
import { computed, type CSSProperties, type Ref } from 'vue'
import type { WaveformLegendOrientation, WaveformLegendPosition } from '../data/types'
import {
margin,
minimumHeight,
TITLE_CHAR_WIDTH_RATIO,
TITLE_DEFAULT_FONT_SIZE,
TITLE_LINE_HEIGHT,
ZERO_LINE_DEFAULTS,
} from './constants'
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
import type { ResolvedWaveformChartProps } from './waveformChartTypes'
interface PresentationContext {
props: ResolvedWaveformChartProps
observedWidth: Ref<number>
observedHeight: Ref<number>
measuredTitleWidth: Ref<number>
measuredTitleHeight: Ref<number>
internalHiddenSeriesIds: Ref<Set<string>>
paginationBandHeight: Ref<number>
}
export function useWaveformPresentation(context: PresentationContext) {
const {
props,
observedWidth,
observedHeight,
measuredTitleWidth,
measuredTitleHeight,
internalHiddenSeriesIds,
paginationBandHeight,
} = context
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),
)
const chartHeight = computed(() =>
observedHeight.value > 0 ? observedHeight.value : (fixedHeight.value ?? minimumHeight),
)
const containerStyle = computed(() => ({
width: fixedWidth.value === undefined ? '100%' : `${fixedWidth.value}px`,
height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`,
}))
const isCleanView = computed(() => props.cleanView === true)
const resolvedZeroLine = computed(() => {
const width = props.zeroLine.width
return {
visible: props.zeroLine.visible === true,
color: props.zeroLine.color || ZERO_LINE_DEFAULTS.COLOR,
width:
typeof width === 'number' && Number.isFinite(width) && width > 0
? width
: ZERO_LINE_DEFAULTS.WIDTH,
dash: props.zeroLine.dash ?? ZERO_LINE_DEFAULTS.DASH,
}
})
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 resolveLegendPosition = (trackId: string): WaveformLegendPosition =>
props.legend.trackPositions?.[trackId] ?? props.legend.position ?? 'top-right'
const resolveLegendOrientation = (
position: WaveformLegendPosition,
): Exclude<WaveformLegendOrientation, 'auto'> => {
const orientation = props.legend.orientation ?? 'auto'
if (orientation !== 'auto') return orientation
return position === 'top' || position === 'bottom' ? 'horizontal' : 'vertical'
}
const resolvedTitleText = computed(() => props.title?.text.trim() ?? '')
const titleAreaReserved = computed(
() =>
Boolean(props.title) && props.title?.visible !== false && resolvedTitleText.value.length > 0,
)
const titleVisible = computed(() => titleAreaReserved.value && !isCleanView.value)
const titleFontSize = computed(() => {
const fontSize = props.title?.textStyle?.fontSize
return Number.isFinite(fontSize) && (fontSize ?? 0) > 0
? (fontSize as number)
: TITLE_DEFAULT_FONT_SIZE
})
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: String(TITLE_LINE_HEIGHT),
}))
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 * TITLE_CHAR_WIDTH_RATIO + spacingWidth,
)
})
const titleAvailableWidth = computed(() => {
const availableWidth = chartWidth.value - TITLE_AREA_HORIZONTAL_PADDING * 2
return availableWidth > 0 ? availableWidth : 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 * TITLE_LINE_HEIGHT,
availableWidth: titleAvailableWidth.value,
rotation: titleRotation.value,
}),
)
const titleAreaHeight = computed(() =>
titleAreaReserved.value ? titleLayout.value.areaHeight : 0,
)
const chartTopMargin = computed(() => margin.top)
const drawingHeight = computed(() =>
Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.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})`,
}))
return {
fixedWidth,
fixedHeight,
chartWidth,
chartHeight,
containerStyle,
isCleanView,
resolvedZeroLine,
legendBackgroundColor,
legendInteractive,
hiddenSeriesIdSet,
resolvedHiddenSeriesIds,
resolveLegendPosition,
resolveLegendOrientation,
resolvedTitleText,
titleAreaReserved,
titleVisible,
titleMeasureStyle,
titleLayout,
titleAreaHeight,
chartTopMargin,
drawingHeight,
innerHeight,
titleAreaStyle,
titleVisualStyle,
titleTextStyle,
}
}

View File

@@ -0,0 +1,103 @@
import type {
WaveformAnnotation,
WaveformAxesOptions,
WaveformData,
WaveformDisplayMode,
WaveformFrameStyle,
WaveformInteractionMode,
WaveformLegendOptions,
WaveformOverlayMode,
WaveformPoint,
WaveformRenderingOptions,
WaveformTitleOptions,
WaveformZeroLineOptions,
WaveformZoomEndPayload,
} from '../data/types'
import type { WaveformGridOptions } from './grid'
export interface WaveformChartProps {
data: WaveformData
displayMode?: WaveformDisplayMode
overlayMode?: WaveformOverlayMode
width?: number
height?: number
xLabel?: string
yLabel?: string
lineColor?: string
showTooltip?: boolean
zoomable?: boolean
pannable?: boolean
minZoomSpan?: number
minVisiblePoints?: number
initialXDomain?: [number, number]
initialXDomains?: Record<string, [number, number]>
timeUnit?: 's' | 'ms'
frameNumber?: string | number
frameStyle?: WaveformFrameStyle
axes?: WaveformAxesOptions
annotations?: WaveformAnnotation[]
annotationsVisible?: boolean
interactionMode?: WaveformInteractionMode
grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions
title?: WaveformTitleOptions
legend?: WaveformLegendOptions
hiddenSeriesIds?: string[]
defaultHiddenSeriesIds?: string[]
cleanView?: boolean
zeroLine?: WaveformZeroLineOptions
}
type DefaultedProp =
| 'displayMode'
| 'overlayMode'
| 'yLabel'
| 'lineColor'
| 'showTooltip'
| 'zoomable'
| 'pannable'
| 'minVisiblePoints'
| 'timeUnit'
| 'annotations'
| 'annotationsVisible'
| 'grid'
| 'rendering'
| 'legend'
| 'defaultHiddenSeriesIds'
| 'cleanView'
| 'zeroLine'
export type ResolvedWaveformChartProps = Readonly<
WaveformChartProps & Required<Pick<WaveformChartProps, DefaultedProp>>
>
export interface WaveformChartEmit {
(event: 'point-hover', point: WaveformPoint | null): void
(event: 'zoom-change', domain: [number, number]): void
(event: 'zoom-end', payload: WaveformZoomEndPayload): void
(event: 'zoom-reset'): void
(event: 'update:annotations', annotations: WaveformAnnotation[]): void
(event: 'update:hidden-series-ids', ids: string[]): void
(
event: 'series-visibility-change',
payload: { seriesId: string; visible: boolean; hiddenSeriesIds: string[] },
): void
(event: 'annotation-create', annotation: WaveformAnnotation): void
(event: 'annotation-update', annotation: WaveformAnnotation, previous: WaveformAnnotation): void
(event: 'annotation-delete', annotation: WaveformAnnotation): void
(event: 'page-change', page: number, pageCount: number): void
}
export interface ViewportSelectionState {
trackIndex: number
independent: boolean
overlay: SVGRectElement
startX: number
startY: number
currentX: number
currentY: number
pointerId: number
mode: 'box' | 'pan'
xDomain: [number, number]
yDomains: Record<string, [number, number]>
}

View File

@@ -0,0 +1,12 @@
export const Y_AXIS_CHARACTER_WIDTH = 7
export const Y_AXIS_TICK_PADDING = 7
export const Y_AXIS_OUTER_PADDING = 4
export const Y_AXIS_LABEL_GAP = 6
export const Y_AXIS_LABEL_BAND_WIDTH = 24
export function resolveYAxisSides(axisCount: number): Array<'left' | 'right'> {
if (axisCount >= 4) return ['left', 'left', 'right', 'right']
if (axisCount === 3) return ['left', 'right', 'right']
if (axisCount === 2) return ['left', 'right']
return ['left']
}