feat(chart): add series styling and visibility controls

This commit is contained in:
李启源
2026-07-20 17:57:52 +08:00
parent c9e4dae25f
commit efb685646a
31 changed files with 2356 additions and 209 deletions

View File

@@ -1,6 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { WaveformLegendPosition } from '../../types'
import type { DisplaySeries } from '../core/types'
import {
waveformLegendErrorBarPath,
waveformLegendLinePath,
waveformPointSymbolPath,
} from './seriesStyle'
interface Props {
series: DisplaySeries[]
@@ -9,9 +16,26 @@ interface Props {
backgroundColor: string
width: number
height: number
interactive?: boolean
hiddenSeriesIds?: string[]
}
defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
interactive: false,
hiddenSeriesIds: () => [],
})
const emit = defineEmits<{
toggle: [seriesId: string]
}>()
const hiddenSeriesIdSet = computed(() => new Set(props.hiddenSeriesIds))
function isHidden(seriesId: string): boolean {
return hiddenSeriesIdSet.value.has(seriesId)
}
function toggleSeries(seriesId: string) {
if (props.interactive) emit('toggle', seriesId)
}
</script>
<template>
@@ -32,19 +56,62 @@ defineProps<Props>()
>
<div
class="waveform-legend__panel"
:class="`waveform-legend__panel--${orientation}`"
:class="[
`waveform-legend__panel--${orientation}`,
{ 'waveform-legend__panel--interactive': interactive },
]"
:style="{ backgroundColor }"
role="list"
>
<div
<button
v-for="item in series"
:key="item.id"
class="waveform-legend__item waveform-chart__legend-item"
:class="{ 'is-hidden': isHidden(item.id) }"
type="button"
role="listitem"
:disabled="!interactive"
:aria-pressed="interactive ? !isHidden(item.id) : undefined"
:aria-label="
interactive ? `${isHidden(item.id) ? '显示' : '隐藏'}曲线 ${item.name}` : undefined
"
@click.stop="toggleSeries(item.id)"
>
<i class="waveform-legend__swatch" :style="{ backgroundColor: item.color }" />
<svg
class="waveform-legend__swatch"
viewBox="0 0 26 16"
aria-hidden="true"
:data-line-type="item.lineType"
:data-point-type="item.pointType"
:data-error-bar-visible="item.errorBar.visible || undefined"
>
<path
v-if="waveformLegendLinePath(item.lineType)"
class="waveform-legend__line"
:d="waveformLegendLinePath(item.lineType) ?? undefined"
:stroke="item.color"
stroke-width="1.5"
fill="none"
/>
<path
v-if="item.errorBar.visible"
class="waveform-legend__error-bar"
:d="waveformLegendErrorBarPath(item.errorBar.capWidth)"
:stroke="item.errorBar.color || item.color"
:stroke-width="item.errorBar.width"
stroke-linecap="butt"
fill="none"
/>
<path
v-if="item.pointType !== 'none'"
class="waveform-legend__point"
:d="waveformPointSymbolPath(item.pointType, 30) ?? undefined"
:fill="item.color"
transform="translate(13 8)"
/>
</svg>
<span class="waveform-legend__label" :title="item.name">{{ item.name }}</span>
</div>
</button>
</div>
</div>
</foreignObject>
@@ -119,6 +186,10 @@ defineProps<Props>()
border-radius: 4px;
}
.waveform-legend__panel--interactive {
pointer-events: auto;
}
.waveform-legend__panel--horizontal {
flex-flow: row wrap;
align-items: center;
@@ -135,13 +206,44 @@ defineProps<Props>()
max-width: 160px;
align-items: center;
gap: 6px;
padding: 0;
color: inherit;
font: inherit;
text-align: left;
white-space: nowrap;
appearance: none;
background: none;
border: 0;
}
.waveform-legend__item:disabled {
opacity: 1;
}
.waveform-legend__panel--interactive .waveform-legend__item {
cursor: pointer;
}
.waveform-legend__panel--interactive .waveform-legend__item:focus-visible {
outline: 2px solid #1677ff;
outline-offset: 2px;
}
.waveform-legend__item.is-hidden {
opacity: 0.45;
}
.waveform-legend__item.is-hidden .waveform-legend__label {
text-decoration: line-through;
}
.waveform-legend__swatch {
flex: 0 0 18px;
width: 18px;
height: 2px;
flex: 0 0 26px;
width: 26px;
height: 16px;
overflow: visible;
stroke-linecap: round;
stroke-linejoin: round;
}
.waveform-legend__label {

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed } from 'vue'
import { resolveWaveformPointErrors } from '../../core'
import type { TrackLayout, TrackSeriesPath } from '../core/types'
import { waveformPointSeriesPath } from './seriesStyle'
const props = defineProps<{
track: TrackLayout
clipPathId: string
}>()
interface RenderedSeriesPath extends TrackSeriesPath {
pointPath: string | null
errorBarPath: string | null
}
const renderedSeriesPaths = computed<RenderedSeriesPath[]>(() =>
props.track.seriesPaths.map((seriesPath) => {
const pointPath = waveformPointSeriesPath(
seriesPath.series.pointType,
seriesPath.pointRenderPoints.map((point) => ({
x: props.track.xScale(point.x),
y: seriesPath.yScale(point.y),
})),
)
const capHalfWidth = seriesPath.series.errorBar.capWidth / 2
const errorBarPath = seriesPath.errorBarRenderPoints
.map((point) => {
const { lower, upper } = resolveWaveformPointErrors(point)
const x = props.track.xScale(point.x)
const lowerY = seriesPath.yScale(point.y - lower)
const upperY = seriesPath.yScale(point.y + upper)
return [
`M${x - capHalfWidth},${lowerY}H${x + capHalfWidth}`,
`M${x},${lowerY}V${upperY}`,
`M${x - capHalfWidth},${upperY}H${x + capHalfWidth}`,
].join('')
})
.join('')
return { ...seriesPath, pointPath, errorBarPath: errorBarPath || null }
}),
)
</script>
<template>
<g
v-if="!track.isEmpty && track.hasVisibleSeries"
class="waveform-track__series"
:clip-path="`url(#${clipPathId}-${track.index})`"
>
<g
v-for="seriesPath in renderedSeriesPaths"
:key="seriesPath.series.id"
class="waveform-track__series-item waveform-chart__series"
:data-series-id="seriesPath.series.id"
:data-series-name="seriesPath.series.name || undefined"
>
<path
v-if="seriesPath.path"
class="waveform-track__line waveform-chart__line"
:data-series-id="seriesPath.series.id"
:data-series-name="seriesPath.series.name || undefined"
:data-y-axis-index="seriesPath.yAxisIndex"
:data-line-type="seriesPath.series.lineType"
:d="seriesPath.path"
:stroke="seriesPath.series.color"
/>
<g
v-if="seriesPath.series.errorBar.visible"
class="waveform-track__error-bars waveform-chart__error-bars"
:data-series-id="seriesPath.series.id"
>
<path
v-if="seriesPath.errorBarPath"
class="waveform-track__error-bar waveform-chart__error-bar"
:d="seriesPath.errorBarPath"
:stroke="seriesPath.series.errorBar.color || seriesPath.series.color"
:stroke-width="seriesPath.series.errorBar.width"
/>
</g>
<g
v-if="seriesPath.series.pointType !== 'none'"
class="waveform-track__points waveform-chart__points"
:data-series-id="seriesPath.series.id"
:data-point-type="seriesPath.series.pointType"
>
<path
v-if="seriesPath.pointPath"
class="waveform-track__point waveform-chart__point"
:d="seriesPath.pointPath"
:fill="seriesPath.series.color"
/>
</g>
</g>
</g>
</template>
<style scoped>
.waveform-track__line {
fill: none;
stroke-width: 1.5;
stroke-linejoin: round;
stroke-linecap: round;
}
.waveform-track__error-bar {
fill: none;
}
.waveform-track__point,
.waveform-track__error-bar {
pointer-events: none;
}
</style>

View File

@@ -15,6 +15,7 @@ import type {
WaveformYAxisLayout,
} from '../core/types'
import WaveformLegend from './WaveformLegend.vue'
import WaveformSeriesLayer from './WaveformSeriesLayer.vue'
interface Props {
/** 轨道布局信息 */
@@ -47,6 +48,10 @@ interface Props {
legendOrientation?: 'horizontal' | 'vertical'
/** 多曲线图例背景颜色 */
legendBackgroundColor?: string
/** 图例是否允许切换曲线显隐 */
legendInteractive?: boolean
/** 当前隐藏的系列 ID */
hiddenSeriesIds?: string[]
}
interface Emits {
@@ -54,6 +59,7 @@ interface Emits {
(e: 'pointer-leave'): void
(e: 'click', event: MouseEvent): void
(e: 'contextmenu', event: MouseEvent): void
(e: 'series-visibility-toggle', seriesId: string): void
}
const props = withDefaults(defineProps<Props>(), {
@@ -61,6 +67,8 @@ const props = withDefaults(defineProps<Props>(), {
legendPosition: 'top-right',
legendOrientation: 'vertical',
legendBackgroundColor: 'rgba(255, 255, 255, 0.7)',
legendInteractive: false,
hiddenSeriesIds: () => [],
})
const emit = defineEmits<Emits>()
@@ -92,14 +100,6 @@ function setYAxisElement(element: unknown, index: number) {
if (element) yAxisElements.value[index] = element as SVGGElement
}
function resolveHoveredYScale() {
const seriesId = props.hoveredPoint?.id
return (
props.track.seriesPaths.find((seriesPath) => seriesPath.series.id === seriesId)?.yScale ??
props.track.yScale
)
}
/**
* 判断是否应该显示 Y 轴标签
* 在紧凑模式下,当轨道高度太小时隐藏标签避免重叠
@@ -121,12 +121,6 @@ function crosshairX(): number {
: 0
}
function crosshairY(): number {
return props.hoveredPoint && props.hoveredPoint.trackIndex === props.track.index
? resolveHoveredYScale()(props.hoveredPoint.point.y)
: 0
}
function hasCrosshair(): boolean {
return (
props.showTooltip &&
@@ -213,7 +207,11 @@ watch(
/>
<!-- 网格和背景 -->
<g v-if="!track.isEmpty" :clip-path="`url(#${clipPathId}-${track.index})`" aria-hidden="true">
<g
v-if="!track.isEmpty && track.hasVisibleSeries"
:clip-path="`url(#${clipPathId}-${track.index})`"
aria-hidden="true"
>
<g
class="waveform-track__grid waveform-track__grid--minor waveform-chart__grid waveform-chart__grid--minor"
>
@@ -258,7 +256,7 @@ watch(
<!-- 帧编号水印 -->
<text
v-if="!track.isEmpty && frameNumber !== undefined"
v-if="!track.isEmpty && track.hasVisibleSeries && frameNumber !== undefined"
class="waveform-track__watermark waveform-chart__watermark"
:x="(track.width ?? innerWidth) / 2"
:y="track.height / 2"
@@ -344,6 +342,7 @@ watch(
<g
v-if="
!track.isEmpty &&
track.hasVisibleSeries &&
track.seriesList.length === 1 &&
track.showYAxisLabel &&
resolveYAxisLabel(track.series) &&
@@ -407,45 +406,21 @@ watch(
aria-hidden="true"
/>
<!-- 波形线 -->
<g v-if="!track.isEmpty" class="waveform-track__lines">
<path
v-for="seriesPath in track.seriesPaths"
:key="seriesPath.series.id"
class="waveform-track__line waveform-chart__line"
:data-series-id="seriesPath.series.id"
:data-series-name="seriesPath.series.name || undefined"
:data-y-axis-index="seriesPath.yAxisIndex"
:d="seriesPath.path ?? undefined"
:stroke="seriesPath.series.color"
:clip-path="`url(#${clipPathId}-${track.index})`"
/>
</g>
<WaveformLegend
v-if="!track.isEmpty && track.seriesList.length > 1"
:series="track.seriesList"
:position="legendPosition"
:orientation="legendOrientation"
:background-color="legendBackgroundColor"
:width="track.width ?? innerWidth"
:height="track.height"
/>
<!-- 波形系列隔离在静态子组件中,避免 hover 更新遍历大量 SVG 节点。 -->
<WaveformSeriesLayer :track="track" :clip-path-id="clipPathId" />
<!-- 十字线 -->
<g
v-if="!track.isEmpty && hasCrosshair()"
v-if="!track.isEmpty && track.hasVisibleSeries && hasCrosshair()"
class="waveform-track__crosshair waveform-chart__crosshair"
:clip-path="`url(#${clipPathId}-${track.index})`"
>
<line :x1="crosshairX()" :x2="crosshairX()" y1="0" :y2="track.height" />
<line x1="0" :x2="track.width ?? innerWidth" :y1="crosshairY()" :y2="crosshairY()" />
<circle :cx="crosshairX()" :cy="crosshairY()" r="4" :fill="track.series.color" />
</g>
<!-- 交互覆盖层(仅在独立模式下) -->
<rect
v-if="!track.isEmpty && displayMode === 'independent'"
v-if="!track.isEmpty && track.hasVisibleSeries && displayMode === 'independent'"
class="waveform-track__overlay waveform-track__overlay--independent waveform-chart__overlay waveform-chart__overlay--independent"
:class="{
'is-zoomable': zoomable && interactionMode === 'zoom',
@@ -459,19 +434,37 @@ watch(
@click="emit('click', $event)"
@contextmenu="emit('contextmenu', $event)"
/>
<text
v-if="!track.isEmpty && !track.hasVisibleSeries"
class="waveform-track__no-visible-series"
:x="(track.width ?? innerWidth) / 2"
:y="track.height / 2"
text-anchor="middle"
dominant-baseline="central"
>
暂无可见曲线
</text>
<WaveformLegend
v-if="!track.isEmpty && track.legendSeries.length > 1"
:series="track.legendSeries"
:position="legendPosition"
:orientation="legendOrientation"
:background-color="legendBackgroundColor"
:interactive="legendInteractive"
:hidden-series-ids="hiddenSeriesIds"
:width="track.width ?? innerWidth"
:height="track.height"
@toggle="emit('series-visibility-toggle', $event)"
/>
</g>
</template>
<style scoped>
.waveform-track {
isolation: isolate;
}
.waveform-track__line {
fill: none;
stroke-width: 1.5;
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
}
.waveform-track__y-axis-label-bg {
@@ -517,9 +510,16 @@ watch(
.waveform-track__overlay {
fill: transparent;
cursor: crosshair;
pointer-events: all;
touch-action: none;
}
.waveform-track__no-visible-series {
fill: #8c8c8c;
font: 13px sans-serif;
pointer-events: none;
}
.waveform-track__overlay.is-zoomable {
cursor: grab;
}
@@ -542,11 +542,6 @@ watch(
stroke-dasharray: 4 3;
}
.waveform-track__crosshair circle {
stroke: #fff;
stroke-width: 2;
}
.waveform-track__axis-endpoint {
fill: #667085;
font-size: 11px;

View File

@@ -1,2 +1,3 @@
export { default as WaveformTrack } from './WaveformTrack.vue'
export { default as WaveformLegend } from './WaveformLegend.vue'
export { waveformPointSymbolPath } from './seriesStyle'

View File

@@ -0,0 +1,98 @@
import {
symbol,
symbolCircle,
symbolDiamond,
symbolSquare,
symbolTriangle,
type SymbolType,
} from 'd3'
import type { WaveformLineType, WaveformPointType } from '../../types'
const LEGEND_SWATCH_CENTER_X = 13
const LEGEND_ERROR_BAR_TOP = 2
const LEGEND_ERROR_BAR_BOTTOM = 14
const LEGEND_ERROR_BAR_DEFAULT_CAP_WIDTH = 8
const LEGEND_ERROR_BAR_MAX_CAP_WIDTH = 24
const pointSymbols: Record<Exclude<WaveformPointType, 'none'>, SymbolType> = {
circle: symbolCircle,
square: symbolSquare,
triangle: symbolTriangle,
diamond: symbolDiamond,
}
export function waveformPointSymbolPath(pointType: WaveformPointType, size = 48): string | null {
if (pointType === 'none') return null
return symbol().type(pointSymbols[pointType]).size(size)() ?? null
}
export function waveformPointSeriesPath(
pointType: WaveformPointType,
points: ReadonlyArray<{ x: number; y: number }>,
size = 48,
): string | null {
if (pointType === 'none' || points.length === 0) return null
if (pointType === 'circle') {
const radius = Math.sqrt(size / Math.PI)
return points
.map(
({ x, y }) =>
`M${x + radius},${y}A${radius},${radius},0,1,1,${x - radius},${y}` +
`A${radius},${radius},0,1,1,${x + radius},${y}`,
)
.join('')
}
if (pointType === 'square') {
const side = Math.sqrt(size)
const halfSide = side / 2
return points
.map(({ x, y }) => `M${x - halfSide},${y - halfSide}h${side}v${side}h${-side}Z`)
.join('')
}
if (pointType === 'triangle') {
const topOffset = Math.sqrt(size / ((Math.sqrt(3) * 3) / 4))
const halfWidth = (topOffset * Math.sqrt(3)) / 2
const bottomOffset = topOffset / 2
return points
.map(
({ x, y }) =>
`M${x},${y - topOffset}L${x + halfWidth},${y + bottomOffset}` +
`L${x - halfWidth},${y + bottomOffset}Z`,
)
.join('')
}
const verticalOffset = Math.sqrt(size / (2 * Math.tan(Math.PI / 6)))
const horizontalOffset = verticalOffset * Math.tan(Math.PI / 6)
return points
.map(
({ x, y }) =>
`M${x},${y - verticalOffset}L${x + horizontalOffset},${y}` +
`L${x},${y + verticalOffset}L${x - horizontalOffset},${y}Z`,
)
.join('')
}
export function waveformLegendLinePath(lineType: WaveformLineType): string | null {
if (lineType === 'none') return null
return 'M1 8H25'
}
export function waveformLegendErrorBarPath(capWidth: number): string {
const resolvedCapWidth =
Number.isFinite(capWidth) && capWidth > 0
? Math.min(capWidth, LEGEND_ERROR_BAR_MAX_CAP_WIDTH)
: LEGEND_ERROR_BAR_DEFAULT_CAP_WIDTH
const capHalfWidth = resolvedCapWidth / 2
const capStart = LEGEND_SWATCH_CENTER_X - capHalfWidth
const capEnd = LEGEND_SWATCH_CENTER_X + capHalfWidth
return [
`M${capStart} ${LEGEND_ERROR_BAR_TOP}H${capEnd}`,
`M${LEGEND_SWATCH_CENTER_X} ${LEGEND_ERROR_BAR_TOP}V${LEGEND_ERROR_BAR_BOTTOM}`,
`M${capStart} ${LEGEND_ERROR_BAR_BOTTOM}H${capEnd}`,
].join('')
}