|
|
@@ -0,0 +1,254 @@
|
|
|
+<script setup lang="ts">
|
|
|
+import * as echarts from 'echarts'
|
|
|
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
+
|
|
|
+import type { ChartSeries } from '../types'
|
|
|
+
|
|
|
+interface RefLine {
|
|
|
+ value: number
|
|
|
+ label?: string
|
|
|
+}
|
|
|
+
|
|
|
+const props = withDefaults(
|
|
|
+ defineProps<{
|
|
|
+ series: ChartSeries[]
|
|
|
+ faultStart: number
|
|
|
+ faultEnd: number
|
|
|
+ height?: number
|
|
|
+ yMin: number
|
|
|
+ yMax: number
|
|
|
+ unit?: string
|
|
|
+ refLines?: RefLine[]
|
|
|
+ showYAxis?: boolean
|
|
|
+ dark?: boolean
|
|
|
+ zoom?: boolean
|
|
|
+ }>(),
|
|
|
+ { height: 180, unit: '', refLines: () => [], showYAxis: true, dark: true, zoom: false },
|
|
|
+)
|
|
|
+
|
|
|
+const el = ref<HTMLElement | null>(null)
|
|
|
+let chart: echarts.ECharts | null = null
|
|
|
+let observer: ResizeObserver | null = null
|
|
|
+
|
|
|
+const BLUE = '#4da3ff'
|
|
|
+const RED = '#E53935'
|
|
|
+const HOUR = 3600 * 1000
|
|
|
+const PAD = 2 * HOUR
|
|
|
+
|
|
|
+function pad2(value: number): string {
|
|
|
+ return String(value).padStart(2, '0')
|
|
|
+}
|
|
|
+
|
|
|
+function buildDataZoom(
|
|
|
+ indexes: number[],
|
|
|
+ sliderH: number,
|
|
|
+ axisColor: string,
|
|
|
+): Record<string, unknown>[] {
|
|
|
+ const startValue = props.faultStart - PAD
|
|
|
+ const endValue = props.faultEnd + PAD
|
|
|
+ return [
|
|
|
+ { type: 'inside', xAxisIndex: indexes, startValue, endValue },
|
|
|
+ {
|
|
|
+ type: 'slider',
|
|
|
+ xAxisIndex: indexes,
|
|
|
+ startValue,
|
|
|
+ endValue,
|
|
|
+ bottom: 4,
|
|
|
+ height: sliderH,
|
|
|
+ borderColor: props.dark ? '#333a45' : '#dddddd',
|
|
|
+ fillerColor: 'rgba(77,163,255,0.2)',
|
|
|
+ handleStyle: { color: BLUE },
|
|
|
+ textStyle: { color: axisColor, fontSize: 9 },
|
|
|
+ showDetail: false,
|
|
|
+ },
|
|
|
+ ]
|
|
|
+}
|
|
|
+
|
|
|
+function buildOption(): Record<string, unknown> {
|
|
|
+ const n = props.series.length
|
|
|
+ const totalH = props.height
|
|
|
+ const hasLegend = n > 1
|
|
|
+ const legendH = hasLegend ? 18 : 0
|
|
|
+ const xLabelH = 18
|
|
|
+ const sliderH = 16
|
|
|
+ const top = legendH + 6
|
|
|
+ const bottom = props.zoom ? sliderH + xLabelH + 10 : xLabelH + 8
|
|
|
+ const gap = 8
|
|
|
+ const gridH = Math.max(24, (totalH - top - bottom - gap * (n - 1)) / n)
|
|
|
+ const indexes = props.series.map((_, i) => i)
|
|
|
+
|
|
|
+ const axisColor = props.dark ? '#aaaaaa' : '#666666'
|
|
|
+ const lineColor = props.dark ? '#555555' : '#cccccc'
|
|
|
+ const legendColor = props.dark ? '#d6dbe2' : '#333333'
|
|
|
+ const refColor = props.dark ? 'rgba(170,170,170,0.7)' : 'rgba(120,120,120,0.8)'
|
|
|
+ const refText = props.dark ? '#aaaaaa' : '#666666'
|
|
|
+ const tooltipBg = props.dark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.95)'
|
|
|
+ const tooltipText = props.dark ? '#ffffff' : '#333333'
|
|
|
+ const faultLine = props.dark ? '#ff5252' : RED
|
|
|
+ const faultFill = props.dark ? 'rgba(229,57,53,0.22)' : 'rgba(229,57,53,0.10)'
|
|
|
+ const faultBorder = props.dark ? 'rgba(229,57,53,0.55)' : 'transparent'
|
|
|
+
|
|
|
+ const markLines = props.refLines.map((line) => ({
|
|
|
+ yAxis: line.value,
|
|
|
+ lineStyle: { color: refColor, type: 'dashed', width: 1 },
|
|
|
+ label: line.label
|
|
|
+ ? { formatter: line.label, position: 'insideEndTop', color: refText, fontSize: 9 }
|
|
|
+ : { show: false },
|
|
|
+ }))
|
|
|
+
|
|
|
+ const grids: Record<string, unknown>[] = []
|
|
|
+ const xAxis: Record<string, unknown>[] = []
|
|
|
+ const yAxis: Record<string, unknown>[] = []
|
|
|
+ const series: Record<string, unknown>[] = []
|
|
|
+
|
|
|
+ props.series.forEach((s, i) => {
|
|
|
+ const last = i === n - 1
|
|
|
+ grids.push({
|
|
|
+ left: 4,
|
|
|
+ right: 8,
|
|
|
+ top: top + i * (gridH + gap),
|
|
|
+ height: gridH,
|
|
|
+ containLabel: props.showYAxis,
|
|
|
+ })
|
|
|
+ xAxis.push({
|
|
|
+ type: 'time',
|
|
|
+ gridIndex: i,
|
|
|
+ min: props.faultStart - PAD,
|
|
|
+ max: props.faultEnd + PAD,
|
|
|
+ axisLine: { show: last, lineStyle: { color: lineColor } },
|
|
|
+ axisTick: { show: last },
|
|
|
+ axisLabel: { show: last, fontSize: 9, color: axisColor, hideOverlap: false, interval: 'auto' },
|
|
|
+ splitLine: { show: false },
|
|
|
+ axisPointer: { label: { show: false } },
|
|
|
+ })
|
|
|
+ yAxis.push({
|
|
|
+ type: 'value',
|
|
|
+ gridIndex: i,
|
|
|
+ show: props.showYAxis,
|
|
|
+ min: props.yMin,
|
|
|
+ max: props.yMax,
|
|
|
+ splitNumber: 4,
|
|
|
+ axisLine: { show: false },
|
|
|
+ axisTick: { show: false },
|
|
|
+ axisLabel: {
|
|
|
+ fontSize: 9,
|
|
|
+ color: axisColor,
|
|
|
+ formatter: (value: number) => `${Number(value).toFixed(2)}${props.unit}`,
|
|
|
+ },
|
|
|
+ splitLine: { show: false },
|
|
|
+ })
|
|
|
+ series.push({
|
|
|
+ name: s.name,
|
|
|
+ type: 'line',
|
|
|
+ xAxisIndex: i,
|
|
|
+ yAxisIndex: i,
|
|
|
+ data: s.data,
|
|
|
+ showSymbol: false,
|
|
|
+ symbol: 'none',
|
|
|
+ animation: false,
|
|
|
+ sampling: 'none',
|
|
|
+ lineStyle: { width: props.dark ? 1.5 : 1 },
|
|
|
+ emphasis: { disabled: true },
|
|
|
+ markLine: { silent: true, symbol: 'none', data: markLines },
|
|
|
+ markArea: {
|
|
|
+ silent: true,
|
|
|
+ itemStyle: { color: faultFill, borderColor: faultBorder, borderWidth: 1 },
|
|
|
+ data: [[{ xAxis: props.faultStart }, { xAxis: props.faultEnd }]],
|
|
|
+ },
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ return {
|
|
|
+ animation: false,
|
|
|
+ backgroundColor: 'transparent',
|
|
|
+ textStyle: {
|
|
|
+ fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
|
|
|
+ fontWeight: 'normal',
|
|
|
+ },
|
|
|
+ legend: hasLegend
|
|
|
+ ? {
|
|
|
+ top: 0,
|
|
|
+ left: 'center',
|
|
|
+ itemWidth: 12,
|
|
|
+ itemHeight: 8,
|
|
|
+ textStyle: { fontSize: 10, color: legendColor },
|
|
|
+ data: props.series.map((s) => s.name),
|
|
|
+ }
|
|
|
+ : undefined,
|
|
|
+ grid: grids,
|
|
|
+ xAxis,
|
|
|
+ yAxis,
|
|
|
+ series,
|
|
|
+ visualMap: {
|
|
|
+ show: false,
|
|
|
+ dimension: 0,
|
|
|
+ seriesIndex: indexes,
|
|
|
+ pieces: [
|
|
|
+ { lt: props.faultStart, color: BLUE },
|
|
|
+ { gte: props.faultStart, lte: props.faultEnd, color: faultLine },
|
|
|
+ { gt: props.faultEnd, color: BLUE },
|
|
|
+ ],
|
|
|
+ outOfRange: { color: BLUE },
|
|
|
+ },
|
|
|
+ axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
|
|
+ dataZoom: props.zoom ? buildDataZoom(indexes, sliderH, axisColor) : undefined,
|
|
|
+ tooltip: {
|
|
|
+ trigger: 'axis',
|
|
|
+ axisPointer: { type: 'line' },
|
|
|
+ backgroundColor: tooltipBg,
|
|
|
+ textStyle: { fontSize: 11, color: tooltipText },
|
|
|
+ formatter: (params: unknown) => {
|
|
|
+ const items = Array.isArray(params) ? params : [params]
|
|
|
+ if (!items.length) return ''
|
|
|
+ const first = items[0] as { axisValue: number }
|
|
|
+ const date = new Date(first.axisValue)
|
|
|
+ const head =
|
|
|
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
|
|
|
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
|
|
+ const lines = items.map((raw) => {
|
|
|
+ const item = raw as { marker: string; seriesName: string; value: [number, number | null] }
|
|
|
+ const value = item.value?.[1]
|
|
|
+ const text = value === null || value === undefined ? '-' : Number(value).toFixed(2)
|
|
|
+ return `${item.marker}${item.seriesName}: ${text}${props.unit}`
|
|
|
+ })
|
|
|
+ return [head, ...lines].join('<br/>')
|
|
|
+ },
|
|
|
+ },
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function render(): void {
|
|
|
+ if (!chart) return
|
|
|
+ chart.setOption(buildOption() as unknown as echarts.EChartsOption, { notMerge: true })
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ if (!el.value) return
|
|
|
+ chart = echarts.init(el.value)
|
|
|
+ render()
|
|
|
+ observer = new ResizeObserver(() => {
|
|
|
+ chart?.resize()
|
|
|
+ render()
|
|
|
+ })
|
|
|
+ observer.observe(el.value)
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ observer?.disconnect()
|
|
|
+ observer = null
|
|
|
+ chart?.dispose()
|
|
|
+ chart = null
|
|
|
+})
|
|
|
+
|
|
|
+watch(() => [props.series, props.yMin, props.yMax, props.refLines], render, { deep: true })
|
|
|
+</script>
|
|
|
+
|
|
|
+<template>
|
|
|
+ <div ref="el" class="fault-chart" :style="{ height: `${height}px` }" />
|
|
|
+</template>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.fault-chart {
|
|
|
+ width: 100%;
|
|
|
+}
|
|
|
+</style>
|