maxiaoxiao 2 일 전
부모
커밋
9c430fdba5
4개의 변경된 파일442개의 추가작업 그리고 48개의 파일을 삭제
  1. 113 35
      portal/index.html
  2. 253 0
      task-service/src/page-hooks.js
  3. 74 13
      task-service/src/renderer.js
  4. 2 0
      task-service/src/worker.js

+ 113 - 35
portal/index.html

@@ -4,12 +4,12 @@
   <meta charset="utf-8" />
   <meta name="viewport" content="width=device-width, initial-scale=1" />
   <title>实景三维分析申请(外网)</title>
-  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
-  <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
+  <link rel="stylesheet" href="https://unpkg.com/ol@10.0.0/ol.css" />
+  <script src="https://unpkg.com/ol@10.0.0/dist/ol.js"></script>
   <style>
     body { font-family: "Microsoft YaHei", sans-serif; margin: 0; background: #0a2240; color: #dfe9f5; }
     .wrap { display: flex; height: 100vh; }
-    #map { flex: 1; }
+    #map { flex: 1; position: relative; }
     .panel { width: 360px; padding: 16px; box-sizing: border-box; overflow-y: auto; background: #0e2a4d; }
     h3 { margin: 0 0 12px; color: #64daff; }
     label { display: block; margin: 10px 0 4px; font-size: 13px; }
@@ -29,7 +29,7 @@
     <div class="panel">
       <h3>三维分析申请</h3>
       <label>分析类型</label>
-      <select id="analysisType">
+      <select id="analysisType" onchange="resetDraw()">
         <option value="sunlight">日照分析</option>
         <option value="skyline">天际线分析</option>
       </select>
@@ -37,7 +37,7 @@
       <input type="date" id="date" />
       <label>分析范围(在地图上绘制多边形)</label>
       <button type="button" id="drawBtn" onclick="toggleDraw()">开始绘制范围</button>
-      <div class="hint" id="drawHint">画完自动结束并填入下方文本框</div>
+      <div class="hint" id="drawHint">点击"开始绘制范围"后在地图上点击取点,右键结束</div>
       <label>范围 GeoJSON</label>
       <textarea id="rangeJson" rows="4" style="width:100%;box-sizing:border-box;resize:vertical;font-family:monospace;font-size:12px;"></textarea>
       <button id="submitBtn" onclick="submitTask()">提交分析</button>
@@ -49,45 +49,123 @@
   </div>
 
   <script>
-    var map = L.map("map").setView([18.25, 109.51], 12);
-    L.tileLayer(
-      "http://192.168.60.2:8090/iserver/services/map-ugcv5-Image-L18/rest/maps/Image-L18/tileImage.png?x={x}&y={y}&z={z}",
-      {
-        attribution: "内网影像",
+    // ===== OpenLayers 地图(参照 one-code-web,天地图底图)=====
+    var tiandituKey = "12df6e32906dbb916fad14dc65ebdbf8";
+    function tdtLayer(name) {
+      return new ol.layer.Tile({
+        source: new ol.source.XYZ({
+          url:
+            "http://t0.tianditu.gov.cn/" + name +
+            "_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=" + name +
+            "&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=" +
+            tiandituKey,
+          maxZoom: 18,
+        }),
+      });
+    }
+    // 影像底图 + 影像标注
+    var imgLayer = tdtLayer("img");
+    var imgCiaLayer = tdtLayer("cia");
+    var map = new ol.Map({
+      target: "map",
+      layers: [imgLayer, imgCiaLayer],
+      view: new ol.View({
+        center: [0, 0],
+        projection: "EPSG:3857",
+        zoom: 12,
         maxZoom: 18,
+      }),
+    });
+    // EPSG:4326 中心 → 3857
+    map.getView().setCenter(ol.proj.fromLonLat([109.51, 18.25]));
+
+    // 绘制范围图层 + 交互(参照 one-code-web 的 range.vue)
+    var vectorSource = new ol.source.Vector();
+    var vectorLayer = new ol.layer.Vector({
+      source: vectorSource,
+      style: new ol.style.Style({
+        stroke: new ol.style.Stroke({ color: "rgba(0,229,255,1)", width: 2 }),
+        fill: new ol.style.Fill({ color: "rgba(0,229,255,0.15)" }),
+      }),
+      zIndex: 9999,
+    });
+    map.addLayer(vectorLayer);
+    var drawInteraction = null;
+
+    // 根据分析类型获取绘制类型:日照=Polygon,天际线=LineString
+    function getDrawType() {
+      return document.getElementById("analysisType").value === "skyline"
+        ? "LineString"
+        : "Polygon";
+    }
+
+    // 切换分析类型时重置绘制状态
+    function resetDraw() {
+      if (drawInteraction) {
+        map.removeInteraction(drawInteraction);
+        drawInteraction = null;
       }
-    ).addTo(map);
-    var drawn = null;
-    var drawing = false;
-    var polygonLayer = null;
+      vectorSource.clear();
+      document.getElementById("rangeJson").value = "";
+      document.getElementById("drawBtn").textContent = "开始绘制范围";
+      document.getElementById("drawHint").textContent =
+        getDrawType() === "LineString"
+          ? '天际线分析:点击"开始绘制范围"后在地图上绘制两点线段(观察方向),双击结束'
+          : '点击"开始绘制范围"在地图上圈取范围,右键结束';
+    }
 
     function toggleDraw() {
-      if (drawing) { map.off("click"); drawing = false; document.getElementById("drawBtn").textContent = "开始绘制范围"; return; }
-      drawing = true;
-      document.getElementById("drawBtn").textContent = "点击地图取点(结束请点完成)";
-      var points = [];
-      map.on("click", function (e) {
-        points.push([e.latlng.lat, e.latlng.lng]);
-        if (polygonLayer) map.removeLayer(polygonLayer);
-        if (points.length >= 3) {
-          polygonLayer = L.polygon(points, { color: "#00e5ff" }).addTo(map);
-        }
+      if (drawInteraction) {
+        map.removeInteraction(drawInteraction);
+        drawInteraction = null;
+        document.getElementById("drawBtn").textContent = "开始绘制范围";
+        return;
+      }
+      var drawType = getDrawType();
+      vectorSource.clear();
+      drawInteraction = new ol.interaction.Draw({
+        source: vectorSource,
+        type: drawType,
+        style: new ol.style.Style({
+          stroke: new ol.style.Stroke({ color: "#ff9500", width: 2, lineDash: [5, 5] }),
+          fill: new ol.style.Fill({ color: "rgba(255,149,0,0.1)" }),
+        }),
       });
-      setTimeout(function () {
-        map.off("click");
-        drawing = false;
+      map.addInteraction(drawInteraction);
+      document.getElementById("drawBtn").textContent =
+        drawType === "LineString" ? "绘制中…(两点线段,双击结束)" : "绘制中…(右键结束)";
+
+      drawInteraction.on("drawend", function (e) {
+        map.removeInteraction(drawInteraction);
+        drawInteraction = null;
         document.getElementById("drawBtn").textContent = "开始绘制范围";
-        if (points.length >= 3) {
-          var ring = points.concat([points[0]]);
-          var coords = ring.map(function (p) { return [p[1], p[0], 0]; });
-          var geojson = {
+        // 转 GeoJSON,坐标 [lon,lat,0]
+        var geom = e.feature.getGeometry();
+        var geojson;
+        if (drawType === "LineString") {
+          var lineCoords = geom.getCoordinates().map(function (c) {
+            var ll = ol.proj.toLonLat(c, "EPSG:3857");
+            return [ll[0], ll[1], 0];
+          });
+          geojson = {
             type: "Feature",
-            geometry: { type: "Polygon", coordinates: [coords] },
+            geometry: { type: "LineString", coordinates: lineCoords },
+          };
+        } else {
+          var flat = geom.getCoordinates()[0]; // 环(3857)
+          var ring = flat.map(function (c) {
+            var ll = ol.proj.toLonLat(c, "EPSG:3857");
+            return [ll[0], ll[1], 0];
+          });
+          ring.push(ring[0]); // 闭合
+          geojson = {
+            type: "Feature",
+            geometry: { type: "Polygon", coordinates: [ring] },
           };
-          document.getElementById("rangeJson").value = JSON.stringify(geojson);
-          document.getElementById("drawHint").textContent = "已生成范围,可直接提交";
         }
-      }, 4000);
+        document.getElementById("rangeJson").value = JSON.stringify(geojson);
+        document.getElementById("drawHint").textContent = "已生成范围,可直接提交";
+      });
     }
 
     async function submitTask() {

+ 253 - 0
task-service/src/page-hooks.js

@@ -0,0 +1,253 @@
+/*
+ * 无头渲染自动化钩子(外部注入版)——门户 Vue 页面保持零改动
+ * 由 task-service renderer.js 在页面加载后注入执行(page.evaluate(本文件源码))。
+ * 依赖页面全局:Cesium / viewer / scene;日照另需已挂载的 SunlightAnalysis 组件实例。
+ * 暴露:
+ *   window.__sunlightRun({geojson, date, startTime, endTime}) -> Promise<dataURL>
+ *   window.__skylineRun({start, end, faceH, faceW, slider})   -> Promise<dataURL>
+ */
+(function () {
+  // 在页面 DOM 里找 Vue2 组件实例(Vue2 会在组件根元素上挂 __vue__)
+  function findVueInstance(methods) {
+    var els = document.querySelectorAll("*");
+    for (var i = 0; i < els.length; i++) {
+      var vm = els[i].__vue__;
+      if (!vm) continue;
+      var ok = true;
+      for (var m = 0; m < methods.length; m++) {
+        if (typeof vm[methods[m]] !== "function") {
+          ok = false;
+          break;
+        }
+      }
+      if (ok) return vm;
+    }
+    return null;
+  }
+  window.__findSunlightInstance = function () {
+    return findVueInstance(["statrSunlight", "exportResultDataUrl", "analysis"]);
+  };
+
+  function waitTilesLoaded(scene) {
+    return new Promise(function (resolve) {
+      var start = Date.now();
+      var timer = setInterval(function () {
+        var done = false;
+        try {
+          done = scene && scene.globe && scene.globe.tilesLoaded === true;
+        } catch (e) { }
+        if (done || Date.now() - start > 25000) {
+          clearInterval(timer);
+          setTimeout(resolve, 3000); // 额外等待,让高分辨率瓦片续流
+        }
+      }, 1000);
+    });
+  }
+  window.__sunlightSetupRange = async function (geojson) {
+    // var that = window.__sunlightInstance;
+    var that = window.__findSunlightInstance();
+    var allCoords = geojson.geometry.coordinates[0];
+    // 去重首尾重复点(等同自定义绘制的去重顶点)
+    var coords = allCoords;
+    if (allCoords.length > 1) {
+      var fst = allCoords[0],
+        lst = allCoords[allCoords.length - 1];
+      if (fst[0] === lst[0] && fst[1] === lst[1]) {
+        coords = allCoords.slice(0, allCoords.length - 1);
+      }
+    }
+    // 采样 DSM 地形高程,生成带真实高程的 ECEF(z=地表高程才能把彩带渲染在地表上)
+    var cartos = [];
+    for (var si = 0; si < coords.length; si++) {
+      cartos.push(
+        Cesium.Cartographic.fromDegrees(coords[si][0], coords[si][1])
+      );
+    }
+    if (window.viewer.terrainProvider && window.viewer.terrainProvider.visible) {
+      try {
+        await Cesium.sampleTerrainMostDetailed(
+          window.viewer.terrainProvider,
+          cartos
+        );
+      } catch (e) {
+        console.warn("sampleTerrain error", e);
+      }
+    }
+    var cart = [];
+    for (var sci = 0; sci < cartos.length; sci++) {
+      if (!cartos[sci]) continue; // 采样失败返回 undefined 的点跳过
+      cart.push(
+        Cesium.Cartesian3.fromDegrees(
+          Cesium.Math.toDegrees(cartos[sci].longitude),
+          Cesium.Math.toDegrees(cartos[sci].latitude),
+          cartos[sci].height
+        )
+      );
+    }
+    // 不创建多边形实体(避免 Cesium 多边形几何异步抛 DeveloperError),分析只依赖 positions + boxAll
+    that.clear();
+    that.ProjectScope.positions = JSON.parse(JSON.stringify(cart));
+    // boxAll + 飞到范围(ExportResult 也用 boxAll 出图)
+    var ring = [];
+    for (var ri = 0; ri < coords.length; ri++) {
+      ring.push([coords[ri][0], coords[ri][1]]);
+    }
+    // turf polygon() 要求闭环(首尾点相同),否则报 "First and last positions are not equivalent"
+    var closedRing = ring.slice();
+    if (
+      closedRing.length > 1 &&
+      (closedRing[0][0] !== closedRing[closedRing.length - 1][0] ||
+        closedRing[0][1] !== closedRing[closedRing.length - 1][1])
+    ) {
+      closedRing.push(closedRing[0]);
+    }
+    var joinedPoly = polygon([closedRing]);
+    that.geopolygon = joinedPoly;
+    var bx = bbox(joinedPoly);
+    that.boxAll = bx;
+  };
+
+  // ===== 日照分析:驱动已挂载组件(分析引擎=组件方法,流程与页面操作一致) =====
+  window.__sunlightRun = async function (opts) {
+    // var that = window.__sunlightInstance;
+    var that = window.__findSunlightInstance();
+    return new Promise(function (resolve, reject) {
+      if (!that) {
+        reject(new Error("未找到日照分析实例"));
+        return;
+      }
+      (async function () {
+        try {
+          if (opts && opts.geojson) {
+            // __sunlightSetupRange 是 async(含 sampleTerrain 采样高程设置 boxAll),必须 await 完再分析
+            await window.__sunlightSetupRange(opts.geojson);
+          }
+          if (opts && opts.date) {
+            that.form.selDate = new Date(opts.date);
+            if (opts.startTime != null) that.form.startTime = opts.startTime;
+            if (opts.endTime != null) that.form.endTime = opts.endTime;
+          }
+          setTimeout(function () {
+            var sawLoading = false;
+            var start = Date.now();
+            // 跑采光率分析(imptAnalysis),生成日照时长彩色叠加
+            try {
+              that.analysis();
+            } catch (e) {
+              console.warn("analysis error", e);
+            }
+            // 渲染日照效果(阴影/采光率动画)
+            try {
+              that.statrSunlight();
+            } catch (e) {
+              console.warn("statrSunlight error", e);
+            }
+            // 等分析结束(loading 由 true→false),再留几秒让彩色叠加渲染完,然后导出
+            var poll = setInterval(function () {
+              var elapsed = Date.now() - start;
+              if (that.loading) sawLoading = true;
+              if ((sawLoading && !that.loading) || elapsed > 180000) {
+                clearInterval(poll);
+                setTimeout(function () {
+                  that.exportResultDataUrl().then(resolve).catch(reject);
+                }, 3000);
+              }
+            }, 1000);
+          }, 800);
+        } catch (e) {
+          reject(e);
+        }
+      })();
+    });
+  };
+
+  // ===== 天际线分析:纯 Cesium Facade(无需组件实例) =====
+  window.__skylineRun = function (params) {
+    return (async function () {
+      var start = params.start; // [lon,lat]
+      var end = params.end; // [lon,lat]
+      var faceH = params.faceH || 100;
+      var faceW = params.faceW || 200;
+      var slider = params.slider || "left";
+
+      // 1) 采样两点 DSM 地形高程(facade 相机贴地才拍得到楼)
+      var cartos = [
+        Cesium.Cartographic.fromDegrees(start[0], start[1]),
+        Cesium.Cartographic.fromDegrees(end[0], end[1]),
+      ];
+      if (window.viewer.terrainProvider && window.viewer.terrainProvider.visible) {
+        try {
+          await Cesium.sampleTerrainMostDetailed(
+            window.viewer.terrainProvider,
+            cartos
+          );
+        } catch (e) {
+          console.warn("sampleTerrain error", e);
+        }
+      }
+      var h1 =
+        cartos[0] && typeof cartos[0].height === "number"
+          ? cartos[0].height
+          : start[2] || 0;
+      var h2 =
+        cartos[1] && typeof cartos[1].height === "number"
+          ? cartos[1].height
+          : end[2] || 0;
+
+      // 2) 相机先飞到线中点附近,等倾斜摄影瓦片加载稳定。
+      //    facade 渲染不等 S3M 瓦片流,不预载会拍到空场景(全黑图)。
+      var midLon = (start[0] + end[0]) / 2;
+      var midLat = (start[1] + end[1]) / 2;
+      viewer.camera.setView({
+        destination: Cesium.Cartesian3.fromDegrees(
+          midLon,
+          midLat - 0.003,
+          Math.max(h1, h2) + parseFloat(faceH) + 60
+        ),
+        orientation: {
+          heading: 0,
+          pitch: Cesium.Math.toRadians(-25),
+          roll: 0,
+        },
+      });
+      await waitTilesLoaded(viewer.scene);
+
+      // 3) 建 facade 并设置分析参数(与交互 draw() 同序)
+      var facade = new Cesium.Facade(scene);
+      facade.build();
+      facade.setBoundByPoints(null, null);
+      var startPoint = Cesium.Cartesian3.fromDegrees(start[0], start[1], h1);
+      var endPoint = Cesium.Cartesian3.fromDegrees(end[0], end[1], h2);
+      if (slider == "left") facade.setBoundByPoints(startPoint, endPoint);
+      else facade.setBoundByPoints(endPoint, startPoint);
+      facade.maxHeight = parseFloat(faceH);
+      facade.farDistance = parseFloat(faceW);
+
+      // 4) facade 每次帧捕获会重建 readyPromise(_beginFunc 里 defer),
+      //    最终 resolve 的是最新 defer。必须等捕获完成(_fbo._isUpdate 变
+      //    false 是完成信号),再取最终 readyPromise,否则永远挂起。
+      var started = Date.now();
+      return await new Promise(function (resolve2, reject2) {
+        var waitDone = function () {
+          var fbo = facade._fbo;
+          var done = fbo && fbo._isUpdate === false;
+          var timeout = Date.now() - started > 60000;
+          if (done || timeout) {
+            Promise.resolve(facade.readyPromise)
+              .then(function (base64data) {
+                resolve2(base64data); // 天际线结果图 data URL
+              })
+              .catch(function (e) {
+                reject2(e);
+              });
+          } else {
+            setTimeout(waitDone, 200);
+          }
+        };
+        setTimeout(waitDone, 500);
+      });
+    })();
+  };
+
+  console.log("[page-hooks] 注入完成:__sunlightRun / __skylineRun");
+})();

+ 74 - 13
task-service/src/renderer.js

@@ -1,8 +1,9 @@
 const puppeteer = require("puppeteer-core");
+const fs = require("fs");
+const path = require("path");
 
 const CHROME_DEFAULT =
   "C:/Program Files/Google/Chrome/Application/chrome.exe";
-
 /**
  * 无头渲染:打开门户三维路由,等待 viewer,点"日照分析"挂载组件,
  * 自己加载实景三维场景(倾斜摄影) + DSM 高程,再调 __sunlightRun({geojson,date})
@@ -15,6 +16,8 @@ async function renderScene(opts) {
     token = "",
     range = null,
     date = "",
+    analysisType = "sunlight",
+    params = {},
     terrainUrl = "",
     sceneUrl = "",
     iserverHost = "192.168.60.2:8090",
@@ -75,22 +78,58 @@ async function renderScene(opts) {
     }
     await new Promise((r) => setTimeout(r, 8000));
 
-    // 打开日照分析(挂载 SunlightAnalysis,注入 __sunlightRun)
+    // 注入自动化钩子(仅天际线需要;日照的钩子在 SunlightAnalysis.vue mounted 中)
+    if (analysisType !== "sunlight") {
+      const hooksSource = fs.readFileSync(
+        path.join(__dirname, "page-hooks.js"),
+        "utf8"
+      );
+      await page.evaluate(hooksSource);
+    }
+
+    // 打开分析组件:日照需要挂载组件作分析引擎(点"日照分析"按钮),
+    // 天际线是纯 Cesium Facade 实现,无需组件。
     let hasHook = false;
     try {
-      await page.evaluate(() => {
-        const btn = Array.from(document.querySelectorAll("button")).find(
-          (b) => (b.innerText || "").trim() === "日照分析"
+      if (analysisType === "sunlight") {
+        // 通过 puppeteer frames API 搜索所有 frame(含 vue-layer iframe)查找按钮
+        const allFrames = page.frames();
+        console.log("[renderer] frames count:", allFrames.length);
+        let clicked = false;
+        for (const frame of allFrames) {
+          try {
+            const url = frame.url();
+            console.log("[renderer] frame url:", url);
+            const found = await frame.evaluate(() => {
+              const btns = document.querySelectorAll("button");
+              for (let i = 0; i < btns.length; i++) {
+                if ((btns[i].innerText || "").trim() === "日照分析") {
+                  btns[i].click();
+                  return true;
+                }
+              }
+              return false;
+            });
+            if (found) {
+              clicked = true;
+              console.log("[renderer] 已在 frame 中点击'日照分析'按钮");
+              break;
+            }
+          } catch (e) {
+            console.log("[renderer] frame 访问失败:", e.message);
+          }
+        }
+        if (!clicked) console.warn("[renderer] 所有 frame 中均未找到'日照分析'按钮");
+        // 等待 Vue 组件 mounted() 中设置 window.__sunlightRun
+        await page.waitForFunction(
+          "typeof window.__sunlightRun === 'function'",
+          { timeout: 30000 }
         );
-        if (btn) btn.click();
-      });
-      await page.waitForFunction(
-        "typeof window.__sunlightRun === 'function'",
-        { timeout: 30000 }
-      );
+        console.log("[renderer] 找到 __sunlightRun");
+      }
       hasHook = true;
     } catch (e) {
-      console.warn("open 日照分析 error", e.message);
+      console.warn("open 分析 error", e.message);
     }
 
     // 自己加载实景三维场景(倾斜摄影)
@@ -155,7 +194,29 @@ async function renderScene(opts) {
     }
 
     if (range && hasHook) {
-      // 调 __sunlightRun({geojson,date}):内部自动 设置范围(采样高程)→采光率分析→导出→返回图片
+      if (analysisType === "skyline") {
+        // 天际线:range 为 LineString(两点),调 __skylineRun({start,end,faceH,faceW})
+        const dataUrl = await evalTimeout(
+          page,
+          (args) => {
+            var coords = args.range.geometry.coordinates; // [[lon1,lat1],[lon2,lat2]]
+            var start = coords[0], end = coords[coords.length - 1];
+            return window.__skylineRun({
+              start: [start[0], start[1], start[2] || 0],
+              end: [end[0], end[1], end[2] || 0],
+              faceH: (args.params && args.params.faceH) || undefined,
+              faceW: (args.params && args.params.faceW) || undefined,
+            });
+          },
+          { range, params: params || {} }
+        );
+        return {
+          ok: true,
+          jpegBase64: dataUrl.split(",")[1],
+          note: "skyline-export",
+        };
+      }
+      // 日照:调 __sunlightRun({geojson,date}):内部自动 设置范围(采样高程)→采光率分析→导出→返回图片
       const dataUrl = await evalTimeout(
         page,
         (args) =>

+ 2 - 0
task-service/src/worker.js

@@ -39,6 +39,8 @@ function startWorker(cfg) {
           ...render,
           range: task.range || null,
           date: (task.params && task.params.date) || "",
+          analysisType: task.analysisType || "sunlight",
+          params: task.params || {},
         });
         if (result.ok) {
           const push = await request(forwardBase, "POST", "/api/worker/push", {