Преглед на файлове

Fix y-axis scales to whole-batch extents with shared fixed ranges for PV popups

18922397810 преди 1 седмица
родител
ревизия
b7cff301e9

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

@@ -80,6 +80,17 @@ def _safe_float(value: Any) -> float | None:
     return number if np.isfinite(number) else None
 
 
+def _series_extent(items: list[dict[str, Any]]) -> tuple[float, float]:
+    """Whole-window min/max over a series' finite raw values."""
+    if not items:
+        return (0.0, 1.0)
+    values = np.fromiter((item["rawValue"] for item in items), dtype=float, count=len(items))
+    values = values[np.isfinite(values)]
+    if values.size == 0:
+        return (0.0, 1.0)
+    return (float(values.min()), float(values.max()))
+
+
 def _annotation_dict(row: dict[str, Any]) -> dict[str, Any]:
     return {
         "id": int(row["id"]),
@@ -859,6 +870,7 @@ class DataService:
         second_series_data.sort(key=lambda item: item["x"])
         angle_data.sort(key=lambda item: item["x"])
         volume_data.sort(key=lambda item: item["x"])
+        extents = {measurement_type: _series_extent(series_data[measurement_type]) for measurement_type in types}
         return {
             "pointName": point_name,
             "measurementTypes": types,
@@ -870,6 +882,8 @@ class DataService:
                     "measurementType": measurement_type,
                     "color": MEASUREMENT_COLORS[measurement_type],
                     "data": series_data[measurement_type],
+                    "min": extents[measurement_type][0],
+                    "max": extents[measurement_type][1],
                 }
                 for measurement_type in types
             ],
@@ -1202,6 +1216,7 @@ class DataService:
         second_series_data.sort(key=lambda item: item["x"])
         angle_data.sort(key=lambda item: item["x"])
         volume_data.sort(key=lambda item: item["x"])
+        extents = {measurement_type: _series_extent(series_data[measurement_type]) for measurement_type in types}
         return {
             "pointName": point_name,
             "measurementTypes": types,
@@ -1213,6 +1228,8 @@ class DataService:
                     "measurementType": measurement_type,
                     "color": MEASUREMENT_COLORS[measurement_type],
                     "data": series_data[measurement_type],
+                    "min": extents[measurement_type][0],
+                    "max": extents[measurement_type][1],
                 }
                 for measurement_type in types
             ],

+ 7 - 1
frontend/src/App.vue

@@ -3,6 +3,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
 import { ElMessageBox } from 'element-plus'
 import { createAnnotation, deleteAnnotation, deletePretrain, fetchAnnotationConfig, fetchAnnotationsByIds, fetchPeriodDetail, fetchPretrainStatus, fetchQueryOptions, fetchTimePoints, fetchTspluseRuler, fetchWaveWindow, getToken, login as apiLogin, logout as apiLogout, recordPretrain } from './api'
 import PeriodModal from './components/PeriodModal.vue'
+import { fixedAxisRange } from './utils/axis'
 import TimePointStrip from './components/TimePointStrip.vue'
 import WaveChart from './components/WaveChart.vue'
 import { STOPPED_COLOR, statusGradientColor } from './statusColor'
@@ -89,6 +90,11 @@ 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))
@@ -918,6 +924,6 @@ onBeforeUnmount(() => {
       </aside>
     </main>
 
-    <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" @close="closePeriod" />
+    <PeriodModal :visible="periodVisible" :detail="periodDetail" :loading="periodLoading" :pressure-axis="pressureAxis" @close="closePeriod" />
   </div>
 </template>

+ 4 - 0
frontend/src/components/PeriodModal.vue

@@ -7,6 +7,7 @@ const props = defineProps<{
   detail: PeriodDetail | null
   visible: boolean
   loading?: boolean
+  pressureAxis?: { min: number; max: number } | null
 }>()
 
 const emit = defineEmits<{
@@ -85,6 +86,9 @@ function render() {
       axisLine: { show: true, lineStyle: { color: '#e4572e' } },
       axisLabel: { color: '#71808a' },
       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)

+ 30 - 16
frontend/src/components/WaveChart.vue

@@ -2,6 +2,7 @@
 import * as echarts from 'echarts'
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import type { Annotation, Cycle, MeasurementType, TimePoint, WaveWindowResponse } from '../types'
+import { fixedAxisRange } from '../utils/axis'
 
 const props = defineProps<{
   data: WaveWindowResponse | null
@@ -55,6 +56,13 @@ function niceAxisExtent(values: number[]): [number, number] {
   return [Math.floor(min / interval) * interval, Math.ceil(max / interval) * interval]
 }
 
+function fixedYAxis(type: 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 plottedPointCount = computed(() => (
   (props.data?.series.reduce((total, series) => total + series.data.length, 0) ?? 0)
   + (props.data?.secondSeries.data.length ?? 0)
@@ -257,20 +265,24 @@ function buildOption(zoomMode: 'initial' | 'keep' = 'initial') {
     boundaryGap: false,
     splitLine: { show: false },
   }))
-  const yAxes = types.map((type, index) => ({
-    type: 'value' as const,
-    gridIndex: index,
-    name: type === 'second_value' ? '周期数据' : type,
-    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' | '体积'] } },
-    axisLabel: { color: '#71808a', fontSize: 11 },
-    splitLine: { show: true, lineStyle: { color: '#e7edf0', width: 1 } },
-    scale: type === '位移',
-    min: type === '角度' ? 0 : undefined,
-    max: type === '角度' ? 180 : undefined,
-  }))
+  const yAxes = types.map((type, index) => {
+    const fixed = fixedYAxis(type)
+    return {
+      type: 'value' as const,
+      gridIndex: index,
+      name: type === 'second_value' ? '周期数据' : type,
+      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' | '体积'] } },
+      axisLabel: { color: '#71808a', fontSize: 11 },
+      splitLine: { show: true, lineStyle: { color: '#e7edf0', width: 1 } },
+      scale: type === '位移',
+      min: type === '角度' ? 0 : fixed?.min,
+      max: type === '角度' ? 180 : fixed?.max,
+      interval: type === '角度' ? undefined : fixed?.interval,
+    }
+  })
   const series: echarts.SeriesOption[] = []
   if (props.mode === 'merge') {
     const normalised = measurementTypes.map((type) => {
@@ -479,7 +491,8 @@ function drawPvPreviews() {
     const data = props.data
     if (!canvas || !shell || !data || !chart) return
     const height = 132
-    const pressure = data.series.find((series) => series.measurementType === '压力')?.data ?? []
+    const pressureSeries = data.series.find((series) => series.measurementType === '压力')
+    const pressure = pressureSeries?.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
@@ -547,7 +560,8 @@ function drawPvPreviews() {
       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 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)

+ 2 - 0
frontend/src/types.ts

@@ -65,6 +65,8 @@ export type WaveSeries = {
   measurementType: MeasurementType
   color: string
   data: WaveValue[]
+  min?: number
+  max?: number
 }
 
 export type Cycle = {

+ 37 - 0
frontend/src/utils/axis.ts

@@ -0,0 +1,37 @@
+export type FixedAxis = { min: number; max: number; interval: number }
+
+export function niceTickInterval(range: number, targetTicks: number): number {
+  if (!Number.isFinite(range) || range <= 0) return 1
+  const raw = range / targetTicks
+  const magnitude = 10 ** Math.floor(Math.log10(raw))
+  const fraction = raw / magnitude
+  const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10
+  return niceFraction * magnitude
+}
+
+export function fixedAxisRange(
+  type: string,
+  dataMin: number | undefined,
+  dataMax: number | undefined,
+): FixedAxis | null {
+  if (dataMin == null || dataMax == null || !Number.isFinite(dataMin) || !Number.isFinite(dataMax)) return null
+  let range = dataMax - dataMin
+  if (range <= 0) range = Math.max(Math.abs(dataMax) * 0.1, 1e-9)
+  const pad = range * 0.1
+  let min = dataMin - pad
+  let max = dataMax + pad
+  if (type === '位移') {
+    const interval = niceTickInterval(max - min, 6)
+    min = Math.floor(min / interval) * interval
+    max = Math.ceil(max / interval) * interval
+    return { min, max, interval }
+  }
+  const raw = (max - min) / 6
+  const magnitude = 10 ** Math.floor(Math.log10(Math.max(raw, 1)))
+  const fraction = raw / magnitude
+  const niceFraction = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10
+  const interval = Math.max(1, niceFraction * magnitude)
+  min = Math.floor(min / interval) * interval
+  max = Math.ceil(max / interval) * interval
+  return { min, max, interval }
+}