feat(chart): add configurable chart presentation
This commit is contained in:
152
src/components/rendering/WaveformLegend.vue
Normal file
152
src/components/rendering/WaveformLegend.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import type { WaveformLegendPosition } from '../../types'
|
||||
import type { DisplaySeries } from '../core/types'
|
||||
|
||||
interface Props {
|
||||
series: DisplaySeries[]
|
||||
position: WaveformLegendPosition
|
||||
orientation: 'horizontal' | 'vertical'
|
||||
backgroundColor: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<foreignObject
|
||||
class="waveform-legend waveform-chart__legend"
|
||||
x="0"
|
||||
y="0"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:data-position="position"
|
||||
:data-orientation="orientation"
|
||||
aria-label="曲线图例"
|
||||
>
|
||||
<div
|
||||
xmlns="http://www.w3.org/1999/xhtml"
|
||||
class="waveform-legend__viewport"
|
||||
:class="`waveform-legend__viewport--${position}`"
|
||||
>
|
||||
<div
|
||||
class="waveform-legend__panel"
|
||||
:class="`waveform-legend__panel--${orientation}`"
|
||||
:style="{ backgroundColor }"
|
||||
role="list"
|
||||
>
|
||||
<div
|
||||
v-for="item in series"
|
||||
:key="item.id"
|
||||
class="waveform-legend__item waveform-chart__legend-item"
|
||||
role="listitem"
|
||||
>
|
||||
<i class="waveform-legend__swatch" :style="{ backgroundColor: item.color }" />
|
||||
<span class="waveform-legend__label" :title="item.name">{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</foreignObject>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.waveform-legend {
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport--top-left {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport--top {
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport--top-right {
|
||||
align-items: flex-start;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport--right {
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport--bottom-right {
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport--bottom {
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport--bottom-left {
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.waveform-legend__viewport--left {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.waveform-legend__panel {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
gap: 5px 12px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
padding: 5px 7px;
|
||||
overflow: hidden;
|
||||
color: #344054;
|
||||
font: 12px/1.35 sans-serif;
|
||||
border: 1px solid rgb(208 213 221 / 90%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.waveform-legend__panel--horizontal {
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.waveform-legend__panel--vertical {
|
||||
flex-flow: column nowrap;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.waveform-legend__item {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 160px;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.waveform-legend__swatch {
|
||||
flex: 0 0 18px;
|
||||
width: 18px;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.waveform-legend__label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { axisBottom, axisLeft, select } from 'd3'
|
||||
import { formatAxisTime, formatScientificYAxisLabel } from '../../utils'
|
||||
import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types'
|
||||
import type { WaveformFrameStyle } from '../../types'
|
||||
import type {
|
||||
WaveformDisplayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformLegendPosition,
|
||||
} from '../data/types'
|
||||
import type { DisplaySeries, HoveredSeriesPoint, TrackLayout } from '../core/types'
|
||||
import WaveformLegend from './WaveformLegend.vue'
|
||||
|
||||
interface Props {
|
||||
/** 轨道布局信息 */
|
||||
@@ -22,12 +28,20 @@ interface Props {
|
||||
interactionMode?: WaveformInteractionMode
|
||||
/** 帧编号 */
|
||||
frameNumber?: string | number
|
||||
/** 图框样式 */
|
||||
frameStyle?: WaveformFrameStyle
|
||||
/** 时间单位 */
|
||||
timeUnit: 's' | 'ms'
|
||||
/** 悬浮点(用于显示十字线) */
|
||||
hoveredPoint?: HoveredSeriesPoint
|
||||
/** Y 轴标签回退值 */
|
||||
yLabel?: string
|
||||
/** 多曲线图例位置 */
|
||||
legendPosition?: WaveformLegendPosition
|
||||
/** 多曲线图例排列方向 */
|
||||
legendOrientation?: 'horizontal' | 'vertical'
|
||||
/** 多曲线图例背景颜色 */
|
||||
legendBackgroundColor?: string
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -39,11 +53,26 @@ interface Emits {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
interactionMode: 'zoom',
|
||||
legendPosition: 'top-right',
|
||||
legendOrientation: 'vertical',
|
||||
legendBackgroundColor: 'rgba(255, 255, 255, 0.7)',
|
||||
})
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const xAxisElement = ref<SVGGElement>()
|
||||
const yAxisElement = ref<SVGGElement>()
|
||||
const resolvedFrameStyle = computed(() => {
|
||||
const borderWidth = props.frameStyle?.borderWidth
|
||||
return {
|
||||
borderColor: props.frameStyle?.borderColor || '#1f2937',
|
||||
borderWidth:
|
||||
typeof borderWidth === 'number' && Number.isFinite(borderWidth) && borderWidth >= 0
|
||||
? borderWidth
|
||||
: 1,
|
||||
borderStyle: props.frameStyle?.borderStyle === 'dashed' ? 'dashed' : 'solid',
|
||||
backgroundColor: props.frameStyle?.backgroundColor || 'transparent',
|
||||
}
|
||||
})
|
||||
|
||||
function resolveYAxisLabel(series: DisplaySeries): string {
|
||||
return series.name.trim() || props.yLabel || ''
|
||||
@@ -155,6 +184,15 @@ watch(
|
||||
:data-track-height="track.height"
|
||||
:transform="`translate(${track.left ?? 0}, ${track.top})`"
|
||||
>
|
||||
<rect
|
||||
v-if="!track.isEmpty"
|
||||
class="waveform-track__plot-background waveform-chart__plot-background"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
:fill="resolvedFrameStyle.backgroundColor"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- 网格和背景 -->
|
||||
<g v-if="!track.isEmpty" :clip-path="`url(#${clipPathId}-${track.index})`" aria-hidden="true">
|
||||
<g
|
||||
@@ -259,6 +297,7 @@ watch(
|
||||
<g
|
||||
v-if="
|
||||
!track.isEmpty &&
|
||||
track.seriesList.length === 1 &&
|
||||
track.showYAxisLabel &&
|
||||
resolveYAxisLabel(track.series) &&
|
||||
shouldShowYAxisLabel(track.height, track.index)
|
||||
@@ -289,18 +328,35 @@ watch(
|
||||
class="waveform-track__plot-frame waveform-chart__plot-frame"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
fill="none"
|
||||
:stroke="resolvedFrameStyle.borderColor"
|
||||
:stroke-width="resolvedFrameStyle.borderWidth"
|
||||
:stroke-dasharray="resolvedFrameStyle.borderStyle === 'dashed' ? '6 4' : undefined"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- 波形线 -->
|
||||
<path
|
||||
v-if="!track.isEmpty"
|
||||
class="waveform-track__line waveform-chart__line"
|
||||
:data-series-id="track.series.id"
|
||||
:data-series-name="track.series.name || undefined"
|
||||
:d="track.path ?? undefined"
|
||||
:stroke="track.series.color"
|
||||
:clip-path="`url(#${clipPathId}-${track.index})`"
|
||||
<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"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<!-- 十字线 -->
|
||||
@@ -372,9 +428,10 @@ watch(
|
||||
}
|
||||
|
||||
.waveform-track__plot-frame {
|
||||
fill: none;
|
||||
stroke: #1f2937;
|
||||
stroke-width: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-track__plot-background {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { default as WaveformTrack } from './WaveformTrack.vue'
|
||||
export { default as WaveformLegend } from './WaveformLegend.vue'
|
||||
|
||||
Reference in New Issue
Block a user