WaveChart.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725
  1. <script setup lang="ts">
  2. import * as echarts from 'echarts'
  3. import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  4. import type { Annotation, Cycle, MeasurementType, TimePoint, WaveWindowResponse } from '../types'
  5. const props = defineProps<{
  6. data: WaveWindowResponse | null
  7. points: TimePoint[]
  8. mode: 'split' | 'merge'
  9. loading?: boolean
  10. annotations?: Annotation[]
  11. }>()
  12. const emit = defineEmits<{
  13. periodDblclick: [cycle: Cycle]
  14. annotationToggle: [cycle: Cycle]
  15. }>()
  16. const chartElement = ref<HTMLDivElement | null>(null)
  17. let chart: echarts.ECharts | undefined
  18. const pvCanvas = ref<HTMLCanvasElement | null>(null)
  19. const pvShell = ref<HTMLDivElement | null>(null)
  20. let pvFrame: number | undefined
  21. const colors: Record<MeasurementType | '角度' | '合并信号' | 'second_value' | '体积', string> = {
  22. 压力: '#e05252',
  23. 位移: '#287f9e',
  24. 加速度: '#7656a5',
  25. 角度: '#c58b24',
  26. 合并信号: '#287f9e',
  27. second_value: '#f56c6c',
  28. 体积: '#4d9e6f',
  29. }
  30. const pvPhaseColors = [
  31. { start: 0, end: 120, color: '#7b1fa2' },
  32. { start: 120, end: 195, color: '#c62828' },
  33. { start: 195, end: 270, color: '#1565c0' },
  34. { start: 270, end: 360, color: '#2e7d32' },
  35. ]
  36. const modalChartWidth = 1060
  37. const modalChartHeight = 440
  38. function niceAxisExtent(values: number[]): [number, number] {
  39. const dataMin = Math.min(...values)
  40. const dataMax = Math.max(...values)
  41. const min = dataMin >= 0 ? 0 : dataMin
  42. const max = dataMax <= 0 ? 0 : dataMax
  43. const range = Math.max(max - min, Number.EPSILON)
  44. const roughInterval = range / 6
  45. const magnitude = 10 ** Math.floor(Math.log10(roughInterval))
  46. const fraction = roughInterval / magnitude
  47. const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 3 ? 3 : fraction <= 5 ? 5 : 10
  48. const interval = niceFraction * magnitude
  49. return [Math.floor(min / interval) * interval, Math.ceil(max / interval) * interval]
  50. }
  51. const plottedPointCount = computed(() => (
  52. (props.data?.series.reduce((total, series) => total + series.data.length, 0) ?? 0)
  53. + (props.data?.secondSeries.data.length ?? 0)
  54. ))
  55. const hasPlottableData = computed(() => plottedPointCount.value > 0)
  56. const secondValueStatus = computed(() => {
  57. const series = props.data?.secondSeries
  58. if (!series) return ''
  59. return `周期数据:有效 ${series.finiteCount.toLocaleString()} 点 / 非零 ${series.nonZeroCount.toLocaleString()} 点`
  60. })
  61. function formatTime(value: string | undefined) {
  62. return value?.replace('T', ' ').slice(11, 19) ?? ''
  63. }
  64. function minMaxOf(values: number[]): [number, number] {
  65. let min = Infinity
  66. let max = -Infinity
  67. for (let i = 0; i < values.length; i += 1) {
  68. const value = values[i]
  69. if (value < min) min = value
  70. if (value > max) max = value
  71. }
  72. return [min, max]
  73. }
  74. function pointAxisLabel(value: number) {
  75. const index = Math.round(value)
  76. if (Math.abs(value - index) > 0.04 || !props.points[index]) return ''
  77. const point = props.points[index]
  78. const sourceType = props.data?.secondSeries.sourceMeasurementType
  79. const fileId = sourceType ? point.files[sourceType]?.id : undefined
  80. const time = point.sampleTime.replace('T', ' ').slice(0, 19)
  81. return `${fileId ?? ''}\n${time}`
  82. }
  83. function periodAreas(cycles: Cycle[]): any[] {
  84. return cycles.map((cycle, index) => [
  85. {
  86. xAxis: cycle.startX,
  87. itemStyle: {
  88. color: index % 2 === 0 ? 'rgba(213, 155, 43, 0.075)' : 'rgba(15, 29, 43, 0.09)',
  89. },
  90. },
  91. { xAxis: cycle.endX },
  92. ])
  93. }
  94. function triggerLines(xs: number[]) {
  95. return xs.map((x) => ({
  96. xAxis: x,
  97. lineStyle: { color: '#d59b2b', width: 1, type: 'dotted' as const, opacity: 0.65 },
  98. label: { show: false },
  99. }))
  100. }
  101. function annotationXRange(annotation: Annotation): { start: number; end: number } | null {
  102. const matching = (props.data?.cycles ?? []).filter(
  103. (cycle) => cycle.waveFileId === annotation.waveFileId
  104. && cycle.periodNo >= annotation.periodStart
  105. && cycle.periodNo <= annotation.periodEnd,
  106. )
  107. if (!matching.length) return null
  108. const start = Math.min(...matching.map((cycle) => cycle.startX))
  109. const end = Math.max(...matching.map((cycle) => cycle.endX))
  110. return { start, end }
  111. }
  112. function annotationAreas(annotations: Annotation[]): any[] {
  113. const areas: any[] = []
  114. annotations.forEach((annotation) => {
  115. const range = annotationXRange(annotation)
  116. if (!range) return
  117. const color = annotation.label === '异常' ? 'rgba(229, 57, 46, 0.22)' : 'rgba(46, 125, 50, 0.18)'
  118. areas.push([
  119. { xAxis: range.start, itemStyle: { color } },
  120. { xAxis: range.end },
  121. ])
  122. })
  123. return areas
  124. }
  125. function buildAnnotationOverlaySeries(annotations: Annotation[]): echarts.SeriesOption[] {
  126. const data = props.data
  127. if (!data) return []
  128. const gridCount = props.mode === 'merge' ? 1 : data.measurementTypes.length + 3
  129. const areas = annotationAreas(annotations)
  130. return Array.from({ length: gridCount }, (_, index) => ({
  131. id: `annotation-overlay-${index}`,
  132. type: 'line' as const,
  133. xAxisIndex: index,
  134. yAxisIndex: index,
  135. silent: true,
  136. data: [],
  137. markArea: { silent: true, data: areas },
  138. }))
  139. }
  140. function initialZoom(data: WaveWindowResponse): { start: number; end: number } {
  141. const sampleCount = data.files[0]?.sampleCount ?? 65536
  142. const start = data.cycles.length > 0 ? (data.cycles[0].startX / data.xMax) * 100 : 0
  143. const end = (() => {
  144. if (data.cycles.length > 10) return (data.cycles[9].endX / data.xMax) * 100
  145. if (data.cycles.length > 0) return 100
  146. return Math.min(100, (25600 / sampleCount / data.xMax) * 100)
  147. })()
  148. return { start, end }
  149. }
  150. function readCurrentZoom(data: WaveWindowResponse): { start: number; end: number } {
  151. if (chart) {
  152. const option = chart.getOption() as any
  153. const slider = option?.dataZoom?.[1] ?? option?.dataZoom?.[0]
  154. if (slider && slider.start != null && slider.end != null) {
  155. return { start: slider.start, end: slider.end }
  156. }
  157. }
  158. return initialZoom(data)
  159. }
  160. const xMax = computed(() => props.data?.xMax ?? 1)
  161. const annotationSegments = computed(() => {
  162. return (props.annotations ?? [])
  163. .map((annotation) => {
  164. const range = annotationXRange(annotation)
  165. return range ? { annotation, ...range } : null
  166. })
  167. .filter((item): item is { annotation: Annotation; start: number; end: number } => item !== null)
  168. .sort((a, b) => a.start - b.start)
  169. })
  170. const activeAnnotationIndex = ref(0)
  171. function focusAnnotation(index: number) {
  172. const segment = annotationSegments.value[index]
  173. if (!segment || !chart || !props.data) return
  174. activeAnnotationIndex.value = index
  175. const start = Math.max(0, (segment.start / xMax.value) * 100)
  176. const end = Math.min(100, (segment.end / xMax.value) * 100)
  177. chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start, end })
  178. chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start, end })
  179. }
  180. function locateFile(pointIndex: number) {
  181. if (!chart || !props.data) return
  182. const start = Math.max(0, (pointIndex / xMax.value) * 100)
  183. const end = Math.min(100, ((pointIndex + 1) / xMax.value) * 100)
  184. chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start, end })
  185. chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start, end })
  186. }
  187. defineExpose({ locateFile })
  188. watch(annotationSegments, () => {
  189. if (activeAnnotationIndex.value >= annotationSegments.value.length) {
  190. activeAnnotationIndex.value = Math.max(0, annotationSegments.value.length - 1)
  191. }
  192. })
  193. function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
  194. const data = props.data
  195. if (!data) return { animation: false }
  196. const measurementTypes = data.measurementTypes
  197. const periodBackground = periodAreas(data.cycles)
  198. const types = props.mode === 'merge' ? ['合并信号'] : [...measurementTypes, 'second_value', '角度', '体积']
  199. const gridCount = types.length
  200. const chartHeight = chartElement.value?.clientHeight || 640
  201. const topInset = 32
  202. const bottomInset = 68
  203. const rowGap = props.mode === 'merge' ? 0 : 14
  204. const rowHeight = props.mode === 'merge'
  205. ? Math.max(260, chartHeight - topInset - bottomInset)
  206. : Math.max(76, Math.floor((chartHeight - topInset - bottomInset - rowGap * (gridCount - 1)) / gridCount))
  207. const grid = Array.from({ length: gridCount }, (_, index) => ({
  208. left: 70,
  209. right: 22,
  210. top: topInset + index * (rowHeight + rowGap),
  211. height: rowHeight,
  212. containLabel: false,
  213. }))
  214. if (props.mode === 'merge') {
  215. grid[0] = { left: 70, right: 22, top: topInset, height: rowHeight, containLabel: false }
  216. }
  217. const xAxes = grid.map((_, index) => ({
  218. type: 'value' as const,
  219. min: data.xMin,
  220. max: data.xMax,
  221. gridIndex: index,
  222. axisLine: { lineStyle: { color: '#b9c6cc' } },
  223. axisTick: { show: index === gridCount - 1 },
  224. axisLabel: {
  225. show: index === gridCount - 1,
  226. color: '#71808a',
  227. fontSize: 11,
  228. formatter: pointAxisLabel,
  229. },
  230. boundaryGap: false,
  231. splitLine: { show: false },
  232. }))
  233. const yAxes = types.map((type, index) => ({
  234. type: 'value' as const,
  235. gridIndex: index,
  236. name: type === 'second_value' ? '周期数据' : type,
  237. nameLocation: 'middle' as const,
  238. nameGap: 48,
  239. nameTextStyle: { color: colors[type as MeasurementType | '角度' | '合并信号' | 'second_value' | '体积'], fontWeight: 600 },
  240. axisLine: { show: true, lineStyle: { color: colors[type as MeasurementType | '角度' | '合并信号' | 'second_value' | '体积'] } },
  241. axisLabel: { color: '#71808a', fontSize: 11 },
  242. splitLine: { show: true, lineStyle: { color: '#e7edf0', width: 1 } },
  243. min: type === '角度' ? 0 : undefined,
  244. max: type === '角度' ? 180 : undefined,
  245. }))
  246. const series: echarts.SeriesOption[] = []
  247. if (props.mode === 'merge') {
  248. const normalised = measurementTypes.map((type) => {
  249. const source = data.series.find((item) => item.measurementType === type)
  250. const values = source?.data ?? []
  251. const finiteValues = values.map((item) => item.rawValue).filter(Number.isFinite)
  252. const [safeMin, safeMax] = finiteValues.length ? minMaxOf(finiteValues) : [0, 1]
  253. const span = safeMax - safeMin || 1
  254. return {
  255. name: type,
  256. type: 'line' as const,
  257. z: 10,
  258. showSymbol: false,
  259. connectNulls: false,
  260. sampling: 'lttb' as const,
  261. lineStyle: { width: 2.5, color: colors[type], cap: 'round' as const, join: 'round' as const },
  262. itemStyle: { color: colors[type] },
  263. data: values
  264. .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
  265. .map((item) => [item.x, (item.rawValue - safeMin) / span] as [number, number]),
  266. markArea: { silent: true, data: periodBackground },
  267. }
  268. })
  269. series.push(...normalised)
  270. const secondValues = data.secondSeries.data
  271. const secondFiniteValues = secondValues.map((item) => item.rawValue).filter(Number.isFinite)
  272. const [secondMin, secondMax] = secondFiniteValues.length ? minMaxOf(secondFiniteValues) : [0, 1]
  273. const secondSpan = secondMax - secondMin || 1
  274. series.push({
  275. name: '周期数据',
  276. type: 'line',
  277. xAxisIndex: 0,
  278. yAxisIndex: 0,
  279. showSymbol: false,
  280. connectNulls: false,
  281. lineStyle: { width: 3, color: colors.second_value, cap: 'round' as const, join: 'round' as const },
  282. areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
  283. data: secondValues
  284. .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
  285. .map((item) => [item.x, (item.rawValue - secondMin) / secondSpan] as [number, number]),
  286. })
  287. series.push({
  288. name: '角度',
  289. type: 'line',
  290. xAxisIndex: 0,
  291. yAxisIndex: 0,
  292. showSymbol: false,
  293. lineStyle: { width: 1.5, type: 'dashed', color: colors['角度'], opacity: 0.8 },
  294. data: data.angleSeries.data
  295. .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.angle))
  296. .map((item) => [item.x, item.angle / 180] as [number, number]),
  297. markLine: { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) },
  298. })
  299. const volumeValues = data.volumeSeries.data
  300. const volumeFinite = volumeValues.map((item) => item.volume).filter(Number.isFinite)
  301. const [volumeMin, volumeMax] = volumeFinite.length ? minMaxOf(volumeFinite) : [0, 1]
  302. const volumeSpan = volumeMax - volumeMin || 1
  303. series.push({
  304. name: '体积',
  305. type: 'line',
  306. xAxisIndex: 0,
  307. yAxisIndex: 0,
  308. showSymbol: false,
  309. lineStyle: { width: 1.5, color: colors['体积'], opacity: 0.9 },
  310. data: volumeValues
  311. .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
  312. .map((item) => [item.x, (item.volume - volumeMin) / volumeSpan] as [number, number]),
  313. })
  314. } else {
  315. const secondIndex = measurementTypes.length
  316. const angleIndex = secondIndex + 1
  317. const volumeIndex = secondIndex + 2
  318. series.push({
  319. name: '周期数据',
  320. type: 'line',
  321. xAxisIndex: secondIndex,
  322. yAxisIndex: secondIndex,
  323. showSymbol: false,
  324. connectNulls: false,
  325. lineStyle: { width: 3, color: colors.second_value, cap: 'round', join: 'round' },
  326. areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
  327. itemStyle: { color: colors.second_value },
  328. data: data.secondSeries.data
  329. .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
  330. .map((item) => [item.x, item.rawValue] as [number, number]),
  331. markArea: { silent: true, data: periodBackground },
  332. })
  333. series.push({
  334. name: '角度',
  335. type: 'line',
  336. xAxisIndex: angleIndex,
  337. yAxisIndex: angleIndex,
  338. showSymbol: false,
  339. lineStyle: { width: 2, color: colors['角度'] },
  340. itemStyle: { color: colors['角度'] },
  341. data: data.angleSeries.data
  342. .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.angle))
  343. .map((item) => [item.x, item.angle] as [number, number]),
  344. markArea: { silent: true, data: periodBackground },
  345. markLine: { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) },
  346. })
  347. series.push({
  348. name: '体积',
  349. type: 'line',
  350. xAxisIndex: volumeIndex,
  351. yAxisIndex: volumeIndex,
  352. showSymbol: false,
  353. lineStyle: { width: 2, color: colors['体积'] },
  354. itemStyle: { color: colors['体积'] },
  355. data: data.volumeSeries.data
  356. .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
  357. .map((item) => [item.x, item.volume] as [number, number]),
  358. markArea: { silent: true, data: periodBackground },
  359. })
  360. measurementTypes.forEach((type, index) => {
  361. const source = data.series.find((item) => item.measurementType === type)
  362. series.push({
  363. name: type,
  364. type: 'line',
  365. xAxisIndex: index,
  366. yAxisIndex: index,
  367. showSymbol: false,
  368. connectNulls: false,
  369. sampling: 'lttb' as const,
  370. lineStyle: { width: 2.5, color: colors[type], cap: 'round', join: 'round' },
  371. itemStyle: { color: colors[type] },
  372. data: source?.data
  373. .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
  374. .map((item) => [item.x, item.rawValue] as [number, number]) ?? [],
  375. markArea: { silent: true, data: periodBackground },
  376. markLine: index === 0 ? { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) } : undefined,
  377. })
  378. })
  379. }
  380. const zoom = zoomMode === 'keep' ? readCurrentZoom(data) : initialZoom(data)
  381. series.push(...buildAnnotationOverlaySeries(props.annotations ?? []))
  382. return {
  383. animation: false,
  384. color: measurementTypes.map((type) => colors[type]),
  385. grid,
  386. xAxis: xAxes,
  387. yAxis: yAxes,
  388. series,
  389. tooltip: {
  390. trigger: 'axis',
  391. axisPointer: { type: 'cross', snap: false },
  392. backgroundColor: '#162b3c',
  393. borderWidth: 0,
  394. textStyle: { color: '#f7fafb', fontSize: 11 },
  395. formatter: (params: any[]) => {
  396. if (!params?.length) return ''
  397. const first = params[0]
  398. const lines = [`<strong>窗口位置:${Number(first.value?.[0] ?? 0).toFixed(4)}</strong>`]
  399. params.forEach((item) => {
  400. const value = item.value?.[1]
  401. if (value !== undefined && value !== null) {
  402. lines.push(`<span style="color:${item.color}">●</span> ${item.seriesName}: ${Number(value).toPrecision(7)}`)
  403. }
  404. })
  405. return lines.join('<br/>')
  406. },
  407. },
  408. legend: {
  409. data: [...measurementTypes, '周期数据', '角度', '体积'],
  410. top: 0,
  411. left: 70,
  412. itemWidth: 16,
  413. itemHeight: 7,
  414. textStyle: { color: '#60717b', fontSize: 11 },
  415. },
  416. dataZoom: [
  417. { type: 'inside', xAxisIndex: xAxes.map((_, index) => index), zoomOnMouseWheel: true, moveOnMouseMove: true, start: zoom.start, end: zoom.end },
  418. {
  419. type: 'slider',
  420. xAxisIndex: xAxes.map((_, index) => index),
  421. bottom: 8,
  422. height: 36,
  423. borderColor: '#d9e2e5',
  424. backgroundColor: '#f2f5f6',
  425. fillerColor: 'rgba(31, 122, 140, 0.16)',
  426. handleStyle: { color: '#1f7a8c' },
  427. textStyle: { color: '#71808a', fontSize: 10 },
  428. start: zoom.start,
  429. end: zoom.end,
  430. },
  431. ],
  432. graphic: props.mode === 'merge' ? [
  433. {
  434. type: 'text',
  435. left: 70,
  436. top: 31,
  437. style: { text: '归一化值', fill: '#8b9aa2', fontSize: 11 },
  438. },
  439. ] : [],
  440. }
  441. }
  442. function drawPvPreviews() {
  443. if (pvFrame != null) cancelAnimationFrame(pvFrame)
  444. pvFrame = requestAnimationFrame(() => {
  445. pvFrame = undefined
  446. const canvas = pvCanvas.value
  447. const shell = pvShell.value
  448. const data = props.data
  449. if (!canvas || !shell || !data || !chart) return
  450. const height = 132
  451. const pressure = data.series.find((series) => series.measurementType === '压力')?.data ?? []
  452. const xPixel = (value: number) => Number(chart?.convertToPixel({ xAxisIndex: 0 }, value))
  453. const zoom = readCurrentZoom(data)
  454. const visibleMin = data.xMin + (data.xMax - data.xMin) * zoom.start / 100
  455. const visibleMax = data.xMin + (data.xMax - data.xMin) * zoom.end / 100
  456. const chartLeft = xPixel(visibleMin)
  457. const chartRight = xPixel(visibleMax)
  458. if (!Number.isFinite(chartLeft) || !Number.isFinite(chartRight)) return
  459. const plotLeft = Math.min(chartLeft, chartRight)
  460. const plotRight = Math.max(chartLeft, chartRight)
  461. const width = Math.max(plotRight - plotLeft, 1)
  462. shell.style.marginLeft = `${plotLeft}px`
  463. shell.style.width = `${width}px`
  464. const ratio = Math.min(window.devicePixelRatio || 1, 2)
  465. canvas.width = width * ratio
  466. canvas.height = height * ratio
  467. canvas.style.width = `${width}px`
  468. canvas.style.height = `${height}px`
  469. const context = canvas.getContext('2d')
  470. if (!context) return
  471. context.setTransform(ratio, 0, 0, ratio, 0, 0)
  472. context.clearRect(0, 0, width, height)
  473. context.fillStyle = '#f8fafb'
  474. context.fillRect(0, 0, width, height)
  475. if (!pressure.length || !data.cycles.length) return
  476. const localX = (value: number) => xPixel(value) - plotLeft
  477. const groups = new Map<number, typeof pressure>()
  478. pressure.forEach((point) => {
  479. if (point.volume == null || !Number.isFinite(point.volume) || !Number.isFinite(point.rawValue)) return
  480. const list = groups.get(point.waveFileId) ?? []
  481. list.push(point)
  482. groups.set(point.waveFileId, list)
  483. })
  484. const lowerBound = (points: typeof pressure, sampleIndex: number) => {
  485. let low = 0
  486. let high = points.length
  487. while (low < high) {
  488. const middle = (low + high) >> 1
  489. if (points[middle].sampleIndex < sampleIndex) low = middle + 1
  490. else high = middle
  491. }
  492. return low
  493. }
  494. context.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'
  495. context.textAlign = 'center'
  496. data.cycles.forEach((cycle, cycleIndex) => {
  497. const left = localX(cycle.startX)
  498. const right = localX(cycle.endX)
  499. const cycleWidth = Math.abs(right - left)
  500. if (!Number.isFinite(left) || !Number.isFinite(right)) return
  501. const cellLeft = Math.min(left, right)
  502. const cellRight = Math.max(left, right)
  503. const clippedLeft = Math.max(0, cellLeft)
  504. const clippedRight = Math.min(width, cellRight)
  505. if (clippedRight <= clippedLeft) return
  506. context.fillStyle = cycleIndex % 2 === 0 ? 'rgba(213, 155, 43, .075)' : 'rgba(15, 29, 43, .09)'
  507. context.fillRect(clippedLeft, 0, clippedRight - clippedLeft, height)
  508. context.strokeStyle = 'rgba(213, 155, 43, .25)'
  509. context.strokeRect(cellLeft + .5, .5, Math.max(0, cycleWidth - 1), height - 1)
  510. if (cycleWidth < 100) return
  511. const filePoints = groups.get(cycle.waveFileId) ?? []
  512. const start = lowerBound(filePoints, cycle.startSampleIndex)
  513. const end = lowerBound(filePoints, cycle.endSampleIndex + 1)
  514. const points = filePoints.slice(start, end)
  515. if (points.length < 2) return
  516. const volumes = points.map((point) => point.volume as number)
  517. const pressures = points.map((point) => point.rawValue)
  518. const [minVolume, maxVolume] = niceAxisExtent(volumes)
  519. const [minPressure, maxPressure] = niceAxisExtent(pressures)
  520. const volumeSpan = maxVolume - minVolume || 1
  521. const pressureSpan = maxPressure - minPressure || 1
  522. const frameWidth = Math.min(cycleWidth, height * modalChartWidth / modalChartHeight)
  523. const frameHeight = frameWidth * modalChartHeight / modalChartWidth
  524. const frameLeft = cellLeft + (cycleWidth - frameWidth) / 2
  525. const frameTop = (height - frameHeight) / 2
  526. const graphLeft = frameLeft + frameWidth * 64 / modalChartWidth
  527. const graphRight = frameLeft + frameWidth * (modalChartWidth - 28) / modalChartWidth
  528. const graphTop = frameTop + frameHeight * 38 / modalChartHeight
  529. const graphBottom = frameTop + frameHeight * (modalChartHeight - 52) / modalChartHeight
  530. const graphHeight = graphBottom - graphTop
  531. context.save()
  532. context.beginPath()
  533. context.rect(cellLeft, 0, cycleWidth, height)
  534. context.clip()
  535. context.lineWidth = 1
  536. pvPhaseColors.forEach((phase) => {
  537. const phasePoints = points.filter((point) => (
  538. point.angle360 != null
  539. && point.angle360 >= phase.start
  540. && point.angle360 < phase.end
  541. ))
  542. if (phasePoints.length < 2) return
  543. context.strokeStyle = phase.color
  544. context.beginPath()
  545. phasePoints.forEach((point, index) => {
  546. const x = graphLeft + (((point.volume as number) - minVolume) / volumeSpan) * (graphRight - graphLeft)
  547. const y = graphBottom - ((point.rawValue - minPressure) / pressureSpan) * graphHeight
  548. if (index === 0) context.moveTo(x, y)
  549. else context.lineTo(x, y)
  550. })
  551. context.stroke()
  552. })
  553. context.restore()
  554. context.fillStyle = '#788892'
  555. context.fillText(`P-V ${cycle.periodNo}`, (left + right) / 2, 12)
  556. })
  557. })
  558. }
  559. function onDoubleClick(params: any) {
  560. if (!props.data || !chart || !props.data.cycles.length) return
  561. if (params.componentType !== 'series') return
  562. const offsetX = params?.event?.offsetX
  563. if (typeof offsetX !== 'number') return
  564. const coordinate = chart.convertFromPixel({ gridIndex: 0 }, [offsetX, params.event.offsetY ?? 0]) as number[]
  565. const x = coordinate?.[0]
  566. if (typeof x !== 'number') return
  567. const cycle = props.data.cycles.find((item) => x >= item.startX && x <= item.endX)
  568. if (cycle) emit('periodDblclick', cycle)
  569. }
  570. function onBlankDoubleClick(event: any) {
  571. if (!props.data || !chart || !props.data.cycles.length) return
  572. if (event.target) return
  573. const px = event.offsetX
  574. const py = event.offsetY
  575. if (typeof px !== 'number' || typeof py !== 'number') return
  576. const gridCount = props.mode === 'merge' ? 1 : props.data.measurementTypes.length + 3
  577. let inGrid = false
  578. for (let index = 0; index < gridCount; index += 1) {
  579. if (chart.containPixel({ gridIndex: index }, [px, py])) {
  580. inGrid = true
  581. break
  582. }
  583. }
  584. if (!inGrid) return
  585. const coordinate = chart.convertFromPixel({ gridIndex: 0 }, [px, py]) as number[]
  586. const x = coordinate?.[0]
  587. if (typeof x !== 'number') return
  588. const cycle = props.data.cycles.find((item) => x >= item.startX && x <= item.endX)
  589. if (cycle) emit('annotationToggle', cycle)
  590. }
  591. function renderFull(zoomMode: 'initial' | 'keep' = 'initial') {
  592. if (!chart) return
  593. chart.setOption(buildOption(zoomMode), true)
  594. chart.resize()
  595. drawPvPreviews()
  596. }
  597. function updateAnnotationOverlay() {
  598. if (!chart || !props.data) return
  599. chart.setOption({ series: buildAnnotationOverlaySeries(props.annotations ?? []) }, false)
  600. }
  601. function resize() {
  602. chart?.resize()
  603. drawPvPreviews()
  604. }
  605. watch(
  606. () => props.data,
  607. () => nextTick(() => renderFull('initial')),
  608. { deep: true },
  609. )
  610. watch(
  611. () => props.mode,
  612. () => nextTick(() => renderFull('keep')),
  613. )
  614. watch(
  615. () => props.annotations,
  616. () => nextTick(updateAnnotationOverlay),
  617. { deep: true },
  618. )
  619. onMounted(() => {
  620. if (!chartElement.value) return
  621. chart = echarts.init(chartElement.value, undefined, { renderer: 'canvas' })
  622. chart.on('dblclick', onDoubleClick)
  623. chart.on('dataZoom', drawPvPreviews)
  624. chart.getZr().on('dblclick', onBlankDoubleClick)
  625. window.addEventListener('resize', resize)
  626. renderFull('initial')
  627. })
  628. onBeforeUnmount(() => {
  629. window.removeEventListener('resize', resize)
  630. chart?.dispose()
  631. if (pvFrame != null) cancelAnimationFrame(pvFrame)
  632. })
  633. </script>
  634. <template>
  635. <div class="wave-chart-shell">
  636. <div class="chart-toolbar-note">
  637. <span class="chart-dot"></span>
  638. <span>双击曲线查看功图,双击空白处标注 / 取消标注</span>
  639. <span class="chart-separator"></span>
  640. <span>滚轮缩放,底部滑轨横向浏览</span>
  641. <span class="chart-point-count">已加载 {{ plottedPointCount.toLocaleString() }} 点<span v-if="secondValueStatus"> · {{ secondValueStatus }}</span></span>
  642. </div>
  643. <div ref="pvShell" class="pv-preview-shell">
  644. <div class="pv-preview-title">压力-体积功图 · 当前可视周期</div>
  645. <canvas ref="pvCanvas" class="pv-preview-canvas" aria-label="压力-体积功图缩略预览"></canvas>
  646. </div>
  647. <div ref="chartElement" class="wave-chart" :class="{ 'is-merge': mode === 'merge' }"></div>
  648. <div v-if="annotations?.length" class="annotation-nav">
  649. <div class="annotation-nav-head">
  650. <span class="annotation-nav-title">标注导航</span>
  651. <div class="annotation-nav-controls">
  652. <el-button class="ghost-button" size="small" plain :disabled="activeAnnotationIndex <= 0" @click="focusAnnotation(activeAnnotationIndex - 1)">上一标注</el-button>
  653. <span class="annotation-nav-counter">{{ annotationSegments.length ? `${activeAnnotationIndex + 1} / ${annotationSegments.length}` : '0 / 0' }}</span>
  654. <el-button class="ghost-button" size="small" plain :disabled="activeAnnotationIndex >= annotationSegments.length - 1" @click="focusAnnotation(activeAnnotationIndex + 1)">下一标注</el-button>
  655. </div>
  656. </div>
  657. <div class="annotation-nav-track">
  658. <div
  659. v-for="(segment, index) in annotationSegments"
  660. :key="segment.annotation.id"
  661. class="annotation-nav-seg"
  662. :class="{
  663. normal: segment.annotation.label === '正常',
  664. abnormal: segment.annotation.label === '异常',
  665. active: index === activeAnnotationIndex,
  666. }"
  667. :style="{
  668. left: `${(segment.start / xMax) * 100}%`,
  669. width: `${Math.max(((segment.end - segment.start) / xMax) * 100, 0.4)}%`,
  670. }"
  671. :title="`${segment.annotation.label} · 周期 ${segment.annotation.periodStart}—${segment.annotation.periodEnd} · 点 ${segment.annotation.sampleIndexStart}—${segment.annotation.sampleIndexEnd}`"
  672. @click="focusAnnotation(index)"
  673. />
  674. </div>
  675. <div class="annotation-nav-legend">
  676. <span><i class="annotation-swatch normal"></i>正常样本</span>
  677. <span><i class="annotation-swatch abnormal"></i>异常样本</span>
  678. </div>
  679. </div>
  680. <div v-if="loading" class="chart-loading">正在整理波形数据…</div>
  681. <div v-else-if="!data" class="chart-empty-hint">请点击「查询图表」加载波形数据。</div>
  682. <div v-else-if="data && !hasPlottableData" class="chart-empty-hint">当前窗口没有可绘制的波形数据,请检查时间点和数据名称选择。</div>
  683. <div v-if="data && !data.cycles.length" class="chart-empty-hint">当前窗口未检测到完整周期,仍可查看原始波形。</div>
  684. <div v-if="data?.firstCycleNotice" class="chart-empty-hint">{{ data.firstCycleNotice }}</div>
  685. </div>
  686. </template>