Ver código fonte

feat(digital): 新增数字油藏测井曲线可视化页面

- 接入井列表和测井数据接口
- 实现多曲线分道展示及深度联动
- 按相近阈值对测井曲线进行分组
- 统一深度索引压缩,避免曲线数据错位
- 优化独立加载状态、Tooltip 和页面布局
Zimo 1 semana atrás
pai
commit
61e9b4ecfc
2 arquivos alterados com 780 adições e 0 exclusões
  1. 49 0
      src/api/digital/index.ts
  2. 731 0
      src/views/digital/index.vue

+ 49 - 0
src/api/digital/index.ts

@@ -0,0 +1,49 @@
+import request from '@/config/axios'
+
+export interface DigitalWell {
+  id: number
+  well?: string
+  uwi?: string
+  comp?: string
+  fld?: string
+  cnty?: string
+  stat?: string
+  start?: number
+  stop?: number
+  step?: number
+  depth?: number | null
+  elev?: number
+  lat?: number
+  lon?: number
+  [key: string]: unknown
+}
+
+export interface WellListResult {
+  list: DigitalWell[]
+  total: number
+}
+
+export interface WellLogDataset {
+  wellInfo?: DigitalWell
+  depths: number[]
+  sws?: number[]
+  dts?: number[]
+  porns?: number[]
+  rhobs?: number[]
+  rxos?: number[]
+  r25s?: number[]
+  cilds?: number[]
+  calis?: number[]
+  sps?: number[]
+  grs?: number[]
+  [key: string]: unknown
+}
+
+export const DigitalApi = {
+  getWellLogDataset: (wellId: number): Promise<WellLogDataset> => {
+    return request.get({ url: '/pms/las-well-data/wellLogDataset', params: { wellId } })
+  },
+  getWellList: (): Promise<WellListResult> => {
+    return request.get({ url: '/pms/las-well/page', params: { pageNo: 1, pageSize: 100 } })
+  }
+}

+ 731 - 0
src/views/digital/index.vue

@@ -0,0 +1,731 @@
+<script lang="ts" setup>
+import { DigitalApi, type DigitalWell, type WellLogDataset } from '@/api/digital'
+import { Search } from '@element-plus/icons-vue'
+import * as echarts from 'echarts'
+
+defineOptions({ name: 'DigitalWellLog' })
+
+type CurveKey =
+  | 'sws'
+  | 'dts'
+  | 'porns'
+  | 'rhobs'
+  | 'rxos'
+  | 'r25s'
+  | 'cilds'
+  | 'calis'
+  | 'sps'
+  | 'grs'
+
+interface CurveConfig {
+  name: string
+  key: CurveKey
+  unit: string
+  min: number
+  max: number
+  color: string
+  track: number
+  inverse?: boolean
+  lineType?: 'solid' | 'dashed' | 'dotted'
+}
+
+const curves: CurveConfig[] = [
+  { name: 'GR', key: 'grs', unit: 'API', min: 30, max: 300, color: '#16a34a', track: 2 },
+  { name: 'SP', key: 'sps', unit: 'mV', min: 10, max: 300, color: '#2563eb', track: 2 },
+  {
+    name: 'CALI',
+    key: 'calis',
+    unit: 'mm',
+    min: 450,
+    max: 800,
+    color: '#dc2626',
+    track: 3,
+    lineType: 'dashed'
+  },
+  {
+    name: 'RESX',
+    key: 'rxos',
+    unit: 'ohm.m',
+    min: 1,
+    max: 30,
+    color: '#f97316',
+    track: 0,
+    lineType: 'dotted'
+  },
+  { name: 'RESS', key: 'r25s', unit: 'ohm.m', min: 1, max: 30, color: '#d946ef', track: 0 },
+  { name: 'RESD', key: 'cilds', unit: 'ohm·m', min: 1, max: 900, color: '#2563eb', track: 3 },
+  { name: 'RHOB', key: 'rhobs', unit: 'g/cm³', min: 1.95, max: 2.95, color: '#111827', track: 0 },
+  {
+    name: 'NPHI',
+    key: 'porns',
+    unit: 'v/v',
+    min: 0,
+    max: 1,
+    color: '#2563eb',
+    track: 0
+  },
+  {
+    name: 'DTC_SINT',
+    key: 'dts',
+    unit: 'us/m',
+    min: 30,
+    max: 200,
+    color: '#7c3aed',
+    track: 1,
+    lineType: 'dashed'
+  },
+  { name: 'SW', key: 'sws', unit: 'v/v_decimal', min: 20, max: 100, color: '#0891b2', track: 1 }
+]
+
+const trackNames = [
+  '低量程(约 0~30)',
+  '中低量程(约 20~200)',
+  '中量程(约 10~300)',
+  '高量程(约 450~900)'
+]
+const trackCurves = trackNames.map((_, track) => curves.filter((curve) => curve.track === track))
+const trackLayout = [
+  { left: '7%', width: '21%' },
+  { left: '29%', width: '21%' },
+  { left: '51%', width: '21%' },
+  { left: '73%', width: '21%' }
+]
+const SAMPLE_LIMIT = 2000
+
+const message = useMessage()
+const chartRef = ref<HTMLElement>()
+const wells = ref<DigitalWell[]>([])
+const selectedWellId = ref<number>()
+const keyword = ref('')
+const dataset = shallowRef<WellLogDataset>()
+const listLoading = ref(false)
+const dataLoading = ref(false)
+const sampledIndices = shallowRef<number[]>([])
+const chart = shallowRef<echarts.ECharts>()
+let resizeObserver: ResizeObserver | undefined
+
+const filteredWells = computed(() => {
+  const word = keyword.value.trim().toLowerCase()
+  if (!word) return wells.value
+  return wells.value.filter((well) =>
+    `${well.well ?? ''} ${well.uwi ?? ''}`.toLowerCase().includes(word)
+  )
+})
+
+const selectedWell = computed(() => {
+  return wells.value.find((well) => well.id === selectedWellId.value) ?? dataset.value?.wellInfo
+})
+
+const originalCount = computed(() => dataset.value?.depths?.length ?? 0)
+const depthRange = computed(() => {
+  const depths = dataset.value?.depths ?? []
+  let min = Number.POSITIVE_INFINITY
+  let max = Number.NEGATIVE_INFINITY
+  for (const rawDepth of depths) {
+    const depth = Number(rawDepth)
+    if (!Number.isFinite(depth)) continue
+    if (depth < min) min = depth
+    if (depth > max) max = depth
+  }
+  if (!Number.isFinite(min) || !Number.isFinite(max)) return '--'
+  return `${min.toFixed(2)} – ${max.toFixed(2)} m`
+})
+
+function getWellName(well?: DigitalWell) {
+  return well?.well || well?.uwi || (well?.id ? `井 ${well.id}` : '--')
+}
+
+/**
+ * 按深度等距寻找最近点,只生成一次索引;所有曲线严格复用同一组索引。
+ * 这样既适配不等间距深度,也不会发生曲线和深度错位。
+ */
+function createDepthSampleIndices(depths: number[], limit: number) {
+  const valid = depths
+    .map((depth, index) => ({ depth: Number(depth), index }))
+    .filter((item) => Number.isFinite(item.depth))
+  if (valid.length <= limit) return valid.map((item) => item.index)
+
+  const first = valid[0]
+  const last = valid[valid.length - 1]
+  const ascending = last.depth >= first.depth
+  const result = new Set<number>([first.index, last.index])
+  let cursor = 0
+
+  for (let point = 1; point < limit - 1; point += 1) {
+    const target = first.depth + ((last.depth - first.depth) * point) / (limit - 1)
+    while (cursor < valid.length - 2) {
+      const currentDistance = Math.abs(valid[cursor].depth - target)
+      const nextDistance = Math.abs(valid[cursor + 1].depth - target)
+      const passedTarget = ascending
+        ? valid[cursor + 1].depth > target
+        : valid[cursor + 1].depth < target
+      if (nextDistance > currentDistance && passedTarget) break
+      cursor += 1
+    }
+    result.add(valid[cursor].index)
+  }
+
+  return Array.from(result).sort((a, b) => a - b)
+}
+
+function curveValue(curve: CurveConfig, index: number): number | null {
+  const raw = (dataset.value?.[curve.key] as number[] | undefined)?.[index]
+  const value = Number(raw)
+  if (!Number.isFinite(value) || value <= -900) return null
+  // SW 接口为 0~1 小数时换算成用户给定的 20~100 显示范围。
+  if (curve.key === 'sws' && Math.abs(value) <= 1.5) return value * 100
+  return value
+}
+
+function formatNumber(value: unknown) {
+  const number = Number(value)
+  if (!Number.isFinite(number)) return '--'
+  return Math.abs(number) >= 100
+    ? number.toFixed(1)
+    : number.toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
+}
+
+function buildChartOption(): echarts.EChartsOption {
+  const depths = dataset.value?.depths ?? []
+  const indices = sampledIndices.value
+  const sampledDepths = indices.map((index) => Number(depths[index])).filter(Number.isFinite)
+  const minDepth = sampledDepths.length ? Math.min(...sampledDepths) : 0
+  const maxDepth = sampledDepths.length ? Math.max(...sampledDepths) : 1
+
+  const xAxis: any[] = curves.map((curve) => ({
+    type: 'value',
+    gridIndex: curve.track,
+    min: curve.min,
+    max: curve.max,
+    inverse: curve.inverse,
+    show: false,
+    splitNumber: 5
+  }))
+
+  const yAxis: any[] = trackLayout.map((_, track) => ({
+    type: 'value' as const,
+    gridIndex: track,
+    min: minDepth,
+    max: maxDepth,
+    inverse: true,
+    axisLine: { show: true, lineStyle: { color: '#64748b', width: 1 } },
+    axisTick: { show: track === 0, length: 5, lineStyle: { color: '#64748b' } },
+    axisLabel: {
+      show: track === 0,
+      margin: 9,
+      color: '#334155',
+      fontSize: 11,
+      formatter: (value: number) => value.toFixed(1)
+    },
+    splitLine: { show: true, lineStyle: { color: '#cbd5e1', width: 1 } },
+    minorTick: { show: true, splitNumber: 5 },
+    minorSplitLine: { show: true, lineStyle: { color: '#eef2f7' } },
+    axisPointer: {
+      show: true,
+      snap: true,
+      label: {
+        show: track === 0,
+        formatter: ({ value }: { value: number }) => `${Number(value).toFixed(2)} m`
+      }
+    }
+  }))
+
+  const series = curves.map((curve, curveIndex) => ({
+    name: curve.name,
+    type: 'line' as const,
+    xAxisIndex: curveIndex,
+    yAxisIndex: curve.track,
+    data: indices.map((index) => [curveValue(curve, index), Number(depths[index])]),
+    showSymbol: false,
+    symbol: 'none',
+    connectNulls: false,
+    animation: false,
+    clip: true,
+    progressive: 4000,
+    progressiveThreshold: 8000,
+    lineStyle: { color: curve.color, width: 1.25, type: curve.lineType ?? 'solid' },
+    emphasis: { disabled: true }
+  }))
+
+  return {
+    animation: false,
+    backgroundColor: '#ffffff',
+    grid: trackLayout.map((layout) => ({
+      ...layout,
+      top: 8,
+      bottom: 32,
+      containLabel: false,
+      show: true,
+      borderColor: '#64748b',
+      borderWidth: 1
+    })),
+    xAxis,
+    yAxis,
+    series,
+    axisPointer: {
+      link: [{ yAxisIndex: [0, 1, 2, 3] }],
+      lineStyle: { color: '#ef4444', type: 'dashed' }
+    },
+    tooltip: {
+      trigger: 'axis',
+      confine: true,
+      backgroundColor: 'rgba(15, 23, 42, .94)',
+      borderWidth: 0,
+      textStyle: { color: '#f8fafc', fontSize: 12 },
+      // 测井图以深度为主轴。明确使用 y 轴触发,避免 NPHI 等重复值
+      // 被 ECharts 按相同 x 值一次性全部收进 tooltip。
+      axisPointer: { type: 'line', axis: 'y' },
+      formatter: (params: any) => {
+        const rows = Array.isArray(params) ? params : [params]
+        const uniqueRows = Array.from(
+          new Map(rows.map((item: any) => [item.seriesName, item])).values()
+        ) as any[]
+        const depth = uniqueRows[0]?.value?.[1]
+        const content = uniqueRows
+          .map((item: any) => {
+            const curve = curves.find((entry) => entry.name === item.seriesName)
+            const value = item.value?.[0]
+            return `<div style="display:flex;justify-content:space-between;gap:24px;margin-top:5px"><span><i style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${item.color};margin-right:7px"></i>${item.seriesName}</span><b>${formatNumber(value)} ${curve?.unit ?? ''}</b></div>`
+          })
+          .join('')
+        return `<div style="font-weight:600;margin-bottom:3px">深度 ${formatNumber(depth)} m</div>${content}`
+      }
+    },
+    dataZoom: [
+      {
+        type: 'inside',
+        yAxisIndex: [0, 1, 2, 3],
+        filterMode: 'none',
+        zoomOnMouseWheel: true,
+        moveOnMouseMove: true,
+        moveOnMouseWheel: false
+      },
+      {
+        type: 'slider',
+        yAxisIndex: [0, 1, 2, 3],
+        filterMode: 'none',
+        right: 4,
+        top: 8,
+        bottom: 32,
+        width: 14,
+        showDetail: false,
+        borderColor: '#cbd5e1',
+        fillerColor: 'rgba(37, 99, 235, .14)',
+        handleSize: 14
+      }
+    ]
+  }
+}
+
+function renderChart() {
+  if (!chartRef.value) return
+  if (!chart.value) chart.value = echarts.init(chartRef.value)
+  chart.value.setOption(buildChartOption(), true)
+}
+
+function updateSampling() {
+  sampledIndices.value = createDepthSampleIndices(dataset.value?.depths ?? [], SAMPLE_LIMIT)
+  nextTick(renderChart)
+}
+
+async function loadWellData(wellId?: number) {
+  if (!wellId) return
+  dataLoading.value = true
+  try {
+    dataset.value = await DigitalApi.getWellLogDataset(wellId)
+    updateSampling()
+  } catch (error) {
+    dataset.value = undefined
+    sampledIndices.value = []
+    renderChart()
+    message.error('测井数据加载失败,请稍后重试')
+  } finally {
+    dataLoading.value = false
+  }
+}
+
+async function loadWells() {
+  listLoading.value = true
+  let initialWellId: number | undefined
+  try {
+    const result = await DigitalApi.getWellList()
+    wells.value = result?.list ?? []
+    if (!selectedWellId.value && wells.value.length) selectedWellId.value = wells.value[0].id
+    initialWellId = selectedWellId.value
+  } catch (error) {
+    message.error('井列表加载失败,请稍后重试')
+  } finally {
+    listLoading.value = false
+  }
+
+  if (initialWellId) await loadWellData(initialWellId)
+}
+
+function selectWell(well: DigitalWell) {
+  if (well.id === selectedWellId.value) return
+  selectedWellId.value = well.id
+  loadWellData(well.id)
+}
+
+onMounted(async () => {
+  resizeObserver = new ResizeObserver(() => chart.value?.resize())
+  if (chartRef.value) resizeObserver.observe(chartRef.value)
+  renderChart()
+  await loadWells()
+})
+
+onBeforeUnmount(() => {
+  resizeObserver?.disconnect()
+  chart.value?.dispose()
+})
+</script>
+
+<template>
+  <div
+    class="digital-shell h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    <aside class="well-panel">
+      <div class="panel-title">
+        <div>
+          <div class="eyebrow">WELL DIRECTORY</div>
+          <h2>井目录</h2>
+        </div>
+        <span class="well-count">{{ wells.length }}</span>
+      </div>
+
+      <el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索井名 / UWI" />
+
+      <div v-loading="listLoading" class="well-list">
+        <button
+          v-for="well in filteredWells"
+          :key="well.id"
+          type="button"
+          class="well-item"
+          :class="{ active: selectedWellId === well.id }"
+          @click="selectWell(well)">
+          <span class="well-status"></span>
+          <span class="well-text">
+            <strong>{{ getWellName(well) }}</strong>
+            <small>{{ well.uwi || `ID ${well.id}` }}</small>
+          </span>
+          <span class="well-depth">{{ well.stop ? `${well.stop.toFixed(0)}m` : '' }}</span>
+        </button>
+        <el-empty
+          v-if="!listLoading && !filteredWells.length"
+          :image-size="70"
+          description="暂无匹配井" />
+      </div>
+
+      <div v-if="selectedWell" class="well-summary">
+        <div class="summary-caption">当前井信息</div>
+        <dl>
+          <div
+            ><dt>井名</dt><dd>{{ getWellName(selectedWell) }}</dd></div
+          >
+          <div
+            ><dt>UWI</dt><dd>{{ selectedWell.uwi || '--' }}</dd></div
+          >
+          <div
+            ><dt>深度段</dt><dd>{{ depthRange }}</dd></div
+          >
+          <div
+            ><dt>采样间隔</dt><dd>{{ selectedWell.step ?? '--' }} m</dd></div
+          >
+        </dl>
+      </div>
+    </aside>
+
+    <main class="workspace">
+      <section v-loading="dataLoading" class="log-board">
+        <div class="track-header">
+          <div v-for="(group, track) in trackCurves" :key="track" class="track-card">
+            <div v-for="curve in group" :key="curve.key" class="curve-scale">
+              <div class="curve-name" :style="{ color: curve.color }">
+                <i :style="{ backgroundColor: curve.color }"></i>{{ curve.name }}
+              </div>
+              <div class="scale-row"
+                ><span>{{ curve.inverse ? curve.max : curve.min }}</span
+                ><b>{{ curve.unit }}</b
+                ><span>{{ curve.inverse ? curve.min : curve.max }}</span></div
+              >
+            </div>
+          </div>
+        </div>
+        <div class="depth-caption">DEPTH<br /><span>m</span></div>
+        <div ref="chartRef" class="log-chart"></div>
+        <el-empty
+          v-if="!dataLoading && !originalCount"
+          class="chart-empty"
+          description="请选择有测井数据的井" />
+      </section>
+    </main>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.digital-shell {
+  --navy: #0f2742;
+
+  display: flex;
+  width: 100%;
+  overflow: hidden;
+  color: #1e293b;
+  background: #e8edf3;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+}
+
+.eyebrow {
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 1.5px;
+  color: #64748b;
+}
+
+.well-panel {
+  display: flex;
+  flex: 0 0 238px;
+  flex-direction: column;
+  gap: 14px;
+  padding: 18px 14px;
+  background: #f8fafc;
+  border-right: 1px solid #cbd5e1;
+}
+
+.panel-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.panel-title h2 {
+  margin: 3px 0 0;
+  font-size: 20px;
+  color: var(--navy);
+}
+
+.well-count {
+  min-width: 30px;
+  padding: 5px 8px;
+  font-size: 12px;
+  font-weight: 700;
+  color: #fff;
+  text-align: center;
+  background: var(--navy);
+  border-radius: 12px;
+}
+
+.well-list {
+  flex: 1;
+  min-height: 0;
+  padding-right: 3px;
+  overflow-y: auto;
+}
+
+.well-item {
+  display: flex;
+  width: 100%;
+  padding: 10px;
+  margin-bottom: 4px;
+  color: #334155;
+  text-align: left;
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  border-radius: 6px;
+  transition: 0.15s ease;
+  align-items: center;
+  gap: 9px;
+}
+
+.well-item:hover {
+  background: #e9eff6;
+}
+
+.well-item.active {
+  color: #0f3d69;
+  background: #dce9f6;
+  box-shadow: inset 3px 0 #2563eb;
+}
+
+.well-status {
+  width: 7px;
+  height: 7px;
+  flex: none;
+  background: #22c55e;
+  border-radius: 50%;
+  box-shadow: 0 0 0 3px rgb(34 197 94 / 12%);
+}
+
+.well-text {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  flex-direction: column;
+}
+
+.well-text strong {
+  overflow: hidden;
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.well-text small {
+  margin-top: 2px;
+  font-size: 10px;
+  color: #94a3b8;
+}
+
+.well-depth {
+  font-family: monospace;
+  font-size: 10px;
+  color: #64748b;
+}
+
+.well-summary {
+  padding: 13px;
+  background: #eef3f8;
+  border: 1px solid #d8e0e9;
+  border-radius: 6px;
+}
+
+.summary-caption {
+  margin-bottom: 8px;
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 1px;
+  color: #64748b;
+}
+
+.well-summary dl {
+  margin: 0;
+}
+
+.well-summary dl div {
+  display: flex;
+  justify-content: space-between;
+  gap: 10px;
+  padding: 4px 0;
+  font-size: 11px;
+}
+
+.well-summary dt {
+  color: #64748b;
+}
+
+.well-summary dd {
+  margin: 0;
+  overflow: hidden;
+  font-weight: 600;
+  color: #1e293b;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.workspace {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  flex-direction: column;
+  padding: 14px;
+}
+
+.log-board {
+  position: relative;
+  display: flex;
+  min-height: 0;
+  flex: 1;
+  flex-direction: column;
+  overflow: hidden;
+  background: #fff;
+  border: 1px solid #aebbc9;
+}
+
+.track-header {
+  display: grid;
+  flex: none;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 1.15%;
+  margin: 0 6% 0 7%;
+  background: #64748b;
+  border-right: 1px solid #64748b;
+  border-left: 1px solid #64748b;
+}
+
+.track-card {
+  min-width: 0;
+  background: #fff;
+  border-top: 1px solid #64748b;
+}
+
+.curve-scale {
+  padding: 5px 7px 6px;
+  border-bottom: 1px dotted #cbd5e1;
+}
+
+.curve-name {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  font-weight: 800;
+}
+
+.curve-name i {
+  width: 18px;
+  height: 2px;
+  margin-right: 6px;
+}
+
+.scale-row {
+  display: flex;
+  margin-top: 3px;
+  font-family: monospace;
+  font-size: 11px;
+  color: #334155;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.scale-row b {
+  font-weight: 500;
+  color: #64748b;
+}
+
+.depth-caption {
+  position: absolute;
+  top: 9px;
+  left: 1%;
+  z-index: 2;
+  width: 5%;
+  font-size: 13px;
+  font-weight: 800;
+  line-height: 1.4;
+  color: #334155;
+  text-align: center;
+}
+
+.depth-caption span {
+  font-size: 11px;
+  font-weight: 500;
+  color: #64748b;
+}
+
+.log-chart {
+  width: 100%;
+  min-height: 360px;
+  flex: 1;
+}
+
+.chart-empty {
+  position: absolute;
+  inset: 150px 0 0;
+  background: rgb(255 255 255 / 85%);
+}
+
+@media (width <= 1200px) {
+  .well-panel {
+    flex-basis: 205px;
+  }
+}
+</style>