소스 검색

首页改造

liujiejie 3 주 전
부모
커밋
b3f1436a86
2개의 변경된 파일과 523개의 추가작업 그리고 212개의 파일을 삭제
  1. 30 23
      .env.hd
  2. 493 189
      src/components/map/index.vue

+ 30 - 23
.env.hd

@@ -6,31 +6,38 @@ VUE_APP_PROJECT='hd'
 #外网
 # #http://106.120.102.238:18997/
 # VUE_APP_MAPVIEW="http://106.120.102.238:18000/tiles/{z}/{x}/{y}.png" # 地图废除
-VUE_APP_MAPVIEW=/tiles/{z}/{x}/{y}.png
-VUE_APP_UPLOAD="http://106.120.102.238:16700/energy-manage-service/api/check/upload"
-VUE_APP_APIPROXY='http://106.120.102.238:16700'
-VUE_APP_MAP=http://106.120.102.238:18080
-VUE_APP_WZLAPIPROXY='http://106.120.102.238:18080/WindTransDev'
-VUE_APP_ETLAPIPROXY='http://106.120.102.238:18080/WindTransDev'
-VUE_APP_AnalysisMultiAPIPROXY='http://106.120.102.238:28999/AnalysisMulti'
-#自定义算法文佳 目前无法使用,可能是服务未启动
-VUE_APP_sAlgorithmAPIPROXY='http://106.120.102.238:58880'
-VUE_APP_databaseApiAPIPROXY='http://106.120.102.238:58880'
-# #暂时不知下载报告内网dev 环境地址
-VUE_APP_downLoadChartAPIPROXY='http://106.120.102.238:58880'
+# VUE_APP_MAPVIEW=/tiles/{z}/{x}/{y}.png
+# VUE_APP_UPLOAD="http://106.120.102.238:16700/energy-manage-service/api/check/upload"
+# VUE_APP_APIPROXY='http://106.120.102.238:16700'
+# VUE_APP_MAP=http://106.120.102.238:18080
+# VUE_APP_WZLAPIPROXY='http://106.120.102.238:18080/WindTransDev'
+# VUE_APP_ETLAPIPROXY='http://106.120.102.238:18080/WindTransDev'
+# VUE_APP_AnalysisMultiAPIPROXY='http://106.120.102.238:28999/AnalysisMulti'
+# #自定义算法文佳 目前无法使用,可能是服务未启动
+# VUE_APP_sAlgorithmAPIPROXY='http://106.120.102.238:58880'
+# VUE_APP_databaseApiAPIPROXY='http://106.120.102.238:58880'
+# # #暂时不知下载报告内网dev 环境地址
+# VUE_APP_downLoadChartAPIPROXY='http://106.120.102.238:58880'
 
 #内网
 # VUE_APP_UPLOAD="http://192.168.0.1/energy-manage-service/api/check/upload"
-# VUE_APP_UPLOAD="http://192.168.50.235/energy-manage-service/api/check/upload"
+VUE_APP_UPLOAD="http://192.168.50.235/energy-manage-service/api/check/upload"
 # VUE_APP_MAPVIEW=/tiles/{z}/{x}/{y}.png
+# 卫星底图:nginx 若 alias 到 map/tiles/s2/,保持此路径
+VUE_APP_MAPVIEW=/tiles_map/{z}/{x}/{y}.jpg
+# 行政区划叠加:nginx 需单独 alias 到 map/tiles/admin/
+VUE_APP_MAPVIEW_ADMIN=/tiles_map/admin/{z}/{x}/{y}.png
+# 乡镇/街道/村注记:nginx 需单独 alias 到 map/tiles/places/
+VUE_APP_MAPVIEW_PLACES=/tiles_map/places/{z}/{x}/{y}.png
+VUE_APP_MAP="http://192.168.50.235:8000"
 # VUE_APP_MAP=http://192.168.50.235
-# VUE_APP_APIPROXY='http://192.168.50.235:16200'
-# VUE_APP_WZLAPIPROXY='http://192.168.50.241:9001'
-# VUE_APP_ETLAPIPROXY='http://192.168.50.241:9001'
-# # #自定义算法文佳 目前无法使用,可能是服务未启动
-# VUE_APP_sAlgorithmAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
-# VUE_APP_databaseApiAPIPROXY='http://192.168.50.234:3002'
-# #暂时不知健康评估内网dev 环境地址
-# VUE_APP_AnalysisMultiAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
-# #暂时不知下载报告内网dev 环境地址
-# VUE_APP_downLoadChartAPIPROXY='http://192.168.50.235:8998/downLoadChart'
+VUE_APP_APIPROXY='http://192.168.50.235:16200'
+VUE_APP_WZLAPIPROXY='http://192.168.50.241:9001'
+VUE_APP_ETLAPIPROXY='http://192.168.50.241:9001'
+# #自定义算法文佳 目前无法使用,可能是服务未启动
+VUE_APP_sAlgorithmAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
+VUE_APP_databaseApiAPIPROXY='http://192.168.50.234:3002'
+#暂时不知健康评估内网dev 环境地址
+VUE_APP_AnalysisMultiAPIPROXY='http://192.168.50.235:8998/AnalysisMulti'
+#暂时不知下载报告内网dev 环境地址
+VUE_APP_downLoadChartAPIPROXY='http://192.168.50.235:8998/downLoadChart'

+ 493 - 189
src/components/map/index.vue

@@ -1,6 +1,6 @@
 <template>
   <div class="map-container">
-    <div id="map" class="map"></div>
+    <div ref="mapRoot" class="map"></div>
     <!-- 以下为悬浮信息等 -->
     <div
       v-if="hoverInfo"
@@ -10,13 +10,18 @@
       @mouseleave="onHoverLeave"
     >
       <h3>{{ hoverInfo.fieldName }}</h3>
-      <div>
+      <div v-if="hoverInfo.isCluster">
         <p>
+          <span>提示</span><span>点击聚合点放大查看</span>
+        </p>
+      </div>
+      <div v-else>
+        <!-- <p>
           <span>状态:</span
           ><span>{{
             hoverInfo.analysisState === 30 ? "已完成分析" : "未完成分析"
           }}</span>
-        </p>
+        </p> -->
         <p>
           <span>风场编号</span><span>{{ hoverInfo.codeNumber }}</span>
         </p>
@@ -28,7 +33,8 @@
           ><span
             >{{
               newWind?.ratedCapacityNumber ? newWind.ratedCapacityNumber : 0
-            }}/KW</span
+            }}
+            MW</span
           >
         </p>
         <p>
@@ -37,7 +43,7 @@
         <p>
           <span>纬度</span><span>{{ hoverInfo.latitude }}°</span>
         </p>
-        <p>
+        <!-- <p>
           <span>分析模型数</span
           ><span
             >{{
@@ -76,7 +82,7 @@
               ? $formatDateTWO(newWind.dataEndTime)
               : "暂无数据"
           }}</span>
-        </p>
+        </p> -->
       </div>
     </div>
 
@@ -97,7 +103,7 @@
           ><span
             >{{
               hoverfengji?.ratedCapacity ? hoverfengji?.ratedCapacity : 0
-            }}/KW</span
+            }}kW</span
           >
         </p>
         <p>
@@ -172,26 +178,39 @@ import "ol/ol.css";
 import { Map, View, Feature } from "ol";
 import TileLayer from "ol/layer/Tile.js";
 import { XYZ } from "ol/source";
-import { fromLonLat } from "ol/proj";
+import { fromLonLat, transformExtent } from "ol/proj";
 import { Vector } from "ol/source";
+import Cluster from "ol/source/Cluster";
 import { Vector as VectorLayer } from "ol/layer";
 import { Point } from "ol/geom";
-import { Icon, Style, Stroke, Fill, Text } from "ol/style";
+import { Icon, Style, Stroke, Fill, Circle as CircleStyle, Text } from "ol/style";
+import { boundingExtent } from "ol/extent";
 import ZoomSlider from "ol/control/ZoomSlider.js";
 import { defaults as defaultControls } from "ol/control.js";
 import GeoJSON from "ol/format/GeoJSON";
-import { ChinaData, ProvinceData } from "china-map-geojson";
 
 // 假设你已经有正确的山西省边界数据文件(GeoJSON 格式)
 // import shanxiBoundary from "./shanxi.json";
 import shanxiBoundary from "./jilin.json";
-import icon01 from "../../assets/img/iconFC.png";
-import icon02 from "../../assets/img/iconFC.png";
-import icon03 from "../../assets/img/iconFC.png";
-import icon04 from "../../assets/img/iconFJ.png";
-import icon05 from "../../assets/img/icon05.png";
-import icon06 from "../../assets/img/iconFJ.png";
-import defaultIcon from "../../assets/img/iconFJ.png";
+// import icon01 from "../../assets/img/iconFC.png";
+// import icon02 from "../../assets/img/iconFC.png";
+// import icon03 from "../../assets/img/iconFC.png";
+// 地图点图标:按 addMarker 的 val / 风场 analysisState 选用
+import icon01 from "../../assets/img/iconFJ.png"; // 风场:未完成分析 / 排队中(val: -1、10)
+import icon02 from "../../assets/img/iconFJ.png"; // 风场:正在分析中(val: 0、20)
+import icon03 from "../../assets/img/iconFJ.png"; // 风场:已完成分析(val: 1、30)
+import icon04 from "../../assets/img/iconFJ.png"; // 风机点(val: "4",scale 0.5)
+import icon05 from "../../assets/img/icon05.png"; // 测风塔(val: "5")
+import icon06 from "../../assets/img/iconFJ.png"; // 异常风机(val: "6",点击弹出异常描述)
+import defaultIcon from "../../assets/img/iconFJ.png"; // 未匹配到 val 时的兜底图标
+
+// import icon01 from "../../assets/img/icon01.png";
+// import icon02 from "../../assets/img/icon02.png";
+// import icon03 from "../../assets/img/icon03.png";
+// import icon04 from "../../assets/img/icon04.png";
+// import icon05 from "../../assets/img/icon05.png";
+// import icon06 from "../../assets/img/iconFJ.png";
+// import defaultIcon from "../../assets/img/iconFJ.png";
 
 import { queryErrDescByEngine, queryFloatingWindowInfo } from "@/api/ledger.js";
 export default {
@@ -204,6 +223,7 @@ export default {
   name: "T-map",
   data() {
     return {
+      map: null,
       dialogVisible: false,
       tableData: [],
       hoverInfo: null,
@@ -232,153 +252,140 @@ export default {
       fengjiTimer: null,
       taTimer: null,
       newWind: {},
-      provinceLabelLayer: null,
-      cityLabelLayer: null,
-      currentZoom: 3,
     };
   },
   mounted() {
+    // 与 map/tiles 一致:Google XYZ(EPSG:3857),路径 {z}/{x}/{y},Y 轴北向为 0
     this.map = new Map({
-      target: "map",
+      target: this.$refs.mapRoot,
       view: new View({
-        projection: "EPSG:4326",
+        projection: "EPSG:3857",
         center: fromLonLat([116.389, 39.903]),
         zoom: 3,
         minZoom: 3,
-        maxZoom: 15,
-        extent: [69, 18, 136, 54],
+        maxZoom: 13,
+        extent: transformExtent([69, 18, 136, 54], "EPSG:4326", "EPSG:3857"),
       }),
       layers: [
         new TileLayer({
           source: new XYZ({
             url: process.env.VUE_APP_MAPVIEW,
+            projection: "EPSG:3857",
+            tileSize: 256,
+            maxZoom: 13,
           }),
         }),
+        ...(process.env.VUE_APP_MAPVIEW_ADMIN
+          ? [
+              new TileLayer({
+                source: new XYZ({
+                  url: process.env.VUE_APP_MAPVIEW_ADMIN,
+                  projection: "EPSG:3857",
+                  tileSize: 256,
+                  // admin 只切到 12 级;13 级放大 12 级图,避免请求空瓦片导致区县名消失
+                  maxZoom: 13,
+                }),
+              }),
+            ]
+          : []),
+        ...(process.env.VUE_APP_MAPVIEW_PLACES
+          ? [
+              new TileLayer({
+                // OpenLayers 的 minZoom 是 exclusive:11 表示从 12 级起显示
+                minZoom: 11,
+                source: new XYZ({
+                  url: process.env.VUE_APP_MAPVIEW_PLACES,
+                  projection: "EPSG:3857",
+                  tileSize: 256,
+                  minZoom: 12,
+                  maxZoom: 13,
+                }),
+              }),
+            ]
+          : []),
+        new VectorLayer({
+          id: "farm-marker",
+          source: this.createFarmClusterSource(),
+          zIndex: 8,
+          style: (feature) => this.styleFarmCluster(feature),
+        }),
         new VectorLayer({
           id: "marker",
           source: new Vector(),
+          zIndex: 9,
         }),
       ],
       controls: defaultControls().extend([new ZoomSlider()]),
     });
 
-    this.initAdminNameLayers();
-    this.map.getView().on("change:resolution", () => {
-      this.currentZoom = this.map.getView().getZoom() || 3;
-      if (this.provinceLabelLayer) this.provinceLabelLayer.changed();
-      if (this.cityLabelLayer) this.cityLabelLayer.changed();
-    });
-    this.initEvent();
-  },
-  methods: {
-    initAdminNameLayers() {
-      const format = new GeoJSON();
-      const provinceSource = new Vector();
-      const citySource = new Vector();
-
-      // 省级标注
-      format
-        .readFeatures(ChinaData, {
-          dataProjection: "EPSG:4326",
-          featureProjection: "EPSG:4326",
-        })
-        .forEach((feature) => {
-          const props = feature.getProperties() || {};
-          let coord = null;
-          if (Array.isArray(props.cp) && props.cp.length >= 2) {
-            coord = props.cp;
-          } else if (props.longitude != null && props.latitude != null) {
-            coord = [Number(props.longitude), Number(props.latitude)];
-          }
-          if (!coord || !props.name) return;
-          const pointFeature = new Feature({
-            geometry: new Point(coord),
-            name: props.name,
-            level: "province",
-          });
-          provinceSource.addFeature(pointFeature);
-        });
+    // -------------【添加山西省真实边界线】------------- 定位全国的时候注释这个代码
+    // if (this.$route.path === "/home/cockpitManage") {
+    //   // 通过导入的 JSON 文件加载边界数据(GeoJSON 格式),并解析成矢量要素
+    //   const shanxiSource = new Vector({
+    //     features: new GeoJSON().readFeatures(shanxiBoundary, {
+    //       featureProjection: "EPSG:4326",
+    //     }),
+    //   });
+    //   // 创建矢量图层,仅显示边界线(填充颜色设为透明)
+    //   const shanxiLayer = new VectorLayer({
+    //     source: shanxiSource,
+    //     style: new Style({
+    //       stroke: new Stroke({
+    //         color: "rgba(59, 130, 246, 0.5)",
+    //         width: 3,
+    //       }),
+    //       fill: new Fill({
+    //         color: "rgba(59, 130, 246, 0.05)", // 透明填充
+    //       }),
+    //     }),
+    //   });
+    //   this.map.addLayer(shanxiLayer);
 
-      // 地市/州标注(china-map-geojson 最细到地市级;不含乡镇)
-      Object.values(ProvinceData || {}).forEach((collection) => {
-        if (!collection || !collection.features) return;
-        format
-          .readFeatures(collection, {
-            dataProjection: "EPSG:4326",
-            featureProjection: "EPSG:4326",
-          })
-          .forEach((feature) => {
-            const props = feature.getProperties() || {};
-            const cp = props.cp;
-            if (!Array.isArray(cp) || cp.length < 2 || !props.name) return;
-            const pointFeature = new Feature({
-              geometry: new Point(cp),
-              name: props.name,
-              level: "city",
-            });
-            citySource.addFeature(pointFeature);
-          });
-      });
+    //   const markerLayer = this.map
+    //     .getLayers()
+    //     .getArray()
+    //     .find((layer) => {
+    //       return layer.get("id") === "marker";
+    //     });
+    //   if (markerLayer) {
+    //     markerLayer.setZIndex(10);
+    //   }
 
-      this.provinceLabelLayer = new VectorLayer({
-        source: provinceSource,
-        zIndex: 20,
-        declutter: true,
-        style: (feature) => this.createAdminLabelStyle(feature, "province"),
-      });
-      this.cityLabelLayer = new VectorLayer({
-        source: citySource,
-        zIndex: 21,
-        declutter: true,
-        style: (feature) => this.createAdminLabelStyle(feature, "city"),
-      });
-      this.map.addLayer(this.provinceLabelLayer);
-      this.map.addLayer(this.cityLabelLayer);
-    },
-    createAdminLabelStyle(feature, level) {
-      const zoom = this.currentZoom;
-      const name = feature.get("name") || "";
-      if (level === "province") {
-        // 放大到地市级后隐藏省名,减少重叠
-        if (zoom >= 6.2) return null;
-        return new Style({
-          text: new Text({
-            text: name,
-            font: zoom >= 4.8 ? "bold 13px Microsoft YaHei" : "12px Microsoft YaHei",
-            fill: new Fill({ color: "rgba(20, 40, 70, 0.92)" }),
-            stroke: new Stroke({
-              color: "rgba(255, 255, 255, 0.92)",
-              width: 3,
-            }),
-            overflow: true,
-          }),
-        });
-      }
-      // 地市名称:中高缩放显示
-      if (zoom < 5.2) return null;
-      const isCountyLike = /[县市区旗]$/.test(name) || name.includes("自治");
-      // 更高缩放才尽量展示偏县级命名,减轻全国视图拥挤
-      if (zoom < 6.5 && isCountyLike && name.length > 4) {
-        // 仍显示,但字号略小
+    //   // 同时设置高亮图层 zIndex 较低
+    //   shanxiLayer.setZIndex(1);
+    //   // -------------【结束】-------------
+    //   // const targetExtent = [106.8, 34.3, 118.6, 41.2];//山西
+    //   const targetExtent = [119.65, 40.87, 131.31, 46.30];//吉林
+    //   this.map.getView().fit(targetExtent, { duration: 2000 });
+    // }
+    // 这个放在外面
+    this.initEvent();
+    this.bindClusterZoom();
+    this.$nextTick(() => {
+      if (this.map) {
+        this.map.updateSize();
       }
-      return new Style({
-        text: new Text({
-          text: name,
-          font:
-            zoom >= 7
-              ? "12px Microsoft YaHei"
-              : "11px Microsoft YaHei",
-          fill: new Fill({
-            color: "rgba(30, 55, 90, 0.9)",
-          }),
-          stroke: new Stroke({
-            color: "rgba(255, 255, 255, 0.9)",
-            width: 2.5,
-          }),
-          overflow: true,
-        }),
-      });
-    },
+    });
+  },
+  activated() {
+    if (!this.map) return;
+    this.map.setTarget(this.$refs.mapRoot || null);
+    this.$nextTick(() => {
+      this.map && this.map.updateSize();
+    });
+  },
+  deactivated() {
+    if (this.map) {
+      this.map.setTarget(null);
+    }
+  },
+  beforeDestroy() {
+    this.unbindClusterZoom();
+    if (this.map) {
+      this.map.setTarget(null);
+    }
+  },
+  methods: {
     onHoverLeave() {
       this.hovering = false;
       clearTimeout(this.hideTimer);
@@ -408,75 +415,312 @@ export default {
       }, 100);
     },
 
-    addMarker(data = { point: [120.2, 30.35], val: "1" }) {
-      const layer = this.map
+    createFarmClusterSource() {
+      this.farmSource = new Vector();
+      this.clusterSource = new Cluster({
+        distance: 80,
+        minDistance: 36,
+        source: this.farmSource,
+        wrapX: false,
+      });
+      return this.clusterSource;
+    },
+    bindClusterZoom() {
+      if (!this.map) return;
+      this._onClusterResolutionChange = () => {
+        this.updateClusterDistance();
+        const farmLayer = this.getLayerById("farm-marker");
+        if (farmLayer) farmLayer.changed();
+      };
+      this.map
+        .getView()
+        .on("change:resolution", this._onClusterResolutionChange);
+      this.updateClusterDistance();
+    },
+    unbindClusterZoom() {
+      if (!this.map || !this._onClusterResolutionChange) return;
+      this.map
+        .getView()
+        .un("change:resolution", this._onClusterResolutionChange);
+      this._onClusterResolutionChange = null;
+    },
+    updateClusterDistance() {
+      if (!this.clusterSource || !this.map) return;
+      const zoom = this.map.getView().getZoom() || 4;
+      if (zoom >= 10) {
+        this.clusterSource.setDistance(0);
+      } else if (zoom >= 8) {
+        this.clusterSource.setDistance(40);
+      } else if (zoom >= 6) {
+        this.clusterSource.setDistance(56);
+      } else {
+        this.clusterSource.setDistance(72);
+      }
+    },
+    getLayerById(id) {
+      if (!this.map) return null;
+      return this.map
         .getLayers()
         .getArray()
-        .find((element) => {
-          return element.get("id") === "marker";
-        });
-      const source = layer.getSource();
-
-      const iconSrc = this.getIconForValue(data.val);
-      const scale = data.val === "4" ? [0.5, 0.5] : [0.3, 0.3];
+        .find((element) => element.get("id") === id);
+    },
+    isDetailMarker(val) {
+      return val === "4" || val === 4 || val === "5" || val === 5 || val === "6" || val === 6;
+    },
+    getClusterMembers(feature) {
+      const members = feature && feature.get("features");
+      if (Array.isArray(members) && members.length) {
+        return members;
+      }
+      return feature ? [feature] : [];
+    },
+    readThemeColor(name, fallback) {
+      if (typeof window === "undefined") return fallback;
+      const value = getComputedStyle(document.documentElement)
+        .getPropertyValue(name)
+        .trim();
+      return value || fallback;
+    },
+    parseRgb(color) {
+      const value = String(color || "").trim();
+      if (value.startsWith("rgb")) {
+        const parts = value.match(/[\d.]+/g) || [];
+        return {
+          r: Number(parts[0]) || 0,
+          g: Number(parts[1]) || 0,
+          b: Number(parts[2]) || 0,
+        };
+      }
+      const raw = value.replace("#", "");
+      if (raw.length !== 3 && raw.length !== 6) {
+        return { r: 61, g: 135, b: 157 };
+      }
+      const full =
+        raw.length === 3
+          ? raw
+              .split("")
+              .map((item) => item + item)
+              .join("")
+          : raw;
+      const n = parseInt(full, 16);
+      return {
+        r: (n >> 16) & 255,
+        g: (n >> 8) & 255,
+        b: n & 255,
+      };
+    },
+    mixRgb(rgb, target, amount) {
+      const t = Math.min(1, Math.max(0, amount));
+      return {
+        r: Math.round(rgb.r + (target - rgb.r) * t),
+        g: Math.round(rgb.g + (target - rgb.g) * t),
+        b: Math.round(rgb.b + (target - rgb.b) * t),
+      };
+    },
+    rgba(rgb, alpha) {
+      return `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${alpha})`;
+    },
+    colorWithAlpha(color, alpha) {
+      return this.rgba(this.parseRgb(color), alpha);
+    },
+    getClusterRadius(count) {
+      return Math.round(
+        Math.max(16, Math.min(38, 12 + Math.sqrt(count) * 3.6)),
+      );
+    },
+    buildClusterStyles(count) {
+      const radius = this.getClusterRadius(count);
+      const primary = this.parseRgb(
+        this.readThemeColor("--primary-color", "#3D879D"),
+      );
+      const core = this.mixRgb(primary, 0, 0.28);
+      const glow = this.mixRgb(primary, 255, 0.45);
+      const ring = this.mixRgb(primary, 255, 0.62);
+      const label = count > 99 ? "99+" : String(count);
+      const fontSize = radius >= 32 ? 16 : radius >= 24 ? 13 : 11;
 
+      return [
+        new Style({
+          image: new CircleStyle({
+            radius: radius + 8,
+            fill: new Fill({ color: this.rgba(glow, 0.18) }),
+          }),
+        }),
+        new Style({
+          image: new CircleStyle({
+            radius: radius + 3,
+            stroke: new Stroke({
+              color: this.rgba(ring, 0.85),
+              width: 2.5,
+            }),
+          }),
+        }),
+        new Style({
+          image: new CircleStyle({
+            radius,
+            fill: new Fill({ color: this.rgba(core, 0.82) }),
+            stroke: new Stroke({
+              color: "rgba(255, 255, 255, 0.9)",
+              width: 1.5,
+            }),
+          }),
+        }),
+        new Style({
+          image: new CircleStyle({
+            radius: Math.max(3, radius * 0.22),
+            fill: new Fill({ color: "rgba(255, 255, 255, 0.28)" }),
+            displacement: [0, -radius * 0.36],
+          }),
+        }),
+        new Style({
+          text: new Text({
+            text: label,
+            font: `600 ${fontSize}px "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif`,
+            fill: new Fill({ color: "#ffffff" }),
+            stroke: new Stroke({
+              color: this.rgba(core, 0.9),
+              width: 3.5,
+            }),
+            offsetY: 0.5,
+          }),
+        }),
+      ];
+    },
+    getFarmIconSizeByZoom() {
+      const zoom = (this.map && this.map.getView().getZoom()) || 4;
+      if (zoom < 5) return { width: 16, height: 20 };
+      if (zoom < 7) return { width: 22, height: 28 };
+      if (zoom < 9) return { width: 28, height: 35 };
+      return { width: 36, height: 45 };
+    },
+    styleFarmCluster(feature) {
+      const members = this.getClusterMembers(feature);
+      if (members.length > 1) {
+        return this.buildClusterStyles(members.length);
+      }
+      const data = members[0] && members[0].get("data");
+      const iconSize = this.getFarmIconSizeByZoom();
+      return new Style({
+        image: new Icon({
+          src: this.getIconForValue(data && data.val),
+          width: iconSize.width,
+          height: iconSize.height,
+          anchor: [0.5, 1],
+          opacity: 1,
+        }),
+      });
+    },
+    zoomToCluster(members) {
+      if (!this.map || !members.length) return;
+      const coordinates = members.map((item) =>
+        item.getGeometry().getCoordinates(),
+      );
+      this.map.getView().fit(boundingExtent(coordinates), {
+        duration: 450,
+        padding: [72, 72, 72, 72],
+        maxZoom: 9,
+      });
+    },
+    fitMarkers(animate = true) {
+      if (!this.map || !this.farmSource) return;
+      const features = this.farmSource.getFeatures();
+      if (!features.length) return;
+      if (features.length === 1) {
+        this.map.getView().animate({
+          center: features[0].getGeometry().getCoordinates(),
+          zoom: 8,
+          duration: animate ? 600 : 0,
+        });
+        return;
+      }
+      const extent = boundingExtent(
+        features.map((item) => item.getGeometry().getCoordinates()),
+      );
+      this.map.getView().fit(extent, {
+        duration: animate ? 600 : 0,
+        padding: [56, 56, 56, 56],
+        maxZoom: 6,
+      });
+    },
+    addMarker(data = { point: [120.2, 30.35], val: "1" }) {
       const feature = new Feature({
-        geometry: new Point(fromLonLat(data.point, "EPSG:4326")),
+        geometry: new Point(fromLonLat(data.point)),
         name: "marker",
         data,
       });
 
-      feature.setStyle(
-        new Style({
-          image: new Icon({
-            src: iconSrc,
-            scale: scale,
-            anchor: [0.5, 1],
-            opacity: 1,
+      if (this.isDetailMarker(data.val)) {
+        const layer = this.getLayerById("marker");
+        if (!layer) return;
+        const iconSrc = this.getIconForValue(data.val);
+        const iconSize = this.getMarkerIconSize(data.val);
+        feature.setStyle(
+          new Style({
+            image: new Icon({
+              src: iconSrc,
+              width: iconSize.width,
+              height: iconSize.height,
+              anchor: [0.5, 1],
+              opacity: 1,
+            }),
           }),
-        })
-      );
+        );
+        layer.getSource().addFeature(feature);
+        return;
+      }
 
-      source.addFeature(feature);
+      if (this.farmSource) {
+        this.farmSource.addFeature(feature);
+      }
     },
     clearMarkers() {
-      const layer = this.map
-        .getLayers()
-        .getArray()
-        .find((element) => {
-          return element.get("id") === "marker";
-        });
+      if (this.farmSource) {
+        this.farmSource.clear();
+      }
+      const layer = this.getLayerById("marker");
       if (layer) {
-        const source = layer.getSource();
-        source.clear();
+        layer.getSource().clear();
+      }
+    },
+    /**
+     * 按屏幕像素定图标大小。iconFJ2 为 1024px,不能再用 scale(0.2 仍约 200px)。
+     */
+    getMarkerIconSize(val) {
+      if (val === "4" || val === 4 || val === "6" || val === 6) {
+        return { width: 40, height: 50 };
+      }
+      if (val === "5" || val === 5) {
+        return { width: 30, height: 40 };
       }
+      return { width: 40, height: 50 };
     },
+    // val:驾驶舱风场用 analysisState;风场详情页 4=风机 5=测风塔 6=异常风机
     getIconForValue(val) {
       switch (val) {
         case "-1":
         case -1:
-          return icon01;
+          return icon01; // 未完成分析
         case "10":
         case 10:
-          return icon01;
+          return icon01; // 排队中
         case 0:
         case "0":
-          return icon02;
+          return icon02; // 正在分析
         case 20:
         case "20":
-          return icon02;
+          return icon02; // 正在分析
         case 1:
         case "1":
-          return icon03;
+          return icon03; // 已完成分析
         case 30:
         case "30":
-          return icon03;
+          return icon03; // 已完成分析
         case "4":
-          return icon04;
+          return icon04; // 风机
         case "5":
-          return icon05;
+          return icon05; // 测风塔
         case "6":
-          return icon06;
+          return icon06; // 异常风机
         default:
           return defaultIcon;
       }
@@ -505,11 +749,37 @@ export default {
       };
       this.map.on("pointermove", (evt) => {
         const features = this.map.getFeaturesAtPixel(evt.pixel, {
-          hitTolerance: 1,
+          hitTolerance: 8,
         });
+        const mapEl = this.map.getTargetElement();
         if (features && features.length > 0) {
+          if (mapEl) mapEl.style.cursor = "pointer";
           const feature = features.at(0);
-          const data = feature.get("data");
+          const members = this.getClusterMembers(feature);
+          if (members.length > 1) {
+            if (lastHoveredFeature !== feature) {
+              const hoverHeight = 90;
+              const hoverWidth = 200;
+              this.hoverInfo = {
+                isCluster: true,
+                fieldName: `${members.length} 座风场`,
+              };
+              this.newWind = {};
+              this.hoverStyle.left = `${calculateHoverLeft(
+                evt.pixel[0],
+                hoverWidth,
+              )}px`;
+              this.hoverStyle.top = `${calculateHoverTop(
+                evt.pixel[1],
+                hoverHeight,
+              )}px`;
+              this.hoverfengji = null;
+              this.hoverta = null;
+              lastHoveredFeature = feature;
+            }
+            return;
+          }
+          const data = members[0] && members[0].get("data");
           // 如果没有 data 属性,则不处理,但允许事件冒泡
           if (!data) {
             return;
@@ -548,6 +818,7 @@ export default {
             lastHoveredFeature = feature;
           }
         } else {
+          if (mapEl) mapEl.style.cursor = "";
           // 没有特征时,清空所有悬浮信息
           if (!this.hoveringInfo) this.hoverInfo = null;
           if (!this.hoveringFengji) this.hoverfengji = null;
@@ -561,11 +832,16 @@ export default {
       // click 事件处理
       this.map.on("click", (evt) => {
         const features = this.map.getFeaturesAtPixel(evt.pixel, {
-          hitTolerance: 1,
+          hitTolerance: 8,
         });
         if (features && features.length > 0) {
           const feature = features.at(0);
-          const data = feature.get("data");
+          const members = this.getClusterMembers(feature);
+          if (members.length > 1) {
+            this.zoomToCluster(members);
+            return;
+          }
+          const data = members[0] && members[0].get("data");
           // 如果没有 data,直接返回,让事件继续冒泡
           if (!data) {
             return;
@@ -595,7 +871,7 @@ export default {
       });
     },
     getwind() {
-      // if (this.hoverInfo && this.hoverInfo.batchCode) {
+      if (!this.hoverInfo || this.hoverInfo.isCluster) return;
       const param = {
         batchcode: this.hoverInfo.batchCode,
         fieldCode: this.hoverInfo.codeNumber,
@@ -611,10 +887,10 @@ export default {
       //   console.warn("hoverInfo 或 batchCode 未定义");
       // }
     },
-    moveAndZoom(data = { point: [120.2, 30.35], zoom: 15 }) {
+    moveAndZoom(data = { point: [120.2, 30.35], zoom: 12 }) {
       this.map.getView().animate({
-        center: fromLonLat(data.point, "EPSG:4326"),
-        zoom: data.zoom,
+        center: fromLonLat(data.point),
+        zoom: Math.min(Number(data.zoom) || 13, 13),
         duration: 2000,
       });
     },
@@ -637,46 +913,74 @@ export default {
 
 .hover-info {
   position: absolute;
-  color: white;
+  color: var(--map-hover-text, #303133);
   padding: 0;
   z-index: 100;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  border-radius: 4px;
+  overflow: hidden;
+
   h3 {
-    background-color: var(--header-bg);
+    background-color: var(--map-hover-header-bg, var(--primary-color, #008080));
+    color: var(--map-hover-header-text, #ffffff);
     width: 240px;
     padding: 5px 10px;
     font-size: 16px;
+    margin: 0;
   }
+
   div {
-    background-color: var(--content-bg);
+    background-color: var(--map-hover-body-bg, rgba(255, 255, 255, 0.96));
+    color: var(--map-hover-text, #303133);
     width: 240px;
     font-size: 12px;
+
     p {
       padding: 5px 10px;
       display: flex;
       justify-content: space-between;
+      margin: 0;
+
+      span:first-child {
+        color: var(--map-hover-label-text, #606266);
+      }
     }
   }
 }
 
 .hover-fengji {
   position: absolute;
-  color: white;
+  color: var(--map-hover-text, #303133);
   padding: 0;
   z-index: 100;
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+  border-radius: 4px;
+  overflow: hidden;
+
   h3 {
-    background-color: var(--header-bg);
+    background-color: var(--map-hover-header-bg, var(--primary-color, #008080));
+    color: var(--map-hover-header-text, #ffffff);
     width: 240px;
     padding: 5px 10px;
     font-size: 16px;
+    margin: 0;
   }
+
   div {
-    background-color: var(--content-bg);
+    background-color: var(--map-hover-body-bg, rgba(255, 255, 255, 0.96));
+    color: var(--map-hover-text, #303133);
     width: 240px;
     font-size: 12px;
+
     p {
       padding: 5px 10px;
       display: flex;
       justify-content: space-between;
+      margin: 0;
+
+      span:first-child {
+        color: var(--map-hover-label-text, #606266);
+      }
     }
   }
 }