POIVisualization.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. <template>
  2. <!-- POI点信息弹窗 -->
  3. <div v-if="selectedPoint" class="custom-popup" :style="{
  4. left: `${popupPosition.x}px`,
  5. top: `${popupPosition.y}px`
  6. }">
  7. <div class="popup-content" :class="{ 'bottom': popupPosition.bottom }">
  8. <h3>{{ selectedPoint.STNM || '未知点' }}</h3>
  9. <p><strong>经度:</strong> {{ selectedPoint.LGTD }}</p>
  10. <p><strong>纬度:</strong> {{ selectedPoint.LTTD }}</p>
  11. <div class="popup-arrow"></div>
  12. </div>
  13. </div>
  14. </template>
  15. <script setup>
  16. import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue'
  17. import * as Cesium from 'cesium';
  18. // 接收父组件传递的参数
  19. const props = defineProps({
  20. viewer: {
  21. type: Object,
  22. required: true
  23. },
  24. visible: {
  25. type: Boolean,
  26. default: true
  27. },
  28. data: {
  29. type: Array,
  30. default: () => []
  31. },
  32. pointImage: {
  33. type: String,
  34. default: '/src/assets/icon/blue.png'
  35. },
  36. flyToOptions: {
  37. type: Object,
  38. default: () => ({
  39. duration: 1.5,
  40. offset: new Cesium.HeadingPitchRange(0, -0.5, 1500),
  41. maximumHeight: 5000000
  42. })
  43. }
  44. });
  45. // 暴露事件
  46. const emits = defineEmits(['onPointSelected', 'onPointClicked', 'onFlyFailed']);
  47. // POI相关状态
  48. const selectedPoint = ref(null);
  49. const popupPosition = ref({ x: 0, y: 0, bottom: false });
  50. const poiEntities = ref([]);
  51. const entityDataMap = ref(new Map());
  52. const popupUpdateCallback = ref(null);
  53. let handler = null;
  54. const flying = ref(false); // 飞行状态标记
  55. // 获取Cesium容器元素
  56. const getCesiumContainer = () => {
  57. return document.getElementById('cesiumContainer');
  58. };
  59. // 计算页面缩放比例(与autofit配置一致)
  60. const getScaleRatio = () => {
  61. const designWidth = 1920;
  62. const designHeight = 1080;
  63. return Math.min(window.innerWidth / designWidth, window.innerHeight / designHeight);
  64. };
  65. // 更新POI弹窗位置
  66. const updatePOIPopupPosition = (entity) => {
  67. if (popupUpdateCallback.value) {
  68. props.viewer.scene.postRender.removeEventListener(popupUpdateCallback.value);
  69. popupUpdateCallback.value = null;
  70. }
  71. if (!entity || !entity.position || !selectedPoint.value) return;
  72. popupUpdateCallback.value = () => {
  73. const container = getCesiumContainer();
  74. const containerRect = container.getBoundingClientRect();
  75. const entityPosition = props.viewer.scene.cartesianToCanvasCoordinates(
  76. entity.position.getValue(props.viewer.clock.currentTime)
  77. );
  78. if (entityPosition) {
  79. nextTick(() => {
  80. const popupEl = document.querySelector('.custom-popup .popup-content');
  81. if (!popupEl) return;
  82. const popupRect = popupEl.getBoundingClientRect();
  83. const popupWidth = popupRect.width;
  84. const popupHeight = popupRect.height;
  85. const arrowHeight = 10;
  86. // 计算POI点大小
  87. const billboardScale = entity.billboard?.scale || 0.4;
  88. const pointRadius = (20 * billboardScale) / 2;
  89. // 计算弹窗位置
  90. let x = (entityPosition.x - containerRect.left) - (popupWidth / 2);
  91. let y, bottom = false;
  92. // 默认显示在点的上方
  93. y = (entityPosition.y - containerRect.top) - popupHeight - arrowHeight - pointRadius;
  94. // 如果上方空间不足,显示在点的下方
  95. if (y < 0) {
  96. y = (entityPosition.y - containerRect.top) + pointRadius + arrowHeight;
  97. bottom = true;
  98. }
  99. // 边界检查
  100. if (x < 0) x = 0;
  101. if (x + popupWidth > containerRect.width) {
  102. x = containerRect.width - popupWidth;
  103. }
  104. popupPosition.value = { x, y, bottom };
  105. });
  106. }
  107. };
  108. props.viewer.scene.postRender.addEventListener(popupUpdateCallback.value);
  109. popupUpdateCallback.value();
  110. };
  111. // 隐藏POI弹窗
  112. const hidePOIPopup = () => {
  113. selectedPoint.value = null;
  114. if (popupUpdateCallback.value) {
  115. props.viewer.scene.postRender.removeEventListener(popupUpdateCallback.value);
  116. popupUpdateCallback.value = null;
  117. }
  118. };
  119. // 创建POI点实体
  120. const createPOIEntities = () => {
  121. // 清除现有实体
  122. clearPOIEntities();
  123. // 定义距离显示条件和缩放属性
  124. const distanceDisplayCondition = new Cesium.DistanceDisplayCondition(0, 10000000);
  125. const pointNearFarScalar = new Cesium.NearFarScalar(10000, 1.0, 1000000, 0.3);
  126. const labelNearFarScalar = new Cesium.NearFarScalar(10000, 1.0, 400000, 0);
  127. // 创建新实体
  128. props.data.forEach((item) => {
  129. // 验证经纬度数据是否有效
  130. if (!item.LGTD || !item.LTTD) {
  131. console.warn('POI点数据缺少经纬度信息:', item);
  132. return;
  133. }
  134. try {
  135. const longitude = parseFloat(item.LGTD);
  136. const latitude = parseFloat(item.LTTD);
  137. // 验证经纬度是否在有效范围内
  138. if (isNaN(longitude) || isNaN(latitude) || longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90) {
  139. console.warn('无效的经纬度数据:', item);
  140. return;
  141. }
  142. const position = Cesium.Cartesian3.fromDegrees(longitude, latitude);
  143. const entity = props.viewer.entities.add({
  144. position: position,
  145. billboard: {
  146. image: props.pointImage,
  147. scale: 0.4,
  148. color: Cesium.Color.YELLOW,
  149. horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
  150. verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
  151. distanceDisplayCondition: distanceDisplayCondition,
  152. scaleByDistance: pointNearFarScalar,
  153. show: props.visible
  154. },
  155. label: {
  156. text: item.STNM || '未知点',
  157. font: '25px 微软雅黑',
  158. fillColor: Cesium.Color.WHITE,
  159. backgroundColor: new Cesium.Color(0.1, 0.1, 0.1, 0.7),
  160. backgroundPadding: new Cesium.Cartesian2(8, 4),
  161. showBackground: true,
  162. cornerRadius: 4,
  163. outlineColor: Cesium.Color.BLACK,
  164. outlineWidth: 1,
  165. horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
  166. verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
  167. pixelOffset: new Cesium.Cartesian2(0, -32),
  168. scale: 1.0,
  169. disableDepthTestDistance: Number.POSITIVE_INFINITY,
  170. distanceDisplayCondition: distanceDisplayCondition,
  171. scaleByDistance: labelNearFarScalar,
  172. show: props.visible
  173. },
  174. id: `point-${item.STCD || item.LGTD + '-' + item.LTTD}`,
  175. properties: {
  176. data: item
  177. }
  178. });
  179. entityDataMap.value.set(entity.id, item);
  180. poiEntities.value.push(entity);
  181. } catch (error) {
  182. console.error('创建POI实体失败:', error, '数据:', item);
  183. }
  184. });
  185. };
  186. // 清除POI实体
  187. const clearPOIEntities = () => {
  188. poiEntities.value.forEach(entity => {
  189. props.viewer.entities.remove(entity);
  190. });
  191. poiEntities.value = [];
  192. entityDataMap.value.clear();
  193. };
  194. // 优化:直接使用坐标飞行到POI点(移除实体飞行方式)
  195. const flyToPOIPoint = async (entity, data) => {
  196. // 防止重复触发飞行
  197. if (flying.value) return false;
  198. if (!props.viewer || !data || !data.LGTD || !data.LTTD) {
  199. console.error('飞行参数不完整');
  200. emits('onFlyFailed', data, '参数不完整');
  201. return false;
  202. }
  203. flying.value = true;
  204. let success = false;
  205. try {
  206. // 直接使用经纬度坐标飞行(移除实体飞行方式)
  207. const longitude = parseFloat(data.LGTD);
  208. const latitude = parseFloat(data.LTTD);
  209. if (isNaN(longitude) || isNaN(latitude)) {
  210. throw new Error('经纬度解析失败');
  211. }
  212. // 计算目标高度 - 确保合理
  213. const height = Math.min(
  214. props.flyToOptions.offset.range,
  215. props.flyToOptions.maximumHeight
  216. );
  217. // 坐标微调参数(根据实际偏差调整)
  218. const lonOffset = -0.001; // 经度偏移量
  219. const latOffset = -0.025; // 纬度偏移量
  220. // 计算最终目标位置(应用微调)
  221. const destination = Cesium.Cartesian3.fromDegrees(
  222. longitude + lonOffset,
  223. latitude + latOffset,
  224. height
  225. );
  226. // 验证目标位置是否有效
  227. if (Cesium.Cartesian3.equals(destination, Cesium.Cartesian3.ZERO)) {
  228. throw new Error('计算的目标位置无效');
  229. }
  230. // 使用相机直接飞行
  231. await new Promise((resolve, reject) => {
  232. props.viewer.camera.flyTo({
  233. destination: destination,
  234. orientation: {
  235. heading: props.flyToOptions.offset.heading || Cesium.Math.toRadians(0),
  236. pitch: props.flyToOptions.offset.pitch || Cesium.Math.toRadians(-60), // 更平缓的角度
  237. roll: 0
  238. },
  239. duration: props.flyToOptions.duration || 1.5,
  240. complete: resolve,
  241. cancel: () => reject(new Error('用户取消飞行'))
  242. });
  243. });
  244. success = true;
  245. console.log('使用坐标飞行成功:', data.STNM);
  246. } catch (coordError) {
  247. console.error('坐标飞行失败:', coordError.message);
  248. emits('onFlyFailed', data, coordError.message);
  249. // 最终备选方案:直接设置相机位置
  250. try {
  251. const longitude = parseFloat(data.LGTD);
  252. const latitude = parseFloat(data.LTTD);
  253. if (!isNaN(longitude) && !isNaN(latitude)) {
  254. props.viewer.camera.setView({
  255. destination: Cesium.Cartesian3.fromDegrees(
  256. longitude,
  257. latitude,
  258. props.flyToOptions.offset.range
  259. ),
  260. orientation: {
  261. heading: props.flyToOptions.offset.heading || 0,
  262. pitch: props.flyToOptions.offset.pitch || -Cesium.Math.PI_OVER_TWO,
  263. roll: 0
  264. }
  265. });
  266. success = true;
  267. console.log('使用setView直接定位成功');
  268. }
  269. } catch (finalError) {
  270. console.error('所有飞行方法均失败:', finalError.message);
  271. }
  272. } finally {
  273. flying.value = false;
  274. }
  275. return success;
  276. };
  277. // 初始化事件监听
  278. const initEventListeners = () => {
  279. // 清除现有处理器
  280. if (handler) {
  281. handler.destroy();
  282. }
  283. handler = new Cesium.ScreenSpaceEventHandler(props.viewer.scene.canvas);
  284. // 点击事件处理
  285. handler.setInputAction(async (click) => {
  286. const scaleRatio = getScaleRatio();
  287. const correctedX = click.position.x / scaleRatio;
  288. const correctedY = click.position.y / scaleRatio;
  289. if (!props.visible || flying.value) return; // 飞行中不响应新点击
  290. // 标准拾取
  291. const pickedObject = props.viewer.scene.pick(new Cesium.Cartesian2(correctedX, correctedY));
  292. // 隐藏弹窗
  293. hidePOIPopup();
  294. if (Cesium.defined(pickedObject) && Cesium.defined(pickedObject.id)) {
  295. const entityId = pickedObject.id.id;
  296. const data = entityDataMap.value.get(entityId) ||
  297. pickedObject.id.properties?.data?.getValue();
  298. if (data && !entityId.startsWith('typhoon-point-')) {
  299. selectedPoint.value = data;
  300. // 计算图标屏幕坐标,固定显示在图标上方
  301. const entityPosition = props.viewer.scene.cartesianToCanvasCoordinates(pickedObject.id.position._value);
  302. if (entityPosition) {
  303. popupPosition.value = {
  304. x: (entityPosition.x / scaleRatio) - 100,
  305. y: (entityPosition.y / scaleRatio) - 130, // 130 = 弹框高度 + 间距
  306. bottom: false
  307. };
  308. }
  309. // 通知父组件
  310. emits('onPointSelected', data);
  311. emits('onPointClicked', data);
  312. // 跳转到POI点
  313. const flySuccess = await flyToPOIPoint(pickedObject.id, data);
  314. if (!flySuccess) {
  315. console.warn('飞行到POI点最终失败');
  316. }
  317. }
  318. } else {
  319. selectedPoint.value = null;
  320. emits('onPointSelected', null);
  321. }
  322. }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
  323. };
  324. // 监听visible属性变化
  325. watch(() => props.visible, (newVal) => {
  326. poiEntities.value.forEach(entity => {
  327. if (entity && entity.billboard) {
  328. entity.billboard.show = newVal;
  329. entity.label.show = newVal;
  330. }
  331. });
  332. });
  333. // 监听数据变化
  334. watch(() => props.data, (newVal) => {
  335. if (newVal && newVal.length > 0) {
  336. createPOIEntities();
  337. }
  338. }, { deep: true });
  339. // 监听viewer变化
  340. watch(() => props.viewer, (newVal) => {
  341. if (newVal) {
  342. initEventListeners();
  343. if (props.data && props.data.length > 0) {
  344. createPOIEntities();
  345. }
  346. }
  347. });
  348. // 组件挂载时初始化
  349. onMounted(() => {
  350. if (props.viewer) {
  351. initEventListeners();
  352. // 初始创建POI点
  353. if (props.data && props.data.length > 0) {
  354. createPOIEntities();
  355. }
  356. } else {
  357. console.warn('POI组件挂载时viewer尚未初始化');
  358. }
  359. });
  360. // 组件卸载时清理
  361. onUnmounted(() => {
  362. // 移除弹窗更新回调
  363. if (popupUpdateCallback.value) {
  364. props.viewer?.scene.postRender.removeEventListener(popupUpdateCallback.value);
  365. }
  366. // 清除事件处理器
  367. if (handler) {
  368. handler.destroy();
  369. }
  370. // 清除所有POI实体
  371. clearPOIEntities();
  372. });
  373. // 暴露方法
  374. defineExpose({
  375. hidePOIPopup,
  376. flyToPOIPoint
  377. });
  378. </script>
  379. <style scoped>
  380. .custom-popup {
  381. position: absolute;
  382. z-index: 1000;
  383. pointer-events: none;
  384. top: 0;
  385. left: 0;
  386. }
  387. .popup-content {
  388. background-color: white;
  389. border-radius: 5px;
  390. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  391. padding: 10px 15px;
  392. width: 240px;
  393. pointer-events: all;
  394. min-width: 200px;
  395. box-sizing: border-box;
  396. position: relative;
  397. }
  398. .popup-arrow {
  399. position: absolute;
  400. width: 0;
  401. height: 0;
  402. border-left: 10px solid transparent;
  403. border-right: 10px solid transparent;
  404. left: 50%;
  405. transform: translateX(-50%);
  406. }
  407. .popup-content:not(.bottom) .popup-arrow {
  408. bottom: -10px;
  409. border-top: 10px solid white;
  410. }
  411. .popup-content.bottom .popup-arrow {
  412. top: -10px;
  413. border-bottom: 10px solid white;
  414. }
  415. .popup-content h3 {
  416. margin-top: 0;
  417. margin-bottom: 8px;
  418. color: #333;
  419. font-size: 16px;
  420. }
  421. .popup-content p {
  422. margin: 5px 0;
  423. color: #666;
  424. font-size: 14px;
  425. }
  426. @media (max-width: 768px) {
  427. .popup-content {
  428. width: 200px;
  429. padding: 8px 12px;
  430. }
  431. }
  432. </style>