|
|
@@ -1,119 +1,275 @@
|
|
|
<script setup lang="ts">
|
|
|
import * as echarts from 'echarts'
|
|
|
-import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
-import type { PeriodDetail } from '../types'
|
|
|
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
|
+import type { PeriodDetail, PeriodSideDetail } from '../types'
|
|
|
|
|
|
const props = defineProps<{
|
|
|
detail: PeriodDetail | null
|
|
|
visible: boolean
|
|
|
loading?: boolean
|
|
|
- pressureAxis?: { min: number; max: number } | null
|
|
|
}>()
|
|
|
|
|
|
const emit = defineEmits<{
|
|
|
close: []
|
|
|
}>()
|
|
|
|
|
|
-const chartElement = ref<HTMLDivElement | null>(null)
|
|
|
-const chartMode = ref<'angle' | 'volume'>('volume')
|
|
|
-let chart: echarts.ECharts | undefined
|
|
|
-
|
|
|
-function render() {
|
|
|
- if (!chart || !props.detail) return
|
|
|
- const detail = props.detail
|
|
|
- const volumeMode = chartMode.value === 'volume' && detail.volume
|
|
|
-
|
|
|
- const series: echarts.SeriesOption[] = detail.phases.map((phase, phaseIndex) => {
|
|
|
- const isLast = phaseIndex === detail.phases.length - 1
|
|
|
- const data: Array<{ value: [number, number]; idx: number }> = []
|
|
|
- detail.angles360.forEach((angle360, index) => {
|
|
|
- const x = volumeMode ? detail.volume?.[index] : detail.angles[index]
|
|
|
- if (x == null || !Number.isFinite(x)) return
|
|
|
- const inPhase = isLast ? angle360 >= phase.start : angle360 >= phase.start && angle360 < phase.end
|
|
|
- if (inPhase) data.push({ value: [x, detail.pressure[index]], idx: index })
|
|
|
- })
|
|
|
- return {
|
|
|
- name: phase.name,
|
|
|
- type: 'line' as const,
|
|
|
- showSymbol: false,
|
|
|
- lineStyle: { color: phase.color, width: 1.6 },
|
|
|
- itemStyle: { color: phase.color },
|
|
|
- data,
|
|
|
+const pvElement = ref<HTMLDivElement | null>(null)
|
|
|
+const angleElement = ref<HTMLDivElement | null>(null)
|
|
|
+let pvChart: echarts.ECharts | undefined
|
|
|
+let angleChart: echarts.ECharts | undefined
|
|
|
+
|
|
|
+const CHART_RATIO = 762 / 1052
|
|
|
+const SIDE_COLORS: Record<string, string> = {
|
|
|
+ 压力盖侧: '#1f7a8c',
|
|
|
+ 压力轴侧: '#e4572e',
|
|
|
+}
|
|
|
+const CHART_TITLES = {
|
|
|
+ pv: '压力-体积(P-V图)',
|
|
|
+ angle: '压力-角度(P-θ图)',
|
|
|
+} as const
|
|
|
+const PHASE_LINE_STYLES: Array<{ type: 'solid' | number[]; opacity: number }> = [
|
|
|
+ { type: 'solid', opacity: 1 },
|
|
|
+ { type: [26, 2], opacity: 0.96 },
|
|
|
+ { type: [3, 1], opacity: 0.92 },
|
|
|
+ { type: [14, 2, 2, 2], opacity: 0.88 },
|
|
|
+]
|
|
|
+
|
|
|
+const primary = computed(() => {
|
|
|
+ const sides = props.detail?.sides ?? []
|
|
|
+ return sides.find((side) => side.side === props.detail?.primarySide) ?? sides[0] ?? null
|
|
|
+})
|
|
|
+
|
|
|
+function finiteValues(values: Array<number | null | undefined>) {
|
|
|
+ return values.filter((value): value is number => value != null && Number.isFinite(value))
|
|
|
+}
|
|
|
+
|
|
|
+function paddedExtent(values: number[], padRatio = 0.035): [number, number] | null {
|
|
|
+ if (!values.length) return null
|
|
|
+ let min = Infinity
|
|
|
+ let max = -Infinity
|
|
|
+ values.forEach((value) => {
|
|
|
+ if (value < min) min = value
|
|
|
+ if (value > max) max = value
|
|
|
+ })
|
|
|
+ if (!Number.isFinite(min) || !Number.isFinite(max)) return null
|
|
|
+ if (max === min) {
|
|
|
+ const delta = Math.abs(max) * 0.05 || 1
|
|
|
+ return [min - delta, max + delta]
|
|
|
+ }
|
|
|
+ const pad = (max - min) * padRatio
|
|
|
+ const lower = min >= 0 && min - pad < 0 ? 0 : min - pad
|
|
|
+ return [lower, max + pad]
|
|
|
+}
|
|
|
+
|
|
|
+function chartGrid(element: HTMLDivElement) {
|
|
|
+ const width = element.clientWidth || 480
|
|
|
+ const height = element.clientHeight || 520
|
|
|
+ const margin = { left: 62, right: 22, top: 52, bottom: 52 }
|
|
|
+ const availableWidth = Math.max(10, width - margin.left - margin.right)
|
|
|
+ const availableHeight = Math.max(10, height - margin.top - margin.bottom)
|
|
|
+ let gridWidth = availableWidth
|
|
|
+ let gridHeight = gridWidth / CHART_RATIO
|
|
|
+ if (gridHeight > availableHeight) {
|
|
|
+ gridHeight = availableHeight
|
|
|
+ gridWidth = gridHeight * CHART_RATIO
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ left: margin.left + (availableWidth - gridWidth) / 2,
|
|
|
+ top: margin.top,
|
|
|
+ width: gridWidth,
|
|
|
+ height: gridHeight,
|
|
|
+ containLabel: false,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function phaseIndexFor(angle: number, phases: PeriodSideDetail['phases']) {
|
|
|
+ if (!Number.isFinite(angle)) return -1
|
|
|
+ for (let index = 0; index < phases.length; index += 1) {
|
|
|
+ const phase = phases[index]
|
|
|
+ const isLast = index === phases.length - 1
|
|
|
+ if (angle >= phase.start && (isLast || angle < phase.end)) return index
|
|
|
+ }
|
|
|
+ return -1
|
|
|
+}
|
|
|
+
|
|
|
+function sideSeries(mode: 'pv' | 'angle') {
|
|
|
+ return (props.detail?.sides ?? []).flatMap((side: PeriodSideDetail) => {
|
|
|
+ const buckets = side.phases.map(() => [] as number[][])
|
|
|
+ const length = mode === 'pv'
|
|
|
+ ? Math.min(side.pressure.length, side.volume?.length ?? 0)
|
|
|
+ : Math.min(side.pressure.length, side.angles.length)
|
|
|
+ for (let index = 0; index < length; index += 1) {
|
|
|
+ const x = mode === 'pv' ? side.volume?.[index] : side.angles[index]
|
|
|
+ const y = side.pressure[index]
|
|
|
+ const angle360 = side.angles360[index]
|
|
|
+ if (x == null || !Number.isFinite(x) || !Number.isFinite(y)) continue
|
|
|
+ const phaseIndex = phaseIndexFor(angle360, side.phases)
|
|
|
+ if (phaseIndex < 0) continue
|
|
|
+ buckets[phaseIndex].push([x, y, Number.isFinite(angle360) ? angle360 : Number.NaN])
|
|
|
}
|
|
|
+ const color = SIDE_COLORS[side.side] ?? '#54717f'
|
|
|
+ return side.phases.flatMap((_phase, phaseIndex) => {
|
|
|
+ if (!buckets[phaseIndex].length) return []
|
|
|
+ const style = PHASE_LINE_STYLES[phaseIndex % PHASE_LINE_STYLES.length]
|
|
|
+ return [{
|
|
|
+ name: side.side,
|
|
|
+ type: 'line' as const,
|
|
|
+ showSymbol: false,
|
|
|
+ symbol: 'none' as const,
|
|
|
+ connectNulls: false,
|
|
|
+ animation: false,
|
|
|
+ lineStyle: { color, width: 1.5, type: style.type, opacity: style.opacity },
|
|
|
+ itemStyle: { color },
|
|
|
+ emphasis: { disabled: true },
|
|
|
+ data: buckets[phaseIndex],
|
|
|
+ }]
|
|
|
+ })
|
|
|
})
|
|
|
+}
|
|
|
+
|
|
|
+function buildOption(mode: 'pv' | 'angle', element: HTMLDivElement) {
|
|
|
+ const series = sideSeries(mode)
|
|
|
+ const pressures = (props.detail?.sides ?? []).flatMap((side) => finiteValues(side.pressure))
|
|
|
+ const yExtent = paddedExtent(pressures) ?? [0, 1]
|
|
|
+ let xExtent: [number, number]
|
|
|
+ let xName: string
|
|
|
+ if (mode === 'angle') {
|
|
|
+ xExtent = [0, 180]
|
|
|
+ xName = '曲轴角度 (°)'
|
|
|
+ } else {
|
|
|
+ const volumes = (props.detail?.sides ?? []).flatMap((side) => finiteValues(side.volume ?? []))
|
|
|
+ xExtent = paddedExtent(volumes, 0.03) ?? [0, 1]
|
|
|
+ xName = '气缸体积 (L)'
|
|
|
+ }
|
|
|
+ const valueUnit = mode === 'pv' ? '体积' : '角度'
|
|
|
|
|
|
- chart.setOption({
|
|
|
+ return {
|
|
|
animation: false,
|
|
|
- grid: { left: 64, right: 28, top: 38, bottom: 52 },
|
|
|
+ title: {
|
|
|
+ text: CHART_TITLES[mode],
|
|
|
+ left: 'center' as const,
|
|
|
+ top: 6,
|
|
|
+ textStyle: { color: '#303133', fontSize: 14, fontWeight: 600 },
|
|
|
+ },
|
|
|
+ legend: {
|
|
|
+ data: [...new Set(series.map((item) => item.name))],
|
|
|
+ selectedMode: false,
|
|
|
+ top: 28,
|
|
|
+ left: 'center' as const,
|
|
|
+ itemWidth: 16,
|
|
|
+ itemHeight: 7,
|
|
|
+ textStyle: { color: '#60717b', fontSize: 11 },
|
|
|
+ },
|
|
|
+ grid: chartGrid(element),
|
|
|
tooltip: {
|
|
|
- trigger: 'axis',
|
|
|
+ trigger: 'axis' as const,
|
|
|
+ axisPointer: { type: 'cross' as const, snap: false },
|
|
|
backgroundColor: '#162b3c',
|
|
|
borderWidth: 0,
|
|
|
textStyle: { color: '#fff', fontSize: 11 },
|
|
|
formatter: (params: any[]) => {
|
|
|
- const item = params?.[0]
|
|
|
- if (!item) return ''
|
|
|
- const index = item.data?.idx ?? 0
|
|
|
- const angle = detail.angles[index]
|
|
|
- const pressure = detail.pressure[index]
|
|
|
- const volume = detail.volume?.[index]
|
|
|
- const phase = detail.phases.find((p) => detail.angles360[index] >= p.start && detail.angles360[index] < p.end)
|
|
|
- return [
|
|
|
- `曲轴角度:${angle?.toFixed(1)}°${volumeMode ? `(0-360:${detail.angles360[index]?.toFixed(1)}°)` : ''}`,
|
|
|
- `压力原始值:${pressure?.toPrecision(8)}`,
|
|
|
- volumeMode ? `体积:${volume?.toFixed(4)} L` : '',
|
|
|
- phase ? `<span style="color:${phase.color}">●</span> ${phase.name}` : '',
|
|
|
- ].filter(Boolean).join('<br/>')
|
|
|
+ if (!params?.length) return ''
|
|
|
+ const lines = [`${valueUnit}:${Number(params[0].value?.[0] ?? 0).toFixed(3)}${mode === 'angle' ? '°' : ' L'}`]
|
|
|
+ params.forEach((item) => {
|
|
|
+ const value = item.value ?? []
|
|
|
+ const pressure = Number(value[1])
|
|
|
+ if (!Number.isFinite(pressure)) return
|
|
|
+ const angle = Number(value[2])
|
|
|
+ const phases = props.detail?.sides[0]?.phases ?? []
|
|
|
+ const phase = phases[phaseIndexFor(angle, phases)]?.name
|
|
|
+ const marker = `<span style="color:${item.color}">●</span> ${item.seriesName}`
|
|
|
+ const angleText = Number.isFinite(angle) ? ` · 0-360° ${angle.toFixed(1)}°` : ''
|
|
|
+ const phaseText = phase ? ` · ${phase}` : ''
|
|
|
+ lines.push(`${marker}:${pressure.toFixed(3)} MPa${phaseText}${mode === 'pv' ? angleText : ''}`)
|
|
|
+ })
|
|
|
+ return lines.join('<br/>')
|
|
|
},
|
|
|
},
|
|
|
- legend: { top: 0, left: 70, itemWidth: 16, itemHeight: 7, textStyle: { color: '#60717b', fontSize: 11 } },
|
|
|
xAxis: {
|
|
|
- type: 'value',
|
|
|
- name: volumeMode ? '气缸体积 (L)' : '曲轴角度 (°)',
|
|
|
- nameLocation: 'middle',
|
|
|
+ type: 'value' as const,
|
|
|
+ name: xName,
|
|
|
+ nameLocation: 'middle' as const,
|
|
|
nameGap: 30,
|
|
|
- min: volumeMode ? undefined : 0,
|
|
|
- max: volumeMode ? undefined : 180,
|
|
|
+ min: xExtent[0],
|
|
|
+ max: xExtent[1],
|
|
|
axisLine: { lineStyle: { color: '#9eabb1' } },
|
|
|
- axisLabel: { color: '#71808a' },
|
|
|
+ axisLabel: {
|
|
|
+ color: '#71808a',
|
|
|
+ fontSize: 11,
|
|
|
+ hideOverlap: true,
|
|
|
+ showMinLabel: mode === 'angle',
|
|
|
+ showMaxLabel: mode === 'angle',
|
|
|
+ formatter: (value: number) => (mode === 'angle' ? String(Math.round(value)) : value.toFixed(1)),
|
|
|
+ },
|
|
|
+ splitLine: { show: false },
|
|
|
},
|
|
|
yAxis: {
|
|
|
- type: 'value',
|
|
|
- name: '压力原始值',
|
|
|
- nameLocation: 'middle',
|
|
|
+ type: 'value' as const,
|
|
|
+ name: '压力 (MPa)',
|
|
|
+ nameLocation: 'middle' as const,
|
|
|
nameGap: 45,
|
|
|
- nameTextStyle: { color: '#e4572e' },
|
|
|
- axisLine: { show: true, lineStyle: { color: '#e4572e' } },
|
|
|
- axisLabel: { color: '#71808a' },
|
|
|
+ nameTextStyle: { color: '#60717b', fontSize: 11 },
|
|
|
+ min: yExtent[0],
|
|
|
+ max: yExtent[1],
|
|
|
+ axisLine: { show: true, lineStyle: { color: '#9eabb1' } },
|
|
|
+ axisLabel: {
|
|
|
+ color: '#71808a',
|
|
|
+ fontSize: 11,
|
|
|
+ showMinLabel: false,
|
|
|
+ formatter: (value: number) => value.toFixed(2),
|
|
|
+ },
|
|
|
splitLine: { lineStyle: { color: '#edf1f2' } },
|
|
|
- min: props.pressureAxis?.min,
|
|
|
- max: props.pressureAxis?.max,
|
|
|
- interval: props.pressureAxis ? (props.pressureAxis.max - props.pressureAxis.min) / 4 : undefined,
|
|
|
},
|
|
|
series,
|
|
|
- }, true)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderChart(chart: echarts.ECharts | undefined, element: HTMLDivElement | null, mode: 'pv' | 'angle') {
|
|
|
+ if (!chart || !element || !props.detail) return
|
|
|
+ chart.setOption(buildOption(mode, element), true)
|
|
|
chart.resize()
|
|
|
}
|
|
|
|
|
|
-async function ensureChart() {
|
|
|
+function disposeCharts() {
|
|
|
+ pvChart?.dispose()
|
|
|
+ angleChart?.dispose()
|
|
|
+ pvChart = undefined
|
|
|
+ angleChart = undefined
|
|
|
+}
|
|
|
+
|
|
|
+async function ensureCharts() {
|
|
|
if (!props.visible) {
|
|
|
- chart?.dispose()
|
|
|
- chart = undefined
|
|
|
+ disposeCharts()
|
|
|
return
|
|
|
}
|
|
|
await nextTick()
|
|
|
- if (!chartElement.value) return
|
|
|
- if (!chart) chart = echarts.init(chartElement.value, undefined, { renderer: 'canvas' })
|
|
|
- render()
|
|
|
+ if (!pvElement.value || !angleElement.value) return
|
|
|
+ if (!pvChart) pvChart = echarts.init(pvElement.value, undefined, { renderer: 'canvas' })
|
|
|
+ if (!angleChart) angleChart = echarts.init(angleElement.value, undefined, { renderer: 'canvas' })
|
|
|
+ renderChart(pvChart, pvElement.value, 'pv')
|
|
|
+ renderChart(angleChart, angleElement.value, 'angle')
|
|
|
+}
|
|
|
+
|
|
|
+function onResize() {
|
|
|
+ if (!props.visible || !props.detail) return
|
|
|
+ pvChart?.resize()
|
|
|
+ angleChart?.resize()
|
|
|
+ renderChart(pvChart, pvElement.value, 'pv')
|
|
|
+ renderChart(angleChart, angleElement.value, 'angle')
|
|
|
}
|
|
|
|
|
|
-watch(() => [props.detail, props.visible, chartMode.value], () => void ensureChart(), { deep: true })
|
|
|
+watch(() => props.visible, () => void ensureCharts())
|
|
|
+watch(() => props.detail, () => void ensureCharts())
|
|
|
|
|
|
onMounted(() => {
|
|
|
- void ensureChart()
|
|
|
+ window.addEventListener('resize', onResize)
|
|
|
+ void ensureCharts()
|
|
|
})
|
|
|
|
|
|
-onBeforeUnmount(() => chart?.dispose())
|
|
|
+onBeforeUnmount(() => {
|
|
|
+ window.removeEventListener('resize', onResize)
|
|
|
+ disposeCharts()
|
|
|
+})
|
|
|
</script>
|
|
|
|
|
|
<template>
|
|
|
@@ -123,23 +279,23 @@ onBeforeUnmount(() => chart?.dispose())
|
|
|
<header class="period-modal-head">
|
|
|
<div>
|
|
|
<h2>周期功图</h2>
|
|
|
- <p>{{ detail?.waveFile.pointName }} · wave_file #{{ detail?.waveFile.id }} · {{ detail?.waveFile.sampleTime }}</p>
|
|
|
+ <p>{{ detail?.devicePart || primary?.devicePart || '—' }} · {{ detail?.sampleTime || primary?.sampleTime || '—' }}</p>
|
|
|
</div>
|
|
|
<button class="icon-button" type="button" aria-label="关闭" @click="emit('close')">×</button>
|
|
|
</header>
|
|
|
- <div v-if="loading" class="modal-loading">正在生成角度与体积数据…</div>
|
|
|
+ <div v-if="loading" class="modal-loading">正在加载两侧完整周期原始数据…</div>
|
|
|
<template v-else-if="detail">
|
|
|
<div class="period-stat-row">
|
|
|
- <div><span>采样范围</span><strong>{{ detail.period.startSampleIndex }} — {{ detail.period.endSampleIndex }}</strong></div>
|
|
|
- <div><span>周期采样数</span><strong>{{ detail.period.sampleCount.toLocaleString() }}</strong></div>
|
|
|
- <div><span>键相点</span><strong>{{ detail.period.triggerSampleIndices.length }} 个</strong></div>
|
|
|
- <div v-if="detail.volumeInfo"><span>缸径 / 余隙</span><strong>{{ detail.volumeInfo.boreMm }} mm / {{ detail.volumeInfo.clearanceVolumeL }} L</strong></div>
|
|
|
+ <div><span>采样范围</span><strong>{{ primary?.period.startSampleIndex }} — {{ primary?.period.endSampleIndex }}</strong></div>
|
|
|
+ <div><span>周期采样数</span><strong>{{ primary?.period.sampleCount.toLocaleString() }}</strong></div>
|
|
|
+ <div><span>压力侧</span><strong>{{ detail.sides.map((side) => side.side).join(' / ') }}</strong></div>
|
|
|
+ <div v-if="primary?.volumeInfo"><span>缸径 / 余隙</span><strong>{{ primary.volumeInfo.boreMm }} mm / {{ primary.volumeInfo.clearanceVolumeL }} L</strong></div>
|
|
|
</div>
|
|
|
- <div class="modal-tabs">
|
|
|
- <button :class="{ active: chartMode === 'volume' }" type="button" :disabled="!detail.volume" @click="chartMode = 'volume'">压力-体积功图</button>
|
|
|
- <button :class="{ active: chartMode === 'angle' }" type="button" @click="chartMode = 'angle'">压力-角度图</button>
|
|
|
+ <div v-if="detail.sideNotice" class="period-side-notice">{{ detail.sideNotice }}</div>
|
|
|
+ <div class="period-chart-grid">
|
|
|
+ <div ref="pvElement" class="period-chart"></div>
|
|
|
+ <div ref="angleElement" class="period-chart"></div>
|
|
|
</div>
|
|
|
- <div ref="chartElement" class="period-chart"></div>
|
|
|
</template>
|
|
|
</section>
|
|
|
</div>
|