Przeglądaj źródła

fix(water): render complete facility gis markers

Kazerin 1 miesiąc temu
rodzic
commit
dbaf8f2412

+ 43 - 0
docs/design/2026-09-09-water-supply-gis-marker-style.md

@@ -0,0 +1,43 @@
+# 供水 GIS 点位标注规范
+
+## 状态
+accepted
+
+## 决策
+
+供水 GIS 一张图统一使用 `GisMapCanvas` 渲染点位。输入坐标保持 WGS84;组件在渲染到百度地图前统一转换为 BD09。
+
+### 坐标点
+
+- 使用百度地图官方 `T_MarkPoint_*` 图标素材和 `BMapGL.Marker + BMapGL.Icon` 渲染坐标点。
+- 点位名称使用独立 `BMapGL.Label`,显示在坐标点上方。
+- 点击点位或搜索结果时定位并放大到对应要素。
+- 管网 `LineString` 绘制管线,并在起点、终点显示坐标点。
+
+### 设施类型颜色
+
+设施类型与官方图标素材的映射如下:
+
+| 类型 | 名称 | 图标 |
+| --- | --- | --- |
+| `source` | 水源地 | `T_MarkPoint_Blue.png` |
+| `plant` | 水厂 | `T_MarkPoint_Green.png` |
+| `pumpStation` | 泵站 | `T_MarkPoint_Red.png` |
+| `pipe` | 供水管网 | `T_MarkPoint_Yellow.png` |
+| `user` | 用水户 | `T_MarkPoint_Black.png` |
+
+该映射只表示设施类型,不表示设备运行状态。
+
+### 监测点状态颜色
+
+监测设备和报警点位使用状态语义:
+
+| 状态 | 颜色/素材 |
+| --- | --- |
+| 正常 | 绿色 / `T_MarkPoint_Green.png` |
+| 报警 | 红色 / `T_MarkPoint_Red.png` |
+| 预警 | 黄色 / `T_MarkPoint_Yellow.png` |
+| 有关联工单 | 蓝色 / `T_MarkPoint_Blue.png` |
+| 离线 | 灰色 / `T_MarkPoint_Grey.png` |
+
+状态判定不得替代设备状态、报警记录状态或工单状态的独立数据来源。

+ 33 - 24
src/components/GisMapCanvas.vue

@@ -5,13 +5,14 @@
 <script setup>
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { convertCoord } from '@/utils/coordTransform'
+import { getMonitoringPointIcon } from '@/utils/gisMapColors'
 
 const props = defineProps({
   points: { type: Array, default: () => [] },
   lines: { type: Array, default: () => [] },
   center: { type: Object, default: () => ({ lng: 110.3965, lat: 28.4638 }) },
   zoom: { type: Number, default: 15 },
-  markerSize: { type: Number, default: 22 },
+  markerSize: { type: Number, default: 28 },
   showMarkerName: { type: Boolean, default: true },
   mapStyleId: { type: String, default: 'a0f4e6e6f9e7a6c5d8e7f7e7f7e7f7e5' }
 })
@@ -78,20 +79,32 @@ function clearScene() {
 }
 
 
+function createMarkerIcon(BMapGL, iconUrl, size) {
+  return new BMapGL.Icon(iconUrl, new BMapGL.Size(size, size), {
+    anchor: new BMapGL.Size(size / 2, size),
+    imageSize: new BMapGL.Size(size, size)
+  })
+}
+
 function addPointMarker(BMapGL, point) {
   if (!Number.isFinite(Number(point.lng)) || !Number.isFinite(Number(point.lat))) return
   const [lng, lat] = convertCoord(point.lng, point.lat)
   const name = point.name || point.equipmentName || point.facilityName || point.label || ''
+  const iconUrl = point.iconUrl || getMonitoringPointIcon(point)
   const position = new BMapGL.Point(lng, lat)
-  const marker = new BMapGL.Marker(position)
+  const marker = new BMapGL.Marker(position, {
+    icon: createMarkerIcon(BMapGL, iconUrl, props.markerSize),
+    enableDragging: false
+  })
   marker.addEventListener('click', (event) => {
     event?.domEvent?.stopPropagation?.()
     emit('point-click', point)
   })
   mapInstance.addOverlay(marker)
   overlays.push(marker)
+
   if (props.showMarkerName && name) {
-    const label = new BMapGL.Label(`<div class="marker-text">${escapeHtml(name)}</div>`, {
+    const label = new BMapGL.Label(`<div class="gis-marker-label-text">${escapeHtml(name)}</div>`, {
       position,
       offset: new BMapGL.Size(-90, -props.markerSize - 8)
     })
@@ -128,7 +141,12 @@ function addLine(BMapGL, line) {
   ;[points[0], points[points.length - 1]].forEach((point, index) => {
     const [lng, lat] = convertCoord(point.lng, point.lat)
     const position = new BMapGL.Point(lng, lat)
-    const marker = new BMapGL.Marker(position)
+    const iconUrl = line.iconUrl || getMonitoringPointIcon(point)
+    const name = `${line.name || '供水管网'} ${index === 0 ? '起点' : '终点'}`
+    const marker = new BMapGL.Marker(position, {
+      icon: createMarkerIcon(BMapGL, iconUrl, props.markerSize),
+      enableDragging: false
+    })
     marker.addEventListener('click', (event) => {
       event?.domEvent?.stopPropagation?.()
       emit('line-click', line)
@@ -137,8 +155,7 @@ function addLine(BMapGL, line) {
     overlays.push(marker)
 
     if (!props.showMarkerName) return
-    const name = `${line.name || '供水管网'} ${index === 0 ? '起点' : '终点'}`
-    const label = new BMapGL.Label(`<div class="marker-text">${escapeHtml(name)}</div>`, {
+    const label = new BMapGL.Label(`<div class="gis-marker-label-text">${escapeHtml(name)}</div>`, {
       position,
       offset: new BMapGL.Size(-90, -props.markerSize - 8)
     })
@@ -223,7 +240,16 @@ defineExpose({
   background: #e9f0e5;
 }
 
-.marker-text {
+</style>
+
+<style>
+.gis-marker-label {
+  border: none !important;
+  background: transparent !important;
+  padding: 0 !important;
+}
+
+.gis-marker-label-text {
   box-sizing: border-box;
   width: 180px;
   margin-bottom: 4px;
@@ -241,21 +267,4 @@ defineExpose({
   text-overflow: ellipsis;
   white-space: nowrap;
 }
-
-.marker-wrap {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-}
-
-.marker-icon {
-  display: block;
-  object-fit: contain;
-}
-
-.pipe-label {
-  color: #2563eb;
-  font-size: 12px;
-  font-weight: 600;
-}
 </style>

+ 12 - 0
src/utils/gisMapColors.js

@@ -14,6 +14,18 @@ export const gisPointStatusIcons = {
   offline: new URL('../assets/gis/T_MarkPoint_Grey.png', import.meta.url).href
 }
 
+export const facilityPointIcons = {
+  source: new URL('../assets/gis/T_MarkPoint_Blue.png', import.meta.url).href,
+  plant: new URL('../assets/gis/T_MarkPoint_Green.png', import.meta.url).href,
+  pumpStation: new URL('../assets/gis/T_MarkPoint_Red.png', import.meta.url).href,
+  pipe: new URL('../assets/gis/T_MarkPoint_Yellow.png', import.meta.url).href,
+  user: new URL('../assets/gis/T_MarkPoint_Black.png', import.meta.url).href
+}
+
+export function getFacilityPointIcon(type = '') {
+  return facilityPointIcons[type] || facilityPointIcons.plant
+}
+
 export const gisPointStatusLabels = {
   normal: '正常',
   alarm: '报警',

+ 3 - 1
src/views/subSystem/waterSupply/components/WaterAlarmPage.vue

@@ -276,6 +276,7 @@
     import GisMapCanvas from '@/components/GisMapCanvas.vue'
     import { getMonitoringPointColor } from '@/utils/gisMapColors'
     import {normalizeMapPoints, normalizePage, unwrapResponse} from '@/utils/waterSupplyModel'
+import { getMonitoringPointIcon } from '@/utils/gisMapColors'
     import {getRequestErrorMessage} from '@/utils/waterFacilityQuery'
 
     const props = defineProps({
@@ -309,7 +310,8 @@
         lng: point._longitude ?? point.longitude,
         lat: point._latitude ?? point.latitude,
         name: point.deviceCode || point.name || '报警点',
-        color: getMonitoringPointColor(point)
+        color: getMonitoringPointColor(point),
+        iconUrl: getMonitoringPointIcon(point)
     })))
 
     const mapAlarmCount = computed(() => mapPoints.value.length);

+ 1 - 1
src/views/subSystem/waterSupply/components/WaterDeviceGisMap.vue

@@ -169,7 +169,7 @@
 import { computed, nextTick, onMounted, ref } from 'vue'
 import { Aim, Refresh, Search } from '@element-plus/icons-vue'
 import GisMapCanvas from '@/components/GisMapCanvas.vue'
-import { getMonitoringPointColor, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '@/utils/gisMapColors'
+import { getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '@/utils/gisMapColors'
 import {
   getDeviceGisAbnormalPoints,
   getDeviceGisAllPoints,

+ 44 - 23
src/views/subSystem/waterSupply/facility/gis.vue

@@ -84,8 +84,8 @@
           :lines="mapLines"
           :center="centerPoint"
           :zoom="15"
-          @point-click="openFacilityDetail"
-          @line-click="openFacilityDetail"
+          @point-click="locateFeature"
+          @line-click="locateFeature"
           @map-click="detailOpen = false"
           @ready="handleMapReady"
           @error="mapError = $event"
@@ -136,6 +136,7 @@ import { getWaterFacilityOverview, getWaterFacilityFeatures, getWaterFacilityDet
 import { normalizeGeoJson, unwrapResponse } from '@/utils/waterSupplyModel'
 import { getRequestErrorMessage } from '@/utils/waterFacilityQuery'
 import { buildFacilityMapModel, filterFacilityMapModel, findFacilityFeature } from '@/utils/waterFacilityGisModel'
+import { getFacilityPointIcon } from '@/utils/gisMapColors'
 
 const centerPoint = { lng: 110.3965, lat: 28.4638 }
 
@@ -148,19 +149,19 @@ const defaultLayerState = () => ({
 })
 
 const facilityTypes = [
-  { label: '水源地', value: 'source', color: '#457b9d' },
-  { label: '水厂', value: 'plant', color: '#2a9d8f' },
-  { label: '泵站', value: 'pumpStation', color: '#e76f51' },
-  { label: '管网', value: 'pipe', color: '#fcbf49' },
-  { label: '用水户', value: 'user', color: '#7f4f24' }
+  { label: '水源地', value: 'source', color: '#1f77d0' },
+  { label: '水厂', value: 'plant', color: '#2ecc71' },
+  { label: '泵站', value: 'pumpStation', color: '#e74c3c' },
+  { label: '管网', value: 'pipe', color: '#f1c40f' },
+  { label: '用水户', value: 'user', color: '#333333' }
 ]
 
 const colorMap = {
-  source: '#457b9d',
-  plant: '#2a9d8f',
-  pumpStation: '#e76f51',
-  pipe: '#fcbf49',
-  user: '#7f4f24'
+  source: '#1f77d0',
+  plant: '#2ecc71',
+  pumpStation: '#e74c3c',
+  pipe: '#f1c40f',
+  user: '#333333'
 }
 
 const labelMap = {
@@ -223,12 +224,14 @@ const filteredFeatures = computed(() => {
 const mapPoints = computed(() => visibleMapModel.value.points.map(point => ({
   ...point,
   color: colorMap[point.type] || '#457b9d',
+  iconUrl: getFacilityPointIcon(point.type),
   name: point.name || point.code || '供水设施'
 })))
 
 const mapLines = computed(() => visibleMapModel.value.lines.map(line => ({
   ...line,
   color: colorMap[line.type] || '#fcbf49',
+  iconUrl: getFacilityPointIcon(line.type),
   name: line.name || '供水管网'
 })))
 
@@ -304,8 +307,20 @@ function handleSearch() {
   ElMessage.success(`已定位到 ${matched.name}`)
 }
 
+function featureFocus(feature) {
+  if (Array.isArray(feature.points) && feature.points.length) {
+    const first = feature.points[0]
+    const last = feature.points[feature.points.length - 1]
+    return {
+      lng: (first.lng + last.lng) / 2,
+      lat: (first.lat + last.lat) / 2
+    }
+  }
+  return feature
+}
+
 function locateFeature(feature) {
-  gisMapRef.value?.locatePoint(feature)
+  gisMapRef.value?.locatePoint(featureFocus(feature))
   openFacilityDetail(feature)
 }
 
@@ -384,21 +399,26 @@ async function refreshScene() {
   display: grid;
   grid-template-columns: 310px minmax(0, 1fr);
   gap: 16px;
-  align-items: start;
+  height: calc(100vh - 140px);
+  min-height: 620px;
 }
 .side-panel,
 .map-panel {
-  position: relative;
-  height: calc(100vh - 140px);
-  min-height: 620px;
-  overflow: hidden;
+  background: rgba(255,255,255,.94);
+  border: 1px solid #dbe8f5;
+  border-radius: 10px;
+  box-shadow: 0 10px 28px rgba(31,74,121,.08);
 }
 .side-panel {
   display: flex;
   flex-direction: column;
   gap: 14px;
   padding: 16px;
-  overflow: visible;
+  overflow-y: auto;
+}
+.map-panel {
+  position: relative;
+  overflow: hidden;
 }
 .metric-grid {
   display: grid;
@@ -455,17 +475,18 @@ async function refreshScene() {
   flex-shrink: 0;
 }
 .result-section {
-  flex: none;
-  min-height: 220px;
+  flex: 1;
+  min-height: 0;
   display: flex;
   flex-direction: column;
 }
 .device-result-list {
-  flex: none;
+  flex: 1;
+  min-height: 0;
   display: flex;
   flex-direction: column;
   gap: 6px;
-  overflow-y: visible;
+  overflow-y: auto;
 }
 .device-result-list button {
   width: 100%;

+ 21 - 7
tests/gisMapCanvas.test.mjs

@@ -1,7 +1,7 @@
 import test from 'node:test'
 import assert from 'node:assert/strict'
 import { readFile } from 'node:fs/promises'
-import { getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '../src/utils/gisMapColors.js'
+import { getFacilityPointIcon, getMonitoringPointColor, getMonitoringPointIcon, getMonitoringPointLabel, getMonitoringPointStatus, getMonitoringPointTagType } from '../src/utils/gisMapColors.js'
 
 test('shared gis map canvas converts WGS84 coordinates before BMapGL rendering', async () => {
   const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
@@ -13,19 +13,32 @@ test('shared gis map canvas converts WGS84 coordinates before BMapGL rendering',
   assert.doesNotMatch(source, /new BMapGL\.Point\(point\.lng, point\.lat\)/u)
 })
 
-test('shared gis map canvas uses the official default marker with labels above points', async () => {
+test('shared gis map canvas renders every point as an official icon with a label above it', async () => {
   const source = await readFile('src/components/GisMapCanvas.vue', 'utf8')
 
-  assert.match(source, /markerSize: \{ type: Number, default: 22 \}/u)
-  assert.match(source, /const marker = new BMapGL\.Marker\(position\)/u)
-  assert.doesNotMatch(source, /new BMapGL\.Icon\(/u)
-  assert.match(source, /<div class="marker-text">/u)
+  assert.match(source, /markerSize: \{ type: Number, default: 28 \}/u)
+  assert.match(source, /const iconUrl = point\.iconUrl \|\| getMonitoringPointIcon\(point\)/u)
+  assert.match(source, /new BMapGL\.Icon\(iconUrl, new BMapGL\.Size\(size, size\), \{/u)
+  assert.match(source, /anchor: new BMapGL\.Size\(size \/ 2, size\)/u)
+  assert.match(source, /const marker = new BMapGL\.Marker\(position, \{/u)
+  assert.match(source, /new BMapGL\.Label\(`<div class="gis-marker-label-text">/u)
   assert.match(source, /offset: new BMapGL\.Size\(-90, -props\.markerSize - 8\)/u)
+  assert.doesNotMatch(source, /<img class="marker-icon"/u)
+  assert.doesNotMatch(source, /class="marker-wrap"/u)
   assert.match(source, /供水管网.*起点/u)
   assert.match(source, /终点/u)
   assert.ok(source.includes("emit('point-click', point)"))
 })
 
+test('facility point icons follow the five-facility-type contract', () => {
+  assert.match(getFacilityPointIcon('source'), /T_MarkPoint_Blue\.png/u)
+  assert.match(getFacilityPointIcon('plant'), /T_MarkPoint_Green\.png/u)
+  assert.match(getFacilityPointIcon('pumpStation'), /T_MarkPoint_Red\.png/u)
+  assert.match(getFacilityPointIcon('pipe'), /T_MarkPoint_Yellow\.png/u)
+  assert.match(getFacilityPointIcon('user'), /T_MarkPoint_Black\.png/u)
+  assert.match(getFacilityPointIcon('unknown'), /T_MarkPoint_Green\.png/u)
+})
+
 test('monitoring point colors follow the documented status semantics', () => {
   assert.equal(getMonitoringPointStatus({ relatedOrderNo: 'WO-1' }), 'workOrder')
   assert.equal(getMonitoringPointStatus({ relatedWorkOrderNo: 'WO-2' }), 'workOrder')
@@ -66,7 +79,8 @@ test('water supply GIS pages share the common canvas component', async () => {
   assert.match(facilitySource, /<GisMapCanvas/u)
   assert.match(facilitySource, /:points="mapPoints"/u)
   assert.match(facilitySource, /:lines="mapLines"/u)
-  assert.match(facilitySource, /@point-click="openFacilityDetail"/u)
+  assert.match(facilitySource, /iconUrl: getFacilityPointIcon\(point\.type\)/u)
+  assert.match(facilitySource, /@point-click="locateFeature"/u)
   const alarmSource = await readFile('src/views/subSystem/waterSupply/components/WaterAlarmPage.vue', 'utf8')
 
   assert.match(alarmSource, /<GisMapCanvas/u)

+ 2 - 1
tests/waterFacilityGis.test.mjs

@@ -70,5 +70,6 @@ test('GIS page exposes REST-backed right detail drawer and empty layer state', (
   assert.match(source, /detailError/u)
   assert.match(source, /当前筛选下没有可显示的供水设施/u)
   assert.match(source, /const centerPoint = \{ lng: 110\.3965, lat: 28\.4638 \}/u)
-  assert.match(source, /overflow: visible/u)
+  assert.match(source, /function featureFocus\(feature\)/u)
+  assert.match(source, /overflow-y: auto/u)
 })