FaultChart.vue 11 KB

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