refactor(chart): enforce 400-line source limit
Some checks failed
Package component / package (push) Failing after 4m19s
Some checks failed
Package component / package (push) Failing after 4m19s
This commit is contained in:
@@ -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'
|
||||
|
||||
242
src/components/core/trackLayoutBuilder.ts
Normal file
242
src/components/core/trackLayoutBuilder.ts
Normal 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))),
|
||||
}
|
||||
})
|
||||
}
|
||||
290
src/components/core/useWaveformChartController.ts
Normal file
290
src/components/core/useWaveformChartController.ts
Normal 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>
|
||||
306
src/components/core/useWaveformChartLifecycle.ts
Normal file
306
src/components/core/useWaveformChartLifecycle.ts
Normal 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 }
|
||||
}
|
||||
372
src/components/core/useWaveformLayout.ts
Normal file
372
src/components/core/useWaveformLayout.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
201
src/components/core/useWaveformPresentation.ts
Normal file
201
src/components/core/useWaveformPresentation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
103
src/components/core/waveformChartTypes.ts
Normal file
103
src/components/core/waveformChartTypes.ts
Normal 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]>
|
||||
}
|
||||
12
src/components/core/yAxisConstants.ts
Normal file
12
src/components/core/yAxisConstants.ts
Normal 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']
|
||||
}
|
||||
Reference in New Issue
Block a user