| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725 |
- <script setup lang="ts">
- import * as echarts from 'echarts'
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
- import type { Annotation, Cycle, MeasurementType, TimePoint, WaveWindowResponse } from '../types'
- const props = defineProps<{
- data: WaveWindowResponse | null
- points: TimePoint[]
- mode: 'split' | 'merge'
- loading?: boolean
- annotations?: Annotation[]
- }>()
- const emit = defineEmits<{
- periodDblclick: [cycle: Cycle]
- annotationToggle: [cycle: Cycle]
- }>()
- const chartElement = ref<HTMLDivElement | null>(null)
- let chart: echarts.ECharts | undefined
- const pvCanvas = ref<HTMLCanvasElement | null>(null)
- const pvShell = ref<HTMLDivElement | null>(null)
- let pvFrame: number | undefined
- const colors: Record<MeasurementType | '角度' | '合并信号' | 'second_value' | '体积', string> = {
- 压力: '#e05252',
- 位移: '#287f9e',
- 加速度: '#7656a5',
- 角度: '#c58b24',
- 合并信号: '#287f9e',
- second_value: '#f56c6c',
- 体积: '#4d9e6f',
- }
- const pvPhaseColors = [
- { start: 0, end: 120, color: '#7b1fa2' },
- { start: 120, end: 195, color: '#c62828' },
- { start: 195, end: 270, color: '#1565c0' },
- { start: 270, end: 360, color: '#2e7d32' },
- ]
- const modalChartWidth = 1060
- const modalChartHeight = 440
- function niceAxisExtent(values: number[]): [number, number] {
- const dataMin = Math.min(...values)
- const dataMax = Math.max(...values)
- const min = dataMin >= 0 ? 0 : dataMin
- const max = dataMax <= 0 ? 0 : dataMax
- const range = Math.max(max - min, Number.EPSILON)
- const roughInterval = range / 6
- const magnitude = 10 ** Math.floor(Math.log10(roughInterval))
- const fraction = roughInterval / magnitude
- const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 3 ? 3 : fraction <= 5 ? 5 : 10
- const interval = niceFraction * magnitude
- return [Math.floor(min / interval) * interval, Math.ceil(max / interval) * interval]
- }
- const plottedPointCount = computed(() => (
- (props.data?.series.reduce((total, series) => total + series.data.length, 0) ?? 0)
- + (props.data?.secondSeries.data.length ?? 0)
- ))
- const hasPlottableData = computed(() => plottedPointCount.value > 0)
- const secondValueStatus = computed(() => {
- const series = props.data?.secondSeries
- if (!series) return ''
- return `周期数据:有效 ${series.finiteCount.toLocaleString()} 点 / 非零 ${series.nonZeroCount.toLocaleString()} 点`
- })
- function formatTime(value: string | undefined) {
- return value?.replace('T', ' ').slice(11, 19) ?? ''
- }
- function minMaxOf(values: number[]): [number, number] {
- let min = Infinity
- let max = -Infinity
- for (let i = 0; i < values.length; i += 1) {
- const value = values[i]
- if (value < min) min = value
- if (value > max) max = value
- }
- return [min, max]
- }
- function pointAxisLabel(value: number) {
- const index = Math.round(value)
- if (Math.abs(value - index) > 0.04 || !props.points[index]) return ''
- const point = props.points[index]
- const sourceType = props.data?.secondSeries.sourceMeasurementType
- const fileId = sourceType ? point.files[sourceType]?.id : undefined
- const time = point.sampleTime.replace('T', ' ').slice(0, 19)
- return `${fileId ?? ''}\n${time}`
- }
- function periodAreas(cycles: Cycle[]): any[] {
- return cycles.map((cycle, index) => [
- {
- xAxis: cycle.startX,
- itemStyle: {
- color: index % 2 === 0 ? 'rgba(213, 155, 43, 0.075)' : 'rgba(15, 29, 43, 0.09)',
- },
- },
- { xAxis: cycle.endX },
- ])
- }
- function triggerLines(xs: number[]) {
- return xs.map((x) => ({
- xAxis: x,
- lineStyle: { color: '#d59b2b', width: 1, type: 'dotted' as const, opacity: 0.65 },
- label: { show: false },
- }))
- }
- function annotationXRange(annotation: Annotation): { start: number; end: number } | null {
- const matching = (props.data?.cycles ?? []).filter(
- (cycle) => cycle.waveFileId === annotation.waveFileId
- && cycle.periodNo >= annotation.periodStart
- && cycle.periodNo <= annotation.periodEnd,
- )
- if (!matching.length) return null
- const start = Math.min(...matching.map((cycle) => cycle.startX))
- const end = Math.max(...matching.map((cycle) => cycle.endX))
- return { start, end }
- }
- function annotationAreas(annotations: Annotation[]): any[] {
- const areas: any[] = []
- annotations.forEach((annotation) => {
- const range = annotationXRange(annotation)
- if (!range) return
- const color = annotation.label === '异常' ? 'rgba(229, 57, 46, 0.22)' : 'rgba(46, 125, 50, 0.18)'
- areas.push([
- { xAxis: range.start, itemStyle: { color } },
- { xAxis: range.end },
- ])
- })
- return areas
- }
- function buildAnnotationOverlaySeries(annotations: Annotation[]): echarts.SeriesOption[] {
- const data = props.data
- if (!data) return []
- const gridCount = props.mode === 'merge' ? 1 : data.measurementTypes.length + 3
- const areas = annotationAreas(annotations)
- return Array.from({ length: gridCount }, (_, index) => ({
- id: `annotation-overlay-${index}`,
- type: 'line' as const,
- xAxisIndex: index,
- yAxisIndex: index,
- silent: true,
- data: [],
- markArea: { silent: true, data: areas },
- }))
- }
- function initialZoom(data: WaveWindowResponse): { start: number; end: number } {
- const sampleCount = data.files[0]?.sampleCount ?? 65536
- const start = data.cycles.length > 0 ? (data.cycles[0].startX / data.xMax) * 100 : 0
- const end = (() => {
- if (data.cycles.length > 10) return (data.cycles[9].endX / data.xMax) * 100
- if (data.cycles.length > 0) return 100
- return Math.min(100, (25600 / sampleCount / data.xMax) * 100)
- })()
- return { start, end }
- }
- function readCurrentZoom(data: WaveWindowResponse): { start: number; end: number } {
- if (chart) {
- const option = chart.getOption() as any
- const slider = option?.dataZoom?.[1] ?? option?.dataZoom?.[0]
- if (slider && slider.start != null && slider.end != null) {
- return { start: slider.start, end: slider.end }
- }
- }
- return initialZoom(data)
- }
- const xMax = computed(() => props.data?.xMax ?? 1)
- const annotationSegments = computed(() => {
- return (props.annotations ?? [])
- .map((annotation) => {
- const range = annotationXRange(annotation)
- return range ? { annotation, ...range } : null
- })
- .filter((item): item is { annotation: Annotation; start: number; end: number } => item !== null)
- .sort((a, b) => a.start - b.start)
- })
- const activeAnnotationIndex = ref(0)
- function focusAnnotation(index: number) {
- const segment = annotationSegments.value[index]
- if (!segment || !chart || !props.data) return
- activeAnnotationIndex.value = index
- const start = Math.max(0, (segment.start / xMax.value) * 100)
- const end = Math.min(100, (segment.end / xMax.value) * 100)
- chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start, end })
- chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start, end })
- }
- function locateFile(pointIndex: number) {
- if (!chart || !props.data) return
- const start = Math.max(0, (pointIndex / xMax.value) * 100)
- const end = Math.min(100, ((pointIndex + 1) / xMax.value) * 100)
- chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start, end })
- chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start, end })
- }
- defineExpose({ locateFile })
- watch(annotationSegments, () => {
- if (activeAnnotationIndex.value >= annotationSegments.value.length) {
- activeAnnotationIndex.value = Math.max(0, annotationSegments.value.length - 1)
- }
- })
- function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
- const data = props.data
- if (!data) return { animation: false }
- const measurementTypes = data.measurementTypes
- const periodBackground = periodAreas(data.cycles)
- const types = props.mode === 'merge' ? ['合并信号'] : [...measurementTypes, 'second_value', '角度', '体积']
- const gridCount = types.length
- const chartHeight = chartElement.value?.clientHeight || 640
- const topInset = 32
- const bottomInset = 68
- const rowGap = props.mode === 'merge' ? 0 : 14
- const rowHeight = props.mode === 'merge'
- ? Math.max(260, chartHeight - topInset - bottomInset)
- : Math.max(76, Math.floor((chartHeight - topInset - bottomInset - rowGap * (gridCount - 1)) / gridCount))
- const grid = Array.from({ length: gridCount }, (_, index) => ({
- left: 70,
- right: 22,
- top: topInset + index * (rowHeight + rowGap),
- height: rowHeight,
- containLabel: false,
- }))
- if (props.mode === 'merge') {
- grid[0] = { left: 70, right: 22, top: topInset, height: rowHeight, containLabel: false }
- }
- const xAxes = grid.map((_, index) => ({
- type: 'value' as const,
- min: data.xMin,
- max: data.xMax,
- gridIndex: index,
- axisLine: { lineStyle: { color: '#b9c6cc' } },
- axisTick: { show: index === gridCount - 1 },
- axisLabel: {
- show: index === gridCount - 1,
- color: '#71808a',
- fontSize: 11,
- formatter: pointAxisLabel,
- },
- boundaryGap: false,
- splitLine: { show: false },
- }))
- const yAxes = types.map((type, index) => ({
- type: 'value' as const,
- gridIndex: index,
- name: type === 'second_value' ? '周期数据' : type,
- nameLocation: 'middle' as const,
- nameGap: 48,
- nameTextStyle: { color: colors[type as MeasurementType | '角度' | '合并信号' | 'second_value' | '体积'], fontWeight: 600 },
- axisLine: { show: true, lineStyle: { color: colors[type as MeasurementType | '角度' | '合并信号' | 'second_value' | '体积'] } },
- axisLabel: { color: '#71808a', fontSize: 11 },
- splitLine: { show: true, lineStyle: { color: '#e7edf0', width: 1 } },
- min: type === '角度' ? 0 : undefined,
- max: type === '角度' ? 180 : undefined,
- }))
- const series: echarts.SeriesOption[] = []
- if (props.mode === 'merge') {
- const normalised = measurementTypes.map((type) => {
- const source = data.series.find((item) => item.measurementType === type)
- const values = source?.data ?? []
- const finiteValues = values.map((item) => item.rawValue).filter(Number.isFinite)
- const [safeMin, safeMax] = finiteValues.length ? minMaxOf(finiteValues) : [0, 1]
- const span = safeMax - safeMin || 1
- return {
- name: type,
- type: 'line' as const,
- z: 10,
- showSymbol: false,
- connectNulls: false,
- sampling: 'lttb' as const,
- lineStyle: { width: 2.5, color: colors[type], cap: 'round' as const, join: 'round' as const },
- itemStyle: { color: colors[type] },
- data: values
- .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
- .map((item) => [item.x, (item.rawValue - safeMin) / span] as [number, number]),
- markArea: { silent: true, data: periodBackground },
- }
- })
- series.push(...normalised)
- const secondValues = data.secondSeries.data
- const secondFiniteValues = secondValues.map((item) => item.rawValue).filter(Number.isFinite)
- const [secondMin, secondMax] = secondFiniteValues.length ? minMaxOf(secondFiniteValues) : [0, 1]
- const secondSpan = secondMax - secondMin || 1
- series.push({
- name: '周期数据',
- type: 'line',
- xAxisIndex: 0,
- yAxisIndex: 0,
- showSymbol: false,
- connectNulls: false,
- lineStyle: { width: 3, color: colors.second_value, cap: 'round' as const, join: 'round' as const },
- areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
- data: secondValues
- .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
- .map((item) => [item.x, (item.rawValue - secondMin) / secondSpan] as [number, number]),
- })
- series.push({
- name: '角度',
- type: 'line',
- xAxisIndex: 0,
- yAxisIndex: 0,
- showSymbol: false,
- lineStyle: { width: 1.5, type: 'dashed', color: colors['角度'], opacity: 0.8 },
- data: data.angleSeries.data
- .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.angle))
- .map((item) => [item.x, item.angle / 180] as [number, number]),
- markLine: { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) },
- })
- const volumeValues = data.volumeSeries.data
- const volumeFinite = volumeValues.map((item) => item.volume).filter(Number.isFinite)
- const [volumeMin, volumeMax] = volumeFinite.length ? minMaxOf(volumeFinite) : [0, 1]
- const volumeSpan = volumeMax - volumeMin || 1
- series.push({
- name: '体积',
- type: 'line',
- xAxisIndex: 0,
- yAxisIndex: 0,
- showSymbol: false,
- lineStyle: { width: 1.5, color: colors['体积'], opacity: 0.9 },
- data: volumeValues
- .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
- .map((item) => [item.x, (item.volume - volumeMin) / volumeSpan] as [number, number]),
- })
- } else {
- const secondIndex = measurementTypes.length
- const angleIndex = secondIndex + 1
- const volumeIndex = secondIndex + 2
- series.push({
- name: '周期数据',
- type: 'line',
- xAxisIndex: secondIndex,
- yAxisIndex: secondIndex,
- showSymbol: false,
- connectNulls: false,
- lineStyle: { width: 3, color: colors.second_value, cap: 'round', join: 'round' },
- areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
- itemStyle: { color: colors.second_value },
- data: data.secondSeries.data
- .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
- .map((item) => [item.x, item.rawValue] as [number, number]),
- markArea: { silent: true, data: periodBackground },
- })
- series.push({
- name: '角度',
- type: 'line',
- xAxisIndex: angleIndex,
- yAxisIndex: angleIndex,
- showSymbol: false,
- lineStyle: { width: 2, color: colors['角度'] },
- itemStyle: { color: colors['角度'] },
- data: data.angleSeries.data
- .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.angle))
- .map((item) => [item.x, item.angle] as [number, number]),
- markArea: { silent: true, data: periodBackground },
- markLine: { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) },
- })
- series.push({
- name: '体积',
- type: 'line',
- xAxisIndex: volumeIndex,
- yAxisIndex: volumeIndex,
- showSymbol: false,
- lineStyle: { width: 2, color: colors['体积'] },
- itemStyle: { color: colors['体积'] },
- data: data.volumeSeries.data
- .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.volume))
- .map((item) => [item.x, item.volume] as [number, number]),
- markArea: { silent: true, data: periodBackground },
- })
- measurementTypes.forEach((type, index) => {
- const source = data.series.find((item) => item.measurementType === type)
- series.push({
- name: type,
- type: 'line',
- xAxisIndex: index,
- yAxisIndex: index,
- showSymbol: false,
- connectNulls: false,
- sampling: 'lttb' as const,
- lineStyle: { width: 2.5, color: colors[type], cap: 'round', join: 'round' },
- itemStyle: { color: colors[type] },
- data: source?.data
- .filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
- .map((item) => [item.x, item.rawValue] as [number, number]) ?? [],
- markArea: { silent: true, data: periodBackground },
- markLine: index === 0 ? { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) } : undefined,
- })
- })
- }
- const zoom = zoomMode === 'keep' ? readCurrentZoom(data) : initialZoom(data)
- series.push(...buildAnnotationOverlaySeries(props.annotations ?? []))
- return {
- animation: false,
- color: measurementTypes.map((type) => colors[type]),
- grid,
- xAxis: xAxes,
- yAxis: yAxes,
- series,
- tooltip: {
- trigger: 'axis',
- axisPointer: { type: 'cross', snap: false },
- backgroundColor: '#162b3c',
- borderWidth: 0,
- textStyle: { color: '#f7fafb', fontSize: 11 },
- formatter: (params: any[]) => {
- if (!params?.length) return ''
- const first = params[0]
- const lines = [`<strong>窗口位置:${Number(first.value?.[0] ?? 0).toFixed(4)}</strong>`]
- params.forEach((item) => {
- const value = item.value?.[1]
- if (value !== undefined && value !== null) {
- lines.push(`<span style="color:${item.color}">●</span> ${item.seriesName}: ${Number(value).toPrecision(7)}`)
- }
- })
- return lines.join('<br/>')
- },
- },
- legend: {
- data: [...measurementTypes, '周期数据', '角度', '体积'],
- top: 0,
- left: 70,
- itemWidth: 16,
- itemHeight: 7,
- textStyle: { color: '#60717b', fontSize: 11 },
- },
- dataZoom: [
- { type: 'inside', xAxisIndex: xAxes.map((_, index) => index), zoomOnMouseWheel: true, moveOnMouseMove: true, start: zoom.start, end: zoom.end },
- {
- type: 'slider',
- xAxisIndex: xAxes.map((_, index) => index),
- bottom: 8,
- height: 36,
- borderColor: '#d9e2e5',
- backgroundColor: '#f2f5f6',
- fillerColor: 'rgba(31, 122, 140, 0.16)',
- handleStyle: { color: '#1f7a8c' },
- textStyle: { color: '#71808a', fontSize: 10 },
- start: zoom.start,
- end: zoom.end,
- },
- ],
- graphic: props.mode === 'merge' ? [
- {
- type: 'text',
- left: 70,
- top: 31,
- style: { text: '归一化值', fill: '#8b9aa2', fontSize: 11 },
- },
- ] : [],
- }
- }
- function drawPvPreviews() {
- if (pvFrame != null) cancelAnimationFrame(pvFrame)
- pvFrame = requestAnimationFrame(() => {
- pvFrame = undefined
- const canvas = pvCanvas.value
- const shell = pvShell.value
- const data = props.data
- if (!canvas || !shell || !data || !chart) return
- const height = 132
- const pressure = data.series.find((series) => series.measurementType === '压力')?.data ?? []
- const xPixel = (value: number) => Number(chart?.convertToPixel({ xAxisIndex: 0 }, value))
- const zoom = readCurrentZoom(data)
- const visibleMin = data.xMin + (data.xMax - data.xMin) * zoom.start / 100
- const visibleMax = data.xMin + (data.xMax - data.xMin) * zoom.end / 100
- const chartLeft = xPixel(visibleMin)
- const chartRight = xPixel(visibleMax)
- if (!Number.isFinite(chartLeft) || !Number.isFinite(chartRight)) return
- const plotLeft = Math.min(chartLeft, chartRight)
- const plotRight = Math.max(chartLeft, chartRight)
- const width = Math.max(plotRight - plotLeft, 1)
- shell.style.marginLeft = `${plotLeft}px`
- shell.style.width = `${width}px`
- const ratio = Math.min(window.devicePixelRatio || 1, 2)
- canvas.width = width * ratio
- canvas.height = height * ratio
- canvas.style.width = `${width}px`
- canvas.style.height = `${height}px`
- const context = canvas.getContext('2d')
- if (!context) return
- context.setTransform(ratio, 0, 0, ratio, 0, 0)
- context.clearRect(0, 0, width, height)
- context.fillStyle = '#f8fafb'
- context.fillRect(0, 0, width, height)
- if (!pressure.length || !data.cycles.length) return
- const localX = (value: number) => xPixel(value) - plotLeft
- const groups = new Map<number, typeof pressure>()
- pressure.forEach((point) => {
- if (point.volume == null || !Number.isFinite(point.volume) || !Number.isFinite(point.rawValue)) return
- const list = groups.get(point.waveFileId) ?? []
- list.push(point)
- groups.set(point.waveFileId, list)
- })
- const lowerBound = (points: typeof pressure, sampleIndex: number) => {
- let low = 0
- let high = points.length
- while (low < high) {
- const middle = (low + high) >> 1
- if (points[middle].sampleIndex < sampleIndex) low = middle + 1
- else high = middle
- }
- return low
- }
- context.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'
- context.textAlign = 'center'
- data.cycles.forEach((cycle, cycleIndex) => {
- const left = localX(cycle.startX)
- const right = localX(cycle.endX)
- const cycleWidth = Math.abs(right - left)
- if (!Number.isFinite(left) || !Number.isFinite(right)) return
- const cellLeft = Math.min(left, right)
- const cellRight = Math.max(left, right)
- const clippedLeft = Math.max(0, cellLeft)
- const clippedRight = Math.min(width, cellRight)
- if (clippedRight <= clippedLeft) return
- context.fillStyle = cycleIndex % 2 === 0 ? 'rgba(213, 155, 43, .075)' : 'rgba(15, 29, 43, .09)'
- context.fillRect(clippedLeft, 0, clippedRight - clippedLeft, height)
- context.strokeStyle = 'rgba(213, 155, 43, .25)'
- context.strokeRect(cellLeft + .5, .5, Math.max(0, cycleWidth - 1), height - 1)
- if (cycleWidth < 100) return
- const filePoints = groups.get(cycle.waveFileId) ?? []
- const start = lowerBound(filePoints, cycle.startSampleIndex)
- const end = lowerBound(filePoints, cycle.endSampleIndex + 1)
- const points = filePoints.slice(start, end)
- if (points.length < 2) return
- const volumes = points.map((point) => point.volume as number)
- const pressures = points.map((point) => point.rawValue)
- const [minVolume, maxVolume] = niceAxisExtent(volumes)
- const [minPressure, maxPressure] = niceAxisExtent(pressures)
- const volumeSpan = maxVolume - minVolume || 1
- const pressureSpan = maxPressure - minPressure || 1
- const frameWidth = Math.min(cycleWidth, height * modalChartWidth / modalChartHeight)
- const frameHeight = frameWidth * modalChartHeight / modalChartWidth
- const frameLeft = cellLeft + (cycleWidth - frameWidth) / 2
- const frameTop = (height - frameHeight) / 2
- const graphLeft = frameLeft + frameWidth * 64 / modalChartWidth
- const graphRight = frameLeft + frameWidth * (modalChartWidth - 28) / modalChartWidth
- const graphTop = frameTop + frameHeight * 38 / modalChartHeight
- const graphBottom = frameTop + frameHeight * (modalChartHeight - 52) / modalChartHeight
- const graphHeight = graphBottom - graphTop
- context.save()
- context.beginPath()
- context.rect(cellLeft, 0, cycleWidth, height)
- context.clip()
- context.lineWidth = 1
- pvPhaseColors.forEach((phase) => {
- const phasePoints = points.filter((point) => (
- point.angle360 != null
- && point.angle360 >= phase.start
- && point.angle360 < phase.end
- ))
- if (phasePoints.length < 2) return
- context.strokeStyle = phase.color
- context.beginPath()
- phasePoints.forEach((point, index) => {
- const x = graphLeft + (((point.volume as number) - minVolume) / volumeSpan) * (graphRight - graphLeft)
- const y = graphBottom - ((point.rawValue - minPressure) / pressureSpan) * graphHeight
- if (index === 0) context.moveTo(x, y)
- else context.lineTo(x, y)
- })
- context.stroke()
- })
- context.restore()
- context.fillStyle = '#788892'
- context.fillText(`P-V ${cycle.periodNo}`, (left + right) / 2, 12)
- })
- })
- }
- function onDoubleClick(params: any) {
- if (!props.data || !chart || !props.data.cycles.length) return
- if (params.componentType !== 'series') return
- const offsetX = params?.event?.offsetX
- if (typeof offsetX !== 'number') return
- const coordinate = chart.convertFromPixel({ gridIndex: 0 }, [offsetX, params.event.offsetY ?? 0]) as number[]
- const x = coordinate?.[0]
- if (typeof x !== 'number') return
- const cycle = props.data.cycles.find((item) => x >= item.startX && x <= item.endX)
- if (cycle) emit('periodDblclick', cycle)
- }
- function onBlankDoubleClick(event: any) {
- if (!props.data || !chart || !props.data.cycles.length) return
- if (event.target) return
- const px = event.offsetX
- const py = event.offsetY
- if (typeof px !== 'number' || typeof py !== 'number') return
- const gridCount = props.mode === 'merge' ? 1 : props.data.measurementTypes.length + 3
- let inGrid = false
- for (let index = 0; index < gridCount; index += 1) {
- if (chart.containPixel({ gridIndex: index }, [px, py])) {
- inGrid = true
- break
- }
- }
- if (!inGrid) return
- const coordinate = chart.convertFromPixel({ gridIndex: 0 }, [px, py]) as number[]
- const x = coordinate?.[0]
- if (typeof x !== 'number') return
- const cycle = props.data.cycles.find((item) => x >= item.startX && x <= item.endX)
- if (cycle) emit('annotationToggle', cycle)
- }
- function renderFull(zoomMode: 'initial' | 'keep' = 'initial') {
- if (!chart) return
- chart.setOption(buildOption(zoomMode), true)
- chart.resize()
- drawPvPreviews()
- }
- function updateAnnotationOverlay() {
- if (!chart || !props.data) return
- chart.setOption({ series: buildAnnotationOverlaySeries(props.annotations ?? []) }, false)
- }
- function resize() {
- chart?.resize()
- drawPvPreviews()
- }
- watch(
- () => props.data,
- () => nextTick(() => renderFull('initial')),
- { deep: true },
- )
- watch(
- () => props.mode,
- () => nextTick(() => renderFull('keep')),
- )
- watch(
- () => props.annotations,
- () => nextTick(updateAnnotationOverlay),
- { deep: true },
- )
- onMounted(() => {
- if (!chartElement.value) return
- chart = echarts.init(chartElement.value, undefined, { renderer: 'canvas' })
- chart.on('dblclick', onDoubleClick)
- chart.on('dataZoom', drawPvPreviews)
- chart.getZr().on('dblclick', onBlankDoubleClick)
- window.addEventListener('resize', resize)
- renderFull('initial')
- })
- onBeforeUnmount(() => {
- window.removeEventListener('resize', resize)
- chart?.dispose()
- if (pvFrame != null) cancelAnimationFrame(pvFrame)
- })
- </script>
- <template>
- <div class="wave-chart-shell">
- <div class="chart-toolbar-note">
- <span class="chart-dot"></span>
- <span>双击曲线查看功图,双击空白处标注 / 取消标注</span>
- <span class="chart-separator"></span>
- <span>滚轮缩放,底部滑轨横向浏览</span>
- <span class="chart-point-count">已加载 {{ plottedPointCount.toLocaleString() }} 点<span v-if="secondValueStatus"> · {{ secondValueStatus }}</span></span>
- </div>
- <div ref="pvShell" class="pv-preview-shell">
- <div class="pv-preview-title">压力-体积功图 · 当前可视周期</div>
- <canvas ref="pvCanvas" class="pv-preview-canvas" aria-label="压力-体积功图缩略预览"></canvas>
- </div>
- <div ref="chartElement" class="wave-chart" :class="{ 'is-merge': mode === 'merge' }"></div>
- <div v-if="annotations?.length" class="annotation-nav">
- <div class="annotation-nav-head">
- <span class="annotation-nav-title">标注导航</span>
- <div class="annotation-nav-controls">
- <el-button class="ghost-button" size="small" plain :disabled="activeAnnotationIndex <= 0" @click="focusAnnotation(activeAnnotationIndex - 1)">上一标注</el-button>
- <span class="annotation-nav-counter">{{ annotationSegments.length ? `${activeAnnotationIndex + 1} / ${annotationSegments.length}` : '0 / 0' }}</span>
- <el-button class="ghost-button" size="small" plain :disabled="activeAnnotationIndex >= annotationSegments.length - 1" @click="focusAnnotation(activeAnnotationIndex + 1)">下一标注</el-button>
- </div>
- </div>
- <div class="annotation-nav-track">
- <div
- v-for="(segment, index) in annotationSegments"
- :key="segment.annotation.id"
- class="annotation-nav-seg"
- :class="{
- normal: segment.annotation.label === '正常',
- abnormal: segment.annotation.label === '异常',
- active: index === activeAnnotationIndex,
- }"
- :style="{
- left: `${(segment.start / xMax) * 100}%`,
- width: `${Math.max(((segment.end - segment.start) / xMax) * 100, 0.4)}%`,
- }"
- :title="`${segment.annotation.label} · 周期 ${segment.annotation.periodStart}—${segment.annotation.periodEnd} · 点 ${segment.annotation.sampleIndexStart}—${segment.annotation.sampleIndexEnd}`"
- @click="focusAnnotation(index)"
- />
- </div>
- <div class="annotation-nav-legend">
- <span><i class="annotation-swatch normal"></i>正常样本</span>
- <span><i class="annotation-swatch abnormal"></i>异常样本</span>
- </div>
- </div>
- <div v-if="loading" class="chart-loading">正在整理波形数据…</div>
- <div v-else-if="!data" class="chart-empty-hint">请点击「查询图表」加载波形数据。</div>
- <div v-else-if="data && !hasPlottableData" class="chart-empty-hint">当前窗口没有可绘制的波形数据,请检查时间点和数据名称选择。</div>
- <div v-if="data && !data.cycles.length" class="chart-empty-hint">当前窗口未检测到完整周期,仍可查看原始波形。</div>
- <div v-if="data?.firstCycleNotice" class="chart-empty-hint">{{ data.firstCycleNotice }}</div>
- </div>
- </template>
|