|
|
@@ -1,7 +1,7 @@
|
|
|
<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'
|
|
|
+import type { Annotation, Cycle, TimePoint, WaveWindowResponse } from '../types'
|
|
|
import { fixedAxisRange } from '../utils/axis'
|
|
|
|
|
|
const props = defineProps<{
|
|
|
@@ -19,17 +19,17 @@ const emit = defineEmits<{
|
|
|
|
|
|
const chartElement = ref<HTMLDivElement | null>(null)
|
|
|
let chart: echarts.ECharts | undefined
|
|
|
-const pvCanvas = ref<HTMLCanvasElement | null>(null)
|
|
|
const pvShell = ref<HTMLDivElement | null>(null)
|
|
|
+const pvCanvases = ref<(HTMLCanvasElement | null)[]>([])
|
|
|
+const pvRowEls = ref<(HTMLElement | null)[]>([])
|
|
|
let pvFrame: number | undefined
|
|
|
|
|
|
-const colors: Record<MeasurementType | '角度' | '合并信号' | 'second_value' | '体积', string> = {
|
|
|
+const colors: Record<string, string> = {
|
|
|
压力: '#e05252',
|
|
|
位移: '#287f9e',
|
|
|
加速度: '#7656a5',
|
|
|
- 角度: '#c58b24',
|
|
|
合并信号: '#287f9e',
|
|
|
- second_value: '#f56c6c',
|
|
|
+ 周期数据: '#f56c6c',
|
|
|
体积: '#4d9e6f',
|
|
|
}
|
|
|
|
|
|
@@ -56,11 +56,18 @@ function niceAxisExtent(values: number[]): [number, number] {
|
|
|
return [Math.floor(min / interval) * interval, Math.ceil(max / interval) * interval]
|
|
|
}
|
|
|
|
|
|
-function fixedYAxis(type: string) {
|
|
|
+function fixedYAxis(devicePoint: string) {
|
|
|
if (props.mode === 'merge') return null
|
|
|
- if (type !== '压力' && type !== '位移' && type !== '加速度') return null
|
|
|
- const series = props.data?.series.find((item) => item.measurementType === type)
|
|
|
- return fixedAxisRange(type, series?.min, series?.max)
|
|
|
+ const series = props.data?.series.find((item) => item.devicePoint === devicePoint)
|
|
|
+ if (!series) return null
|
|
|
+ return fixedAxisRange(series.measurementType, series.min, series.max)
|
|
|
+}
|
|
|
+
|
|
|
+function splitExtraCount(data: WaveWindowResponse): number {
|
|
|
+ let count = 0
|
|
|
+ if (data.secondSeries.data.length) count += 1
|
|
|
+ if (data.volumeSeries.data.length || data.volumeSeries.info) count += 1
|
|
|
+ return count
|
|
|
}
|
|
|
|
|
|
const plottedPointCount = computed(() => (
|
|
|
@@ -76,8 +83,21 @@ const secondValueStatus = computed(() => {
|
|
|
return `周期数据:有效 ${series.finiteCount.toLocaleString()} 点 / 非零 ${series.nonZeroCount.toLocaleString()} 点`
|
|
|
})
|
|
|
|
|
|
-function formatTime(value: string | undefined) {
|
|
|
- return value?.replace('T', ' ').slice(11, 19) ?? ''
|
|
|
+const pvPointRows = computed(() => (
|
|
|
+ (props.data?.series
|
|
|
+ .filter((series) => series.measurementType === '压力')
|
|
|
+ .map((series) => ({
|
|
|
+ devicePoint: series.devicePoint,
|
|
|
+ title: `${series.devicePoint} · 压力-体积功图 · 当前可视周期`,
|
|
|
+ })) ?? [])
|
|
|
+))
|
|
|
+
|
|
|
+function setPvCanvas(el: unknown, index: number) {
|
|
|
+ pvCanvases.value[index] = el as HTMLCanvasElement
|
|
|
+}
|
|
|
+
|
|
|
+function setPvRow(el: unknown, index: number) {
|
|
|
+ pvRowEls.value[index] = el as HTMLElement
|
|
|
}
|
|
|
|
|
|
function minMaxOf(values: number[]): [number, number] {
|
|
|
@@ -95,8 +115,8 @@ 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 sourceDevicePoint = props.data?.secondSeries.sourceDevicePoint
|
|
|
+ const fileId = sourceDevicePoint ? point.files[sourceDevicePoint]?.id : undefined
|
|
|
const time = point.sampleTime.replace('T', ' ').slice(0, 19)
|
|
|
return `${fileId ?? ''}\n${time}`
|
|
|
}
|
|
|
@@ -150,13 +170,14 @@ function annotationAreas(annotations: Annotation[]): any[] {
|
|
|
function buildAnnotationOverlaySeries(annotations: Annotation[]): echarts.SeriesOption[] {
|
|
|
const data = props.data
|
|
|
if (!data) return []
|
|
|
- const gridCount = props.mode === 'merge' ? 1 : data.measurementTypes.length + 2
|
|
|
+ const gridCount = props.mode === 'merge' ? 1 : data.devicePoints.length + splitExtraCount(data)
|
|
|
const areas = annotationAreas(annotations)
|
|
|
return Array.from({ length: gridCount }, (_, index) => ({
|
|
|
id: `annotation-overlay-${index}`,
|
|
|
type: 'line' as const,
|
|
|
xAxisIndex: index,
|
|
|
yAxisIndex: index,
|
|
|
+ z: -10,
|
|
|
silent: true,
|
|
|
data: [],
|
|
|
markArea: { silent: true, data: areas },
|
|
|
@@ -164,14 +185,12 @@ function buildAnnotationOverlaySeries(annotations: Annotation[]): echarts.Series
|
|
|
}
|
|
|
|
|
|
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 }
|
|
|
+ // A point series can occupy a later exact-timestamp slot than the primary
|
|
|
+ // series. Starting at the first cycle would hide that series and its PV row
|
|
|
+ // while axis tooltips still report its values. Always start with the full
|
|
|
+ // selected window; users can zoom in after all selected points are visible.
|
|
|
+ void data
|
|
|
+ return { start: 0, end: 100 }
|
|
|
}
|
|
|
|
|
|
function readCurrentZoom(data: WaveWindowResponse): { start: number; end: number } {
|
|
|
@@ -228,10 +247,19 @@ watch(annotationSegments, () => {
|
|
|
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 devicePoints = data.devicePoints
|
|
|
+ const primaryCycles = data.cycles.filter((cycle) => cycle.devicePoint === data.primaryPoint)
|
|
|
+ const backgroundCycles = data.cycles.filter((cycle) => cycle.background).length
|
|
|
+ ? data.cycles.filter((cycle) => cycle.background)
|
|
|
+ : primaryCycles
|
|
|
+ const periodBackground = periodAreas(backgroundCycles)
|
|
|
+ const showSecond = data.secondSeries.data.length > 0
|
|
|
+ const showVolume = data.volumeSeries.data.length > 0 || data.volumeSeries.info != null
|
|
|
+ const extraRows: string[] = []
|
|
|
+ if (showSecond) extraRows.push('周期数据')
|
|
|
+ if (showVolume) extraRows.push('体积')
|
|
|
+ const rows = props.mode === 'merge' ? ['合并信号'] : [...devicePoints, ...extraRows]
|
|
|
+ const gridCount = rows.length
|
|
|
const chartHeight = chartElement.value?.clientHeight || 640
|
|
|
const topInset = 32
|
|
|
const bottomInset = 68
|
|
|
@@ -265,123 +293,109 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
|
|
|
boundaryGap: false,
|
|
|
splitLine: { show: false },
|
|
|
}))
|
|
|
- const yAxes = types.map((type, index) => {
|
|
|
- const fixed = fixedYAxis(type)
|
|
|
+ const yAxes = rows.map((rowName, index) => {
|
|
|
+ const isDevicePoint = (devicePoints as readonly string[]).includes(rowName)
|
|
|
+ const seriesColor = isDevicePoint
|
|
|
+ ? (data.series.find((series) => series.devicePoint === rowName)?.color ?? '#60717b')
|
|
|
+ : (colors[rowName] ?? '#60717b')
|
|
|
+ const fixed = isDevicePoint ? fixedYAxis(rowName) : null
|
|
|
+ const isDisplacement = isDevicePoint
|
|
|
+ ? data.series.find((series) => series.devicePoint === rowName)?.measurementType === '位移'
|
|
|
+ : false
|
|
|
return {
|
|
|
type: 'value' as const,
|
|
|
gridIndex: index,
|
|
|
- name: type === 'second_value' ? '周期数据' : type,
|
|
|
+ name: rowName,
|
|
|
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' | '体积'] } },
|
|
|
+ nameTextStyle: { color: seriesColor, fontWeight: 600 },
|
|
|
+ axisLine: { show: true, lineStyle: { color: seriesColor } },
|
|
|
axisLabel: { color: '#71808a', fontSize: 11 },
|
|
|
splitLine: { show: true, lineStyle: { color: '#e7edf0', width: 1 } },
|
|
|
- scale: type === '位移',
|
|
|
+ scale: isDisplacement,
|
|
|
min: fixed?.min,
|
|
|
max: fixed?.max,
|
|
|
interval: fixed?.interval,
|
|
|
}
|
|
|
})
|
|
|
const series: echarts.SeriesOption[] = []
|
|
|
+ const deviceColor = (devicePoint: string) => data.series.find((series) => series.devicePoint === devicePoint)?.color ?? colors[data.series.find((series) => series.devicePoint === devicePoint)?.measurementType ?? '加速度']
|
|
|
if (props.mode === 'merge') {
|
|
|
- const normalised = measurementTypes.map((type) => {
|
|
|
- const source = data.series.find((item) => item.measurementType === type)
|
|
|
+ devicePoints.forEach((devicePoint) => {
|
|
|
+ const source = data.series.find((series) => series.devicePoint === devicePoint)
|
|
|
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,
|
|
|
+ const color = source?.color ?? colors[source?.measurementType ?? '加速度']
|
|
|
+ series.push({
|
|
|
+ name: devicePoint,
|
|
|
type: 'line' as const,
|
|
|
z: 10,
|
|
|
+ xAxisIndex: 0,
|
|
|
+ yAxisIndex: 0,
|
|
|
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] },
|
|
|
+ lineStyle: { width: 2.5, color, cap: 'round' as const, join: 'round' as const },
|
|
|
+ itemStyle: { color },
|
|
|
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]),
|
|
|
- })
|
|
|
- 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]),
|
|
|
+ })
|
|
|
})
|
|
|
+ if (showSecond) {
|
|
|
+ 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['周期数据'], 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]),
|
|
|
+ })
|
|
|
+ }
|
|
|
+ if (showVolume) {
|
|
|
+ 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 volumeIndex = secondIndex + 1
|
|
|
- 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: 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)
|
|
|
+ devicePoints.forEach((devicePoint, index) => {
|
|
|
+ const source = data.series.find((series) => series.devicePoint === devicePoint)
|
|
|
+ const color = source?.color ?? colors[source?.measurementType ?? '加速度']
|
|
|
series.push({
|
|
|
- name: type,
|
|
|
+ name: devicePoint,
|
|
|
type: 'line',
|
|
|
+ z: 10,
|
|
|
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] },
|
|
|
+ lineStyle: { width: 2.5, color, cap: 'round', join: 'round' },
|
|
|
+ itemStyle: { color },
|
|
|
data: source?.data
|
|
|
.filter((item) => Number.isFinite(item.x) && Number.isFinite(item.rawValue))
|
|
|
.map((item) => [item.x, item.rawValue] as [number, number]) ?? [],
|
|
|
@@ -389,6 +403,42 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
|
|
|
markLine: index === 0 ? { silent: true, symbol: 'none', data: triggerLines(data.triggerXs) } : undefined,
|
|
|
})
|
|
|
})
|
|
|
+ if (showSecond) {
|
|
|
+ const secondIndex = devicePoints.length
|
|
|
+ series.push({
|
|
|
+ name: '周期数据',
|
|
|
+ type: 'line',
|
|
|
+ z: 10,
|
|
|
+ xAxisIndex: secondIndex,
|
|
|
+ yAxisIndex: secondIndex,
|
|
|
+ showSymbol: false,
|
|
|
+ connectNulls: false,
|
|
|
+ lineStyle: { width: 3, color: colors['周期数据'], cap: 'round', join: 'round' },
|
|
|
+ areaStyle: { color: 'rgba(245, 108, 108, 0.16)' },
|
|
|
+ itemStyle: { color: colors['周期数据'] },
|
|
|
+ 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 },
|
|
|
+ })
|
|
|
+ }
|
|
|
+ if (showVolume) {
|
|
|
+ const volumeIndex = devicePoints.length + (showSecond ? 1 : 0)
|
|
|
+ series.push({
|
|
|
+ name: '体积',
|
|
|
+ type: 'line',
|
|
|
+ z: 10,
|
|
|
+ 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 },
|
|
|
+ })
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
const zoom = zoomMode === 'keep' ? readCurrentZoom(data) : initialZoom(data)
|
|
|
@@ -396,7 +446,7 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
|
|
|
|
|
|
return {
|
|
|
animation: false,
|
|
|
- color: measurementTypes.map((type) => colors[type]),
|
|
|
+ color: devicePoints.map(deviceColor),
|
|
|
grid,
|
|
|
xAxis: xAxes,
|
|
|
yAxis: yAxes,
|
|
|
@@ -421,7 +471,7 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
|
|
|
},
|
|
|
},
|
|
|
legend: {
|
|
|
- data: [...measurementTypes, '周期数据', '体积'],
|
|
|
+ data: [...devicePoints, ...extraRows],
|
|
|
top: 0,
|
|
|
left: 70,
|
|
|
itemWidth: 16,
|
|
|
@@ -459,118 +509,123 @@ 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 pressureSeries = data.series.find((series) => series.measurementType === '压力')
|
|
|
- const pressure = pressureSeries?.data ?? []
|
|
|
- const xPixel = (value: number) => Number(chart?.convertToPixel({ xAxisIndex: 0 }, value))
|
|
|
+ if (!chart || !data) return
|
|
|
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 xPixel = (value: number) => Number(chart?.convertToPixel({ xAxisIndex: 0 }, value))
|
|
|
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
|
|
|
+
|
|
|
+ pvPointRows.value.forEach((row, rowIndex) => {
|
|
|
+ const canvas = pvCanvases.value[rowIndex]
|
|
|
+ const rowEl = pvRowEls.value[rowIndex]
|
|
|
+ if (!canvas || !rowEl) return
|
|
|
+ const series = data.series.find((series) => series.devicePoint === row.devicePoint)
|
|
|
+ const pressure = series?.data ?? []
|
|
|
+ const pointCycles = data.cycles.filter((cycle) => cycle.devicePoint === row.devicePoint)
|
|
|
+ rowEl.style.marginLeft = `${plotLeft}px`
|
|
|
+ rowEl.style.width = `${width}px`
|
|
|
+ const height = 132
|
|
|
+ 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 || !pointCycles.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
|
|
|
}
|
|
|
- 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 fixedPressure = fixedAxisRange('压力', pressureSeries?.min, pressureSeries?.max)
|
|
|
- const [minPressure, maxPressure] = fixedPressure ? [fixedPressure.min, fixedPressure.max] : 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.font = '10px -apple-system, BlinkMacSystemFont, sans-serif'
|
|
|
+ context.textAlign = 'center'
|
|
|
+ pointCycles.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 fixedPressure = fixedAxisRange('压力', series?.min, series?.max)
|
|
|
+ const [minPressure, maxPressure] = fixedPressure ? [fixedPressure.min, fixedPressure.max] : 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()
|
|
|
- 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.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.stroke()
|
|
|
+ context.restore()
|
|
|
+ context.fillStyle = '#788892'
|
|
|
+ context.fillText(`P-V ${cycle.periodNo}`, (left + right) / 2, 12)
|
|
|
})
|
|
|
- context.restore()
|
|
|
- context.fillStyle = '#788892'
|
|
|
- context.fillText(`P-V ${cycle.periodNo}`, (left + right) / 2, 12)
|
|
|
})
|
|
|
})
|
|
|
}
|
|
|
@@ -593,7 +648,7 @@ function onBlankDoubleClick(event: any) {
|
|
|
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
|
|
|
+ const gridCount = props.mode === 'merge' ? 1 : props.data.devicePoints.length + splitExtraCount(props.data) + 1
|
|
|
let inGrid = false
|
|
|
for (let index = 0; index < gridCount; index += 1) {
|
|
|
if (chart.containPixel({ gridIndex: index }, [px, py])) {
|
|
|
@@ -612,6 +667,13 @@ function onBlankDoubleClick(event: any) {
|
|
|
function renderFull(zoomMode: 'initial' | 'keep' = 'initial') {
|
|
|
if (!chart) return
|
|
|
chart.setOption(buildOption(zoomMode), true)
|
|
|
+ if (zoomMode === 'initial') {
|
|
|
+ // ECharts can retain the previous slider state even with a replaced
|
|
|
+ // option. Reset both zoom components so later exact-timestamp slots are
|
|
|
+ // not clipped after adding another device point.
|
|
|
+ chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: 0, end: 100 })
|
|
|
+ chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 })
|
|
|
+ }
|
|
|
chart.resize()
|
|
|
drawPvPreviews()
|
|
|
}
|
|
|
@@ -668,8 +730,15 @@ onBeforeUnmount(() => {
|
|
|
<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
|
|
|
+ v-for="(row, rowIndex) in pvPointRows"
|
|
|
+ :key="row.devicePoint"
|
|
|
+ :ref="(el) => setPvRow(el, rowIndex)"
|
|
|
+ class="pv-preview-row"
|
|
|
+ >
|
|
|
+ <div class="pv-preview-title">{{ row.title }}</div>
|
|
|
+ <canvas :ref="(el) => setPvCanvas(el, rowIndex)" class="pv-preview-canvas" :aria-label="row.title"></canvas>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<div ref="chartElement" class="wave-chart" :class="{ 'is-merge': mode === 'merge' }"></div>
|
|
|
<div v-if="annotations?.length" class="annotation-nav">
|
|
|
@@ -706,7 +775,7 @@ onBeforeUnmount(() => {
|
|
|
</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-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>
|