|
|
@@ -18,6 +18,9 @@ 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)
|
|
|
+let pvFrame: number | undefined
|
|
|
|
|
|
const colors: Record<MeasurementType | '角度' | '合并信号' | 'second_value' | '体积', string> = {
|
|
|
压力: '#e05252',
|
|
|
@@ -29,6 +32,29 @@ const colors: Record<MeasurementType | '角度' | '合并信号' | 'second_value
|
|
|
体积: '#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)
|
|
|
@@ -429,6 +455,124 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+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
|
|
|
@@ -467,6 +611,7 @@ function renderFull(zoomMode: 'initial' | 'keep' = 'initial') {
|
|
|
if (!chart) return
|
|
|
chart.setOption(buildOption(zoomMode), true)
|
|
|
chart.resize()
|
|
|
+ drawPvPreviews()
|
|
|
}
|
|
|
|
|
|
function updateAnnotationOverlay() {
|
|
|
@@ -476,6 +621,7 @@ function updateAnnotationOverlay() {
|
|
|
|
|
|
function resize() {
|
|
|
chart?.resize()
|
|
|
+ drawPvPreviews()
|
|
|
}
|
|
|
|
|
|
watch(
|
|
|
@@ -497,6 +643,7 @@ 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')
|
|
|
@@ -505,6 +652,7 @@ onMounted(() => {
|
|
|
onBeforeUnmount(() => {
|
|
|
window.removeEventListener('resize', resize)
|
|
|
chart?.dispose()
|
|
|
+ if (pvFrame != null) cancelAnimationFrame(pvFrame)
|
|
|
})
|
|
|
</script>
|
|
|
|
|
|
@@ -517,6 +665,10 @@ onBeforeUnmount(() => {
|
|
|
<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">
|