Browse Source

周期功图:双侧并排显示并按阶段微调线型

18922397810 4 giờ trước cách đây
mục cha
commit
a0abab1645

+ 98 - 7
backend/app/services/data_service.py

@@ -29,6 +29,7 @@ MEASUREMENT_COLORS = {
     "加速度": "#7b61a8",
 }
 DEVICE_POINTS = ("压力盖侧", "压力轴侧", "活塞杆沉降", "十字头振动", "自由端振动", "驱动端振动")
+PRESSURE_SIDES = ("压力盖侧", "压力轴侧")
 DEVICE_POINT_TO_TYPE = {
     "压力盖侧": "压力",
     "压力轴侧": "压力",
@@ -2015,7 +2016,20 @@ class DataService:
 
         def database_query():
             metadata, samples = self._load_db_wave(wave_file_id)
-            return self._build_period_detail(metadata, samples, period_number)
+            details = [self._build_period_detail(metadata, samples, period_number)]
+            notice: str | None = None
+            companion = self._find_opposite_pressure_metadata(metadata)
+            if companion is None:
+                notice = "未找到同一时刻的对侧压力数据,仅显示当前侧。"
+            else:
+                try:
+                    companion_meta, companion_samples = self._load_db_wave(int(companion["id"]))
+                    details.append(
+                        self._build_period_detail(companion_meta, companion_samples, period_number),
+                    )
+                except Exception as exc:
+                    notice = f"对侧压力数据读取失败,仅显示当前侧:{exc}"
+            return self._assemble_period_detail(metadata, details, notice)
 
         def demo_query():
             metadata, samples = self._load_demo_wave(
@@ -2024,13 +2038,78 @@ class DataService:
                 DEMO_POINT_NAME,
                 DEMO_START,
             )
-            return self._build_period_detail(metadata, samples, period_number)
+            details = [self._build_period_detail(metadata, samples, period_number)]
+            return self._assemble_period_detail(
+                metadata,
+                details,
+                "演示模式仅提供单侧压力数据。",
+            )
 
         result, source = self._run_with_fallback(database_query, demo_query)
         result["source"] = source
         result["notice"] = self._source_notice(source)
         return result
 
+    def _find_opposite_pressure_metadata(self, metadata: dict[str, Any]) -> dict[str, Any] | None:
+        point_name = str(metadata.get("point_name") or "")
+        side = metadata.get("device_point")
+        if side not in PRESSURE_SIDES:
+            side = next((item for item in PRESSURE_SIDES if point_name.endswith(item)), None)
+        if side not in PRESSURE_SIDES:
+            return None
+        other_side = PRESSURE_SIDES[1] if side == PRESSURE_SIDES[0] else PRESSURE_SIDES[0]
+        device_part = metadata.get("device_part")
+        sample_time = metadata.get("sample_time")
+        if not device_part or sample_time is None:
+            return None
+        try:
+            with get_connection() as connection:
+                with connection.cursor() as cursor:
+                    cursor.execute(
+                        """
+                        SELECT id
+                        FROM wave_file
+                        WHERE measurement_type = %s
+                          AND device_part = %s
+                          AND sample_time = %s
+                          AND device_point = %s
+                        ORDER BY id ASC
+                        LIMIT 1
+                        """,
+                        ("压力", device_part, sample_time, other_side),
+                    )
+                    row = cursor.fetchone()
+            return row
+        except Exception:
+            return None
+
+    @staticmethod
+    def _assemble_period_detail(
+        metadata: dict[str, Any],
+        details: list[dict[str, Any]],
+        side_notice: str | None,
+    ) -> dict[str, Any]:
+        order = {side: index for index, side in enumerate(PRESSURE_SIDES)}
+        details.sort(key=lambda item: order.get(str(item.get("side")), len(order)))
+        point_name = str(metadata.get("point_name") or "")
+        device_part = metadata.get("device_part")
+        sample_time = metadata.get("sample_time")
+        primary_side = metadata.get("device_point")
+        if primary_side not in PRESSURE_SIDES:
+            primary_side = next(
+                (item for item in PRESSURE_SIDES if point_name.endswith(item)),
+                details[0].get("side") if details else None,
+            )
+        if not device_part and primary_side and point_name.endswith(str(primary_side)):
+            device_part = point_name[: -len(str(primary_side))]
+        return {
+            "devicePart": device_part or "",
+            "sampleTime": _time_string(sample_time) if sample_time is not None else "",
+            "primarySide": primary_side,
+            "sideNotice": side_notice,
+            "sides": details,
+        }
+
     def annotation_config(self) -> dict[str, Any]:
         return {
             "source": self.source,
@@ -2969,10 +3048,17 @@ class DataService:
         if cycle is None:
             raise ValueError(f"没有找到周期 {period_number}")
 
-        angles360 = np.linspace(0.0, 359.0, 360)
-        pressure = np.interp(angles360, cycle.angle, cycle.signal)
+        # 保留 wave_sample_one 的完整周期原始采样点,不再插值成固定 360 点。
+        angles360 = np.asarray(cycle.angle, dtype=float)
+        pressure = np.asarray(cycle.signal, dtype=float)
         display_angles = np.where(angles360 <= 180.0, angles360, 360.0 - angles360)
         point_name = str(metadata.get("point_name") or "")
+        side = metadata.get("device_point")
+        if side not in PRESSURE_SIDES:
+            side = next((item for item in PRESSURE_SIDES if point_name.endswith(item)), None)
+        device_part = metadata.get("device_part")
+        if not device_part and side and point_name.endswith(str(side)):
+            device_part = point_name[: -len(str(side))]
         cylinder_name = next(
             (name for name in CYLINDER_BORE_MM if name in point_name),
             None,
@@ -3006,8 +3092,13 @@ class DataService:
         end_offset = min(cycle.end_offset, len(samples) - 1)
         end_index = int(samples[max(cycle.end_offset - 1, cycle.start_offset), 0])
         return {
+            "side": side,
+            "devicePart": device_part or "",
+            "sampleTime": _time_string(metadata.get("sample_time")),
             "waveFile": {
                 "id": int(metadata["id"]),
+                "devicePart": device_part or "",
+                "devicePoint": side,
                 "pointName": point_name,
                 "measurementType": metadata.get("measurement_type"),
                 "sampleTime": _time_string(metadata.get("sample_time")),
@@ -3063,9 +3154,9 @@ class DataService:
             with connection.cursor() as cursor:
                 cursor.execute(
                     """
-                    SELECT id, point_name, measurement_type, sample_frequency_hz,
-                           sample_count, sample_time, rpm, file_name, tspluse_status,
-                           cycle_start, cycle_end
+                    SELECT id, device_part, device_point, point_name, measurement_type,
+                           sample_frequency_hz, sample_count, sample_time, rpm, file_name,
+                           tspluse_status, cycle_start, cycle_end
                     FROM wave_file
                     WHERE id = %s
                     """,

+ 1 - 7
frontend/src/App.vue

@@ -5,7 +5,6 @@ import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConf
 import PeriodModal from './components/PeriodModal.vue'
 import AlarmMonitor from './components/AlarmMonitor.vue'
 import Model2 from './components/Model2.vue'
-import { fixedAxisRange } from './utils/axis'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
 import { STOPPED_COLOR, statusGradientColor } from './statusColor'
@@ -149,11 +148,6 @@ const statusFilter = computed(() => {
   return list
 })
 const currentCycles = computed(() => waveData.value?.cycles ?? [])
-const pressureAxis = computed(() => {
-  const series = waveData.value?.series.find((item) => item.measurementType === '压力')
-  if (!series) return null
-  return fixedAxisRange('压力', series.min, series.max)
-})
 const currentSource = computed(() => waveData.value?.source ?? timePointsSource.value)
 const timePointsSource = ref<'database' | 'demo'>('demo')
 const availableTypeCount = computed(() => selectedOptionRows.value.reduce((total, row) => total + row.fileCount, 0))
@@ -1284,6 +1278,6 @@ onBeforeUnmount(() => {
       </el-tab-pane>
     </el-tabs>
 
-    <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" :pressure-axis="pressureAxis" @close="closePeriod" />
+    <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" @close="closePeriod" />
   </div>
 </template>

+ 236 - 80
frontend/src/components/PeriodModal.vue

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

+ 37 - 15
frontend/src/components/WaveChart.vue

@@ -42,8 +42,7 @@ const pvPhaseColors = [
   { start: 195, end: 270, color: '#1565c0' },
   { start: 270, end: 360, color: '#2e7d32' },
 ]
-const modalChartWidth = 1060
-const modalChartHeight = 440
+const PV_PREVIEW_RATIO = 762 / 1052
 
 type RowKind = 'device' | 'site' | 'siteAgg' | 'second' | 'volume'
 type RowMeta = { name: string; kind: RowKind; siteSeq: number }
@@ -112,6 +111,24 @@ function niceAxisExtent(values: number[]): [number, number] {
   return [Math.floor(min / interval) * interval, Math.ceil(max / interval) * interval]
 }
 
+function paddedExtent(values: number[], padRatio = 0.04): [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 rowColor(data: WaveWindowResponse, name: string): string {
   const meta = buildRowMetas(data).find((item) => item.name === name)
   if (meta?.kind === 'site') return SITE_POINT_COLORS[meta.siteSeq % SITE_POINT_COLORS.length]
@@ -155,7 +172,7 @@ const pvPointRows = computed(() => (
     .filter((series) => series.measurementType === '压力')
     .map((series) => ({
       devicePoint: series.devicePoint,
-      title: `${series.devicePoint} · 压力-体积功图 · 当前可视周期`,
+      title: `${series.devicePoint} · 压力-体积(P-V图)`,
     })) ?? [])
 ))
 
@@ -613,7 +630,7 @@ function drawPvPreviews() {
       const pointCycles = data.cycles.filter((cycle) => cycle.devicePoint === row.devicePoint)
       rowEl.style.marginLeft = `${plotLeft}px`
       rowEl.style.width = `${width}px`
-      const height = 132
+      const height = canvas.clientHeight || 180
       canvas.width = width * ratio
       canvas.height = height * ratio
       canvas.style.width = `${width}px`
@@ -667,20 +684,25 @@ function drawPvPreviews() {
         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 volumeExtent = paddedExtent(volumes, 0.04) ?? niceAxisExtent(volumes)
+        const pressureExtent = paddedExtent(pressures, 0.04) ?? niceAxisExtent(pressures)
+        const [minVolume, maxVolume] = volumeExtent
+        const [minPressure, maxPressure] = pressureExtent
         const volumeSpan = maxVolume - minVolume || 1
         const pressureSpan = maxPressure - minPressure || 1
-        const frameWidth = Math.min(cycleWidth, height * modalChartWidth / modalChartHeight)
-        const frameHeight = frameWidth * modalChartHeight / modalChartWidth
+        const availableHeight = Math.max(24, height - 26)
+        const frameWidth = Math.min(cycleWidth, availableHeight * PV_PREVIEW_RATIO)
+        const frameHeight = frameWidth / PV_PREVIEW_RATIO
         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 frameTop = 20 + (availableHeight - frameHeight) / 2
+        const graphLeft = frameLeft
+        const graphRight = frameLeft + frameWidth
+        const graphTop = frameTop
+        const graphBottom = frameTop + frameHeight
         const graphHeight = graphBottom - graphTop
+        context.strokeStyle = 'rgba(158, 171, 177, .45)'
+        context.lineWidth = 1
+        context.strokeRect(frameLeft + .5, frameTop + .5, Math.max(0, frameWidth - 1), Math.max(0, frameHeight - 1))
         context.save()
         context.beginPath()
         context.rect(cellLeft, 0, cycleWidth, height)
@@ -705,7 +727,7 @@ function drawPvPreviews() {
         })
         context.restore()
         context.fillStyle = '#788892'
-        context.fillText(`P-V ${cycle.periodNo}`, (left + right) / 2, 12)
+        context.fillText(`P-V ${cycle.periodNo}`, (left + right) / 2, 13)
       })
     })
   })

+ 11 - 10
frontend/src/styles.css

@@ -250,9 +250,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; }
 .pv-preview-shell { display: flex; flex-direction: column; gap: 6px; width: 100%; padding: 4px 0; }
-.pv-preview-row { 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; }
+.pv-preview-row { position: relative; width: 100%; height: 204px; overflow: hidden; border: 1px solid #ebeef5; border-radius: 4px; background: #f8fafb; }
+.pv-preview-title { height: 24px; color: #788892; font-size: 11px; line-height: 24px; text-align: center; pointer-events: none; }
+.pv-preview-canvas { display: block; width: 100%; height: 180px; }
 .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; }
@@ -290,7 +290,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(1060px, 100%); min-height: 600px; overflow: hidden; background: #fff; border-radius: 4px; box-shadow: 0 12px 32px rgba(0, 0, 0, .16); }
+.period-modal { position: relative; width: min(1180px, 100%); max-height: 92vh; overflow: auto; 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; }
@@ -301,10 +301,9 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
 .period-stat-row > div { flex: 1 1 140px; min-height: 68px; display: flex; flex-direction: column; justify-content: center; gap: 5px; padding: 9px 18px; border-right: 1px solid #ebeef5; }
 .period-stat-row span { color: #909399; font-size: 11px; }
 .period-stat-row strong { color: #606266; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; font-weight: 500; }
-.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: 440px; }
+.period-side-notice { margin: 10px 20px 0; padding: 8px 10px; color: #8a6d3b; background: #fdf6ec; border: 1px solid #faecd8; border-radius: 4px; font-size: 12px; }
+.period-chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; padding: 12px 20px 18px; }
+.period-chart { width: 100%; height: clamp(420px, 58vh, 680px); min-width: 0; }
 .modal-loading { position: absolute; z-index: 2; top: 100px; bottom: 0; background: rgba(255, 255, 255, .82); }
 
 @media (max-width: 1300px) {
@@ -343,7 +342,8 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
   .chart-actions { width: 100%; align-items: flex-start; flex-direction: column; gap: 10px; }
   .chart-status { align-self: flex-end; }
   .wave-chart { height: 600px; }
-  .pv-preview-row, .pv-preview-canvas { height: 118px; }
+  .pv-preview-row { height: 184px; }
+  .pv-preview-canvas { height: 160px; }
   .chart-toolbar-note { font-size: 11px; }
   .chart-point-count { display: none; }
   .readout-grid { grid-template-columns: 1fr; }
@@ -351,5 +351,6 @@ h2 { font-size: 18px; line-height: 1.35; font-weight: 600; }
   .modal-backdrop { align-items: flex-end; padding: 0; }
   .period-modal { max-height: 92vh; overflow: auto; }
   .period-stat-row > div { flex-basis: 50%; border-bottom: 1px solid #ebeef5; }
-  .period-chart { height: 300px; }
+  .period-chart-grid { grid-template-columns: 1fr; padding: 10px 12px 14px; }
+  .period-chart { height: clamp(360px, 62vh, 560px); }
 }

+ 16 - 3
frontend/src/types.ts

@@ -292,11 +292,14 @@ export type WaveWindowResponse = {
   }
 }
 
-export type PeriodDetail = {
-  source: 'database' | 'demo'
-  notice: string | null
+export type PeriodSideDetail = {
+  side: '压力盖侧' | '压力轴侧'
+  devicePart: string
+  sampleTime: string
   waveFile: {
     id: number
+    devicePart: string
+    devicePoint: '压力盖侧' | '压力轴侧'
     pointName: string
     measurementType: MeasurementType
     sampleTime: string
@@ -330,6 +333,16 @@ export type PeriodDetail = {
   diagnostics: Record<string, number>
 }
 
+export type PeriodDetail = {
+  source: 'database' | 'demo'
+  notice: string | null
+  devicePart: string
+  sampleTime: string
+  primarySide: '压力盖侧' | '压力轴侧' | null
+  sideNotice: string | null
+  sides: PeriodSideDetail[]
+}
+
 export const ANNOTATION_LABELS = ['正常', '异常'] as const
 export type AnnotationLabel = (typeof ANNOTATION_LABELS)[number]