|
|
@@ -0,0 +1,557 @@
|
|
|
+<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 colors: Record<MeasurementType | '角度' | '合并信号' | 'second_value' | '体积', string> = {
|
|
|
+ 压力: '#e05252',
|
|
|
+ 位移: '#287f9e',
|
|
|
+ 加速度: '#7656a5',
|
|
|
+ 角度: '#c58b24',
|
|
|
+ 合并信号: '#287f9e',
|
|
|
+ second_value: '#f56c6c',
|
|
|
+ 体积: '#4d9e6f',
|
|
|
+}
|
|
|
+
|
|
|
+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 ''
|
|
|
+ return `${index + 1}\n${formatTime(props.points[index].sampleTime)}`
|
|
|
+}
|
|
|
+
|
|
|
+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 })
|
|
|
+}
|
|
|
+
|
|
|
+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 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()
|
|
|
+}
|
|
|
+
|
|
|
+function updateAnnotationOverlay() {
|
|
|
+ if (!chart || !props.data) return
|
|
|
+ chart.setOption({ series: buildAnnotationOverlaySeries(props.annotations ?? []) }, false)
|
|
|
+}
|
|
|
+
|
|
|
+function resize() {
|
|
|
+ chart?.resize()
|
|
|
+}
|
|
|
+
|
|
|
+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.getZr().on('dblclick', onBlankDoubleClick)
|
|
|
+ window.addEventListener('resize', resize)
|
|
|
+ renderFull('initial')
|
|
|
+})
|
|
|
+
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ window.removeEventListener('resize', resize)
|
|
|
+ chart?.dispose()
|
|
|
+})
|
|
|
+</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="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 && !hasPlottableData" class="chart-empty-hint">当前窗口没有可绘制的波形数据,请检查时间点和数据名称选择。</div>
|
|
|
+ <div v-if="data && !data.cycles.length" class="chart-empty-hint">当前窗口未检测到完整周期,仍可查看原始波形。</div>
|
|
|
+ </div>
|
|
|
+</template>
|