ソースを参照

Add synchronized PV cycle previews

18922397810 2 週間 前
コミット
e88e443a05

+ 13 - 0
backend/app/services/data_service.py

@@ -544,6 +544,9 @@ class DataService:
 
             detected, diagnostic = detect_cycles(source_samples)
             angle_vector = build_angle_vector(len(source_samples), detected)
+            full_angle_vector = np.full(len(source_samples), np.nan, dtype=float)
+            for detected_cycle in detected:
+                full_angle_vector[detected_cycle.start_offset:detected_cycle.end_offset] = detected_cycle.angle
             current_volume, current_volume_info = self._build_volume_vector(
                 len(source_samples),
                 detected,
@@ -651,6 +654,16 @@ class DataService:
                             "waveFileId": file_id,
                             "sampleTime": point["sampleTime"],
                             "secondValue": second,
+                            "volume": (
+                                _safe_float(current_volume[offset])
+                                if file_id == source_id
+                                else None
+                            ),
+                            "angle360": (
+                                _safe_float(full_angle_vector[offset])
+                                if file_id == source_id
+                                else None
+                            ),
                         },
                     )
                 if file_id == source_id:

+ 152 - 0
frontend/src/components/WaveChart.vue

@@ -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">

+ 6 - 2
frontend/src/styles.css

@@ -167,6 +167,9 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .chart-point-count { margin-left: auto; color: #606266; font-weight: 500; }
 .wave-chart { width: 100%; height: 680px; }
 .wave-chart.is-merge { height: 560px; }
+.pv-preview-shell { position: relative; width: 100%; height: 132px; overflow: hidden; border: 1px solid #ebeef5; border-radius: 4px; background: #f8fafb; }
+.pv-preview-title { position: absolute; z-index: 1; top: 3px; left: 10px; color: #8b9aa2; font-size: 11px; pointer-events: none; }
+.pv-preview-canvas { display: block; width: 100%; height: 132px; }
 .chart-empty-hint { margin-top: 7px; color: #e6a23c; font-size: 12px; }
 
 .annotation-nav { margin-top: 12px; padding: 10px 12px 12px; border: 1px solid #ebeef5; border-radius: 4px; background: #fafbfc; }
@@ -204,7 +207,7 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .app-footer b { padding: 0 5px; color: #409eff; font-weight: 400; }
 
 .modal-backdrop { position: fixed; z-index: 20; inset: 0; display: flex; align-items: center; justify-content: center; padding: 24px; background: rgba(0, 0, 0, .45); backdrop-filter: blur(2px); }
-.period-modal { position: relative; width: min(960px, 100%); min-height: 500px; overflow: hidden; background: #fff; border-radius: 4px; box-shadow: 0 12px 32px rgba(0, 0, 0, .16); }
+.period-modal { position: relative; width: min(1060px, 100%); min-height: 600px; overflow: hidden; background: #fff; border-radius: 4px; box-shadow: 0 12px 32px rgba(0, 0, 0, .16); }
 .period-modal-head { align-items: flex-start; padding: 22px 25px 18px; color: #303133; background: #f5f7fa; border-bottom: 1px solid #ebeef5; }
 .period-modal-head .eyebrow { display: block; margin-bottom: 7px; color: #409eff; }
 .period-modal-head h2 { font-size: 21px; }
@@ -218,7 +221,7 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .modal-tabs { display: flex; gap: 18px; padding: 17px 25px 0; }
 .modal-tabs button { border: 0; border-bottom: 2px solid transparent; padding: 0 0 8px; color: #909399; background: transparent; font-size: 13px; }
 .modal-tabs button.active { color: #409eff; border-bottom-color: #409eff; }
-.period-chart { width: 100%; height: 340px; }
+.period-chart { width: 100%; height: 440px; }
 .modal-loading { position: absolute; z-index: 2; top: 100px; bottom: 0; background: rgba(255, 255, 255, .82); }
 
 @media (max-width: 1250px) {
@@ -253,6 +256,7 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
   .chart-status { align-self: flex-end; }
   .wave-chart { height: 600px; }
   .wave-chart.is-merge { height: 520px; }
+  .pv-preview-shell, .pv-preview-canvas { height: 118px; }
   .chart-toolbar-note { font-size: 11px; }
   .chart-point-count { display: none; }
   .readout-grid { grid-template-columns: 1fr; }

+ 2 - 0
frontend/src/types.ts

@@ -47,6 +47,8 @@ export type WaveValue = {
   waveFileId: number
   sampleTime: string
   secondValue: number | null
+  volume: number | null
+  angle360: number | null
 }
 
 export type WaveSeries = {