FaultChart.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. <script setup lang="ts">
  2. import * as echarts from 'echarts'
  3. import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  4. import type { ChartSeries } from '../types'
  5. interface RefLine {
  6. value: number
  7. label?: string
  8. }
  9. const props = withDefaults(
  10. defineProps<{
  11. series: ChartSeries[]
  12. faultStart: number
  13. faultEnd: number
  14. height?: number
  15. yMin?: number
  16. yMax?: number
  17. unit?: string
  18. refLines?: RefLine[]
  19. showYAxis?: boolean
  20. dark?: boolean
  21. zoom?: boolean
  22. xMin?: number
  23. xMax?: number
  24. showLegend?: boolean
  25. showAxisUnit?: boolean
  26. lineColor?: string
  27. insideZoom?: boolean
  28. perSeriesLegend?: boolean
  29. gridHeight?: number
  30. gridDivider?: boolean
  31. }>(),
  32. {
  33. height: 180,
  34. unit: '',
  35. refLines: () => [],
  36. showYAxis: true,
  37. dark: true,
  38. zoom: false,
  39. showLegend: false,
  40. showAxisUnit: true,
  41. lineColor: '',
  42. insideZoom: false,
  43. perSeriesLegend: false,
  44. gridDivider: false,
  45. },
  46. )
  47. function layout() {
  48. const n = props.series.length
  49. const perSeries = props.perSeriesLegend
  50. const hasLegend = props.showLegend || n > 1
  51. const xLabelH = 18
  52. const sliderH = 16
  53. const topPad = 6
  54. const band = perSeries ? 14 : hasLegend ? 18 : 0
  55. const gap = perSeries ? 18 : 8
  56. const bottom = props.zoom ? sliderH + xLabelH + 10 : xLabelH + 8
  57. const totalH = props.gridHeight
  58. ? topPad + (perSeries ? band * n : band) + gap * (n - 1) + props.gridHeight * n + bottom
  59. : props.height
  60. const usable = perSeries
  61. ? totalH - topPad - bottom - band * n - gap * (n - 1)
  62. : totalH - topPad - band - bottom - gap * (n - 1)
  63. const gridH = props.gridHeight ?? Math.max(20, usable / n)
  64. return { n, totalH, perSeries, hasLegend, topPad, band, gap, bottom, gridH, xLabelH, sliderH }
  65. }
  66. const containerHeight = computed(() => layout().totalH)
  67. const el = ref<HTMLElement | null>(null)
  68. let chart: echarts.ECharts | null = null
  69. let observer: ResizeObserver | null = null
  70. const BLUE = '#4da3ff'
  71. const RED = '#E53935'
  72. function pad2(value: number): string {
  73. return String(value).padStart(2, '0')
  74. }
  75. /** 实际数据的时间范围(用于坐标轴/dataZoom 的范围;与故障红区无关)。 */
  76. function seriesExtent(): [number, number] {
  77. let lo = Infinity
  78. let hi = -Infinity
  79. props.series.forEach((s) => {
  80. s.data.forEach(([t]) => {
  81. if (t < lo) lo = t
  82. if (t > hi) hi = t
  83. })
  84. })
  85. if (!Number.isFinite(lo) || !Number.isFinite(hi)) return [props.faultStart, props.faultEnd]
  86. return [lo, hi]
  87. }
  88. function buildDataZoom(
  89. indexes: number[],
  90. sliderH: number,
  91. axisColor: string,
  92. startValue: number,
  93. endValue: number,
  94. ): Record<string, unknown>[] {
  95. return [
  96. { type: 'inside', xAxisIndex: indexes, startValue, endValue },
  97. {
  98. type: 'slider',
  99. xAxisIndex: indexes,
  100. startValue,
  101. endValue,
  102. bottom: 4,
  103. height: sliderH,
  104. borderColor: props.dark ? '#333a45' : '#dddddd',
  105. fillerColor: 'rgba(77,163,255,0.2)',
  106. handleStyle: { color: BLUE },
  107. textStyle: { color: axisColor, fontSize: 9 },
  108. showDetail: false,
  109. },
  110. ]
  111. }
  112. function buildOption(): Record<string, unknown> {
  113. const { n, perSeries, hasLegend, gridH, sliderH, topPad, band, gap } = layout()
  114. const topOf = (i: number): number =>
  115. perSeries ? topPad + i * (band + gridH + gap) : topPad + band + i * (gridH + gap)
  116. const gridTop = (i: number): number => (perSeries ? topOf(i) + band : topOf(i))
  117. const indexes = props.series.map((_, i) => i)
  118. const [tMin, tMax] =
  119. props.xMin != null && props.xMax != null ? [props.xMin, props.xMax] : seriesExtent()
  120. const axisColor = props.dark ? '#aaaaaa' : '#666666'
  121. const lineColor = props.dark ? '#555555' : '#cccccc'
  122. const legendColor = props.dark ? '#d6dbe2' : '#333333'
  123. const refColor = props.dark ? 'rgba(170,170,170,0.7)' : 'rgba(120,120,120,0.8)'
  124. const refText = props.dark ? '#aaaaaa' : '#666666'
  125. const tooltipBg = props.dark ? 'rgba(0,0,0,0.8)' : 'rgba(255,255,255,0.95)'
  126. const tooltipText = props.dark ? '#ffffff' : '#333333'
  127. const faultLine = props.dark ? '#ff5252' : RED
  128. const faultFill = props.dark ? 'rgba(229,57,53,0.22)' : 'rgba(229,57,53,0.10)'
  129. const faultBorder = props.dark ? 'rgba(229,57,53,0.55)' : 'transparent'
  130. const baseColor = props.lineColor || BLUE
  131. const customColors = props.series.some((s) => s.color)
  132. const markLines = props.refLines.map((line) => ({
  133. yAxis: line.value,
  134. lineStyle: { color: refColor, type: 'dashed', width: 1 },
  135. label: line.label
  136. ? { formatter: line.label, position: 'insideEndTop', color: refText, fontSize: 9 }
  137. : { show: false },
  138. }))
  139. const grids: Record<string, unknown>[] = []
  140. const xAxis: Record<string, unknown>[] = []
  141. const yAxis: Record<string, unknown>[] = []
  142. const series: Record<string, unknown>[] = []
  143. props.series.forEach((s, i) => {
  144. const last = i === n - 1
  145. grids.push({
  146. left: 4,
  147. right: 8,
  148. top: gridTop(i),
  149. height: gridH,
  150. containLabel: props.showYAxis,
  151. })
  152. xAxis.push({
  153. type: 'time',
  154. gridIndex: i,
  155. min: tMin,
  156. max: tMax,
  157. axisLine: { show: last, lineStyle: { color: lineColor } },
  158. axisTick: { show: last },
  159. axisLabel: { show: last, fontSize: 9, color: axisColor, hideOverlap: false, interval: 'auto' },
  160. splitLine: { show: false },
  161. axisPointer: { label: { show: false } },
  162. })
  163. yAxis.push({
  164. type: 'value',
  165. gridIndex: i,
  166. show: props.showYAxis,
  167. scale: true,
  168. min: s.yMin ?? props.yMin,
  169. max: s.yMax ?? props.yMax,
  170. splitNumber: 4,
  171. axisLine: { show: false },
  172. axisTick: { show: false },
  173. axisLabel: {
  174. fontSize: 9,
  175. color: axisColor,
  176. formatter: (value: number) =>
  177. props.showAxisUnit ? `${Number(value).toFixed(2)}${props.unit}` : Number(value).toFixed(2),
  178. },
  179. splitLine: { show: false },
  180. })
  181. series.push({
  182. name: s.name,
  183. type: 'line',
  184. xAxisIndex: i,
  185. yAxisIndex: i,
  186. data: s.data,
  187. showSymbol: false,
  188. symbol: 'none',
  189. animation: false,
  190. sampling: 'none',
  191. lineStyle: { width: props.dark ? 1.5 : 1, ...(s.color ? { color: s.color } : {}) },
  192. itemStyle: s.color ? { color: s.color } : undefined,
  193. emphasis: { disabled: true },
  194. markLine: { silent: true, symbol: 'none', data: markLines },
  195. markArea: {
  196. silent: true,
  197. itemStyle: { color: faultFill, borderColor: faultBorder, borderWidth: 1 },
  198. data: [[{ xAxis: props.faultStart }, { xAxis: props.faultEnd }]],
  199. },
  200. })
  201. })
  202. const width = chart ? chart.getWidth() : el.value?.clientWidth ?? 0
  203. const graphic: Record<string, unknown>[] = []
  204. if (props.gridDivider && width > 0) {
  205. for (let i = 0; i < n - 1; i += 1) {
  206. const y = gridTop(i) + gridH + gap / 2
  207. graphic.push({
  208. type: 'line',
  209. z: 10,
  210. silent: true,
  211. shape: { x1: 4, y1: y, x2: width - 8, y2: y },
  212. style: { stroke: '#ffffff', lineWidth: 1 },
  213. })
  214. }
  215. }
  216. return {
  217. animation: false,
  218. backgroundColor: 'transparent',
  219. graphic: graphic.length ? graphic : undefined,
  220. textStyle: {
  221. fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
  222. fontWeight: 'normal',
  223. },
  224. legend: perSeries
  225. ? props.series.map((s, i) => ({
  226. top: topOf(i) + 1,
  227. left: 'center',
  228. itemWidth: 14,
  229. itemHeight: 8,
  230. textStyle: { fontSize: 10, color: legendColor },
  231. data: [s.name],
  232. }))
  233. : hasLegend
  234. ? {
  235. top: 0,
  236. left: 'center',
  237. itemWidth: 12,
  238. itemHeight: 8,
  239. textStyle: { fontSize: 10, color: legendColor },
  240. data: props.series.map((s) => s.name),
  241. }
  242. : undefined,
  243. grid: grids,
  244. xAxis,
  245. yAxis,
  246. series,
  247. visualMap: customColors
  248. ? undefined
  249. : {
  250. show: false,
  251. dimension: 0,
  252. seriesIndex: indexes,
  253. pieces: [
  254. { lt: props.faultStart, color: baseColor },
  255. { gte: props.faultStart, lte: props.faultEnd, color: faultLine },
  256. { gt: props.faultEnd, color: baseColor },
  257. ],
  258. outOfRange: { color: baseColor },
  259. },
  260. axisPointer: { link: [{ xAxisIndex: 'all' }] },
  261. dataZoom: props.insideZoom
  262. ? [{ type: 'inside', xAxisIndex: indexes, startValue: tMin, endValue: tMax }]
  263. : props.zoom
  264. ? buildDataZoom(indexes, sliderH, axisColor, tMin, tMax)
  265. : undefined,
  266. tooltip: {
  267. trigger: 'axis',
  268. axisPointer: { type: 'line' },
  269. backgroundColor: tooltipBg,
  270. textStyle: { fontSize: 11, color: tooltipText },
  271. formatter: (params: unknown) => {
  272. const items = Array.isArray(params) ? params : [params]
  273. if (!items.length) return ''
  274. const first = items[0] as { axisValue: number }
  275. const date = new Date(first.axisValue)
  276. const head =
  277. `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
  278. `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
  279. const lines = items.map((raw) => {
  280. const item = raw as { marker: string; seriesName: string; value: [number, number | null] }
  281. const value = item.value?.[1]
  282. const text = value === null || value === undefined ? '-' : Number(value).toFixed(2)
  283. return `${item.marker}${item.seriesName}: ${text}${props.unit}`
  284. })
  285. return [head, ...lines].join('<br/>')
  286. },
  287. },
  288. }
  289. }
  290. function render(): void {
  291. if (!chart) return
  292. chart.setOption(buildOption() as unknown as echarts.EChartsOption, { notMerge: true })
  293. }
  294. onMounted(() => {
  295. if (!el.value) return
  296. chart = echarts.init(el.value)
  297. render()
  298. observer = new ResizeObserver(() => {
  299. chart?.resize()
  300. render()
  301. })
  302. observer.observe(el.value)
  303. })
  304. onBeforeUnmount(() => {
  305. observer?.disconnect()
  306. observer = null
  307. chart?.dispose()
  308. chart = null
  309. })
  310. watch(
  311. () => [props.series, props.yMin, props.yMax, props.refLines, props.xMin, props.xMax],
  312. render,
  313. { deep: true },
  314. )
  315. </script>
  316. <template>
  317. <div ref="el" class="fault-chart" :style="{ height: `${containerHeight}px` }" />
  318. </template>
  319. <style scoped>
  320. .fault-chart {
  321. width: 100%;
  322. }
  323. </style>