feat(chart): add series styling and visibility controls
This commit is contained in:
@@ -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 {
|
||||
|
||||
117
src/components/rendering/WaveformSeriesLayer.vue
Normal file
117
src/components/rendering/WaveformSeriesLayer.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as WaveformTrack } from './WaveformTrack.vue'
|
||||
export { default as WaveformLegend } from './WaveformLegend.vue'
|
||||
export { waveformPointSymbolPath } from './seriesStyle'
|
||||
|
||||
98
src/components/rendering/seriesStyle.ts
Normal file
98
src/components/rendering/seriesStyle.ts
Normal 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('')
|
||||
}
|
||||
Reference in New Issue
Block a user