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, resolveYAxisSeriesGroups, 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 currentPage: Ref hiddenSeriesIdSet: ComputedRef> chartWidth: ComputedRef innerHeight: ComputedRef isCleanView: ComputedRef sharedTransform: ShallowRef independentTransforms: ShallowRef sharedYDomains: Ref> independentYDomains: Ref> annotationInteraction: ReturnType } export function useWaveformLayout(context: LayoutContext) { const { props, preparedSeries, currentPage, hiddenSeriesIdSet, chartWidth, innerHeight, isCleanView, sharedTransform, independentTransforms, sharedYDomains, independentYDomains, annotationInteraction, } = context const chartSeries = computed(() => preparedSeries.value.map((series, index): DisplaySeries => ({ ...series, color: series.color ?? (index === 0 ? props.lineColor : channelColors[index % channelColors.length]), })), ) const chartTracks = computed(() => { const groupedSeries = new Map() 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) .flatMap((track) => resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains), ) .map((group) => { const scale = scaleLinear(group.domain, [1, 0]) if (!group.fixed) scale.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, props.yDomain, props.yDomains, ) 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(() => 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, fixedYDomain: props.yDomain, fixedYDomains: props.yDomains, 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(() => 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(() => { 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, } }