overview-map.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557
  1. <template>
  2. <div
  3. v-if="visible"
  4. class="overview-map-container"
  5. >
  6. <div class="overview-map-header">
  7. <span class="overview-title">鹰眼</span>
  8. <span class="overview-close" @click="close">×</span>
  9. </div>
  10. <div
  11. class="overview-map-canvas"
  12. ref="canvasContainer"
  13. @click="onCanvasClick"
  14. >
  15. <div ref="cesiumContainer" class="cesium-mini-map"></div>
  16. <!-- 半透明红框 -->
  17. <div
  18. class="view-rect"
  19. :style="viewRectStyle"
  20. ></div>
  21. </div>
  22. </div>
  23. </template>
  24. <script>
  25. import { ref, reactive, onMounted, onUnmounted, watch, nextTick } from "vue";
  26. export default {
  27. name: "Sm3dOverviewMap",
  28. props: {
  29. visible: {
  30. type: Boolean,
  31. default: false
  32. }
  33. },
  34. emits: ['close'],
  35. setup(props, { emit }) {
  36. // 获取主场景viewer(优先从window获取)
  37. const getMainViewer = () => {
  38. return window.viewer || null;
  39. };
  40. const canvasContainer = ref(null);
  41. const cesiumContainer = ref(null);
  42. // 福建行政外接矩形(WGS84/EPSG:4326)
  43. const FUJIAN_EXTENT = {
  44. minLng: 115.7,
  45. maxLng: 120.8,
  46. minLat: 23.5,
  47. maxLat: 28.3
  48. };
  49. // 红框样式
  50. const viewRectStyle = reactive({
  51. left: '0px',
  52. top: '0px',
  53. width: '40px',
  54. height: '40px',
  55. display: 'block'
  56. });
  57. let miniViewer = null;
  58. let updateInterval = null;
  59. // 将经纬度转换为容器坐标(福建范围映射)
  60. function latLngToContainer(lat, lng, width, height) {
  61. // 福建范围:经度115.7~120.8,纬度23.5~28.3
  62. const lngRange = FUJIAN_EXTENT.maxLng - FUJIAN_EXTENT.minLng;
  63. const latRange = FUJIAN_EXTENT.maxLat - FUJIAN_EXTENT.minLat;
  64. const x = ((lng - FUJIAN_EXTENT.minLng) / lngRange) * width;
  65. const y = ((FUJIAN_EXTENT.maxLat - lat) / latRange) * height;
  66. return { x, y };
  67. }
  68. // 将容器坐标转换为经纬度(福建范围映射)
  69. function containerToLatLng(x, y, width, height) {
  70. const lngRange = FUJIAN_EXTENT.maxLng - FUJIAN_EXTENT.minLng;
  71. const latRange = FUJIAN_EXTENT.maxLat - FUJIAN_EXTENT.minLat;
  72. const lng = (x / width) * lngRange + FUJIAN_EXTENT.minLng;
  73. const lat = FUJIAN_EXTENT.maxLat - (y / height) * latRange;
  74. return { lng, lat };
  75. }
  76. // 获取当前相机视线指向的地面位置(视口中心点对应的地面位置)
  77. function getCameraPosition() {
  78. const mainViewer = getMainViewer();
  79. if (!mainViewer || !mainViewer.scene || !mainViewer.camera) return null;
  80. try {
  81. const scene = mainViewer.scene;
  82. const camera = mainViewer.camera;
  83. const canvas = scene.canvas;
  84. // 创建从视口中心发射的射线(这是获取视口中心点对应地面位置的正确方式)
  85. const centerScreenPos = new Cesium.Cartesian2(canvas.width / 2, canvas.height / 2);
  86. const ray = camera.getPickRay(centerScreenPos);
  87. // 获取射线与地面的交点
  88. const intersection = scene.globe.pick(ray, scene);
  89. if (intersection) {
  90. // 将交点转换为经纬度
  91. const cartographic = Cesium.Cartographic.fromCartesian(intersection);
  92. // 计算相机到目标点的实际距离(这是真正的观察高度,不受相机倾斜影响)
  93. const distance = Cesium.Cartesian3.distance(camera.position, intersection);
  94. return {
  95. lat: Cesium.Math.toDegrees(cartographic.latitude),
  96. lng: Cesium.Math.toDegrees(cartographic.longitude),
  97. height: distance
  98. };
  99. }
  100. // 如果无法获取交点,使用相机位置的经纬度(正下方地面)
  101. const cameraCartographic = Cesium.Cartographic.fromCartesian(camera.position);
  102. if (cameraCartographic) {
  103. return {
  104. lat: Cesium.Math.toDegrees(cameraCartographic.latitude),
  105. lng: Cesium.Math.toDegrees(cameraCartographic.longitude),
  106. height: cameraCartographic.height
  107. };
  108. }
  109. return null;
  110. } catch (e) {
  111. console.warn('获取相机位置失败:', e);
  112. return null;
  113. }
  114. }
  115. // 获取当前视口范围
  116. function getViewportBounds() {
  117. const mainViewer = getMainViewer();
  118. if (!mainViewer || !mainViewer.scene) return null;
  119. try {
  120. const scene = mainViewer.scene;
  121. const camera = mainViewer.camera;
  122. // 获取视口四角的地面交点
  123. const canvas = scene.canvas;
  124. const width = canvas.width;
  125. const height = canvas.height;
  126. const corners = [
  127. new Cesium.Cartesian2(0, 0),
  128. new Cesium.Cartesian2(width, 0),
  129. new Cesium.Cartesian2(width, height),
  130. new Cesium.Cartesian2(0, height)
  131. ];
  132. let minLat = 90, maxLat = -90, minLng = 180, maxLng = -180;
  133. for (const corner of corners) {
  134. const ray = camera.getPickRay(corner);
  135. const intersection = scene.globe.pick(ray, scene);
  136. if (intersection) {
  137. const cartographic = Cesium.Cartographic.fromCartesian(intersection);
  138. const lat = Cesium.Math.toDegrees(cartographic.latitude);
  139. const lng = Cesium.Math.toDegrees(cartographic.longitude);
  140. minLat = Math.min(minLat, lat);
  141. maxLat = Math.max(maxLat, lat);
  142. minLng = Math.min(minLng, lng);
  143. maxLng = Math.max(maxLng, lng);
  144. }
  145. }
  146. // 如果有有效范围,返回边界
  147. if (minLat <= maxLat && minLng <= maxLng) {
  148. return { minLat, maxLat, minLng, maxLng };
  149. }
  150. return null;
  151. } catch (e) {
  152. console.warn('获取视口范围失败:', e);
  153. return null;
  154. }
  155. }
  156. // 更新红框位置
  157. function updateViewRect() {
  158. if (!canvasContainer.value || !miniViewer) {
  159. return;
  160. }
  161. const containerRect = canvasContainer.value.getBoundingClientRect();
  162. const width = containerRect.width;
  163. const height = containerRect.height;
  164. // 如果没有主场景viewer,隐藏红框
  165. const mainViewer = getMainViewer();
  166. if (!mainViewer || !mainViewer.scene) {
  167. viewRectStyle.display = 'none';
  168. return;
  169. }
  170. const cameraPos = getCameraPosition();
  171. if (!cameraPos) {
  172. viewRectStyle.display = 'none';
  173. return;
  174. }
  175. // 使用Cesium的精确坐标转换获取红框中心位置
  176. try {
  177. const cartesian = Cesium.Cartesian3.fromDegrees(cameraPos.lng, cameraPos.lat);
  178. const screenPos = miniViewer.scene.cartesianToCanvasCoordinates(cartesian);
  179. if (screenPos && !isNaN(screenPos.x) && !isNaN(screenPos.y)) {
  180. // 使用Cesium转换后的屏幕坐标作为红框中心
  181. const centerX = screenPos.x;
  182. const centerY = screenPos.y;
  183. // 计算红框尺寸(基于相机高度估算视口范围)
  184. // 相机高度越高(越远)→ 视口范围越大 → 红框越大
  185. // 相机高度越低(越近)→ 视口范围越小 → 红框越小
  186. const maxRectSize = Math.min(width, height) * 0.35;
  187. const minRectSize = 15;
  188. // 使用高度比例:高度越高,红框越大
  189. const rectSize = Math.max(minRectSize, Math.min(maxRectSize,
  190. (cameraPos.height / 50000) * Math.min(width, height) * 0.2));
  191. // 设置红框位置(以转换后的坐标为中心)
  192. viewRectStyle.left = (centerX - rectSize / 2) + 'px';
  193. viewRectStyle.top = (centerY - rectSize / 2) + 'px';
  194. viewRectStyle.width = rectSize + 'px';
  195. viewRectStyle.height = rectSize + 'px';
  196. viewRectStyle.display = 'block';
  197. return;
  198. }
  199. } catch (e) {
  200. console.warn('Cesium坐标转换失败:', e);
  201. }
  202. // 回退方案:使用手动计算
  203. const center = latLngToContainer(cameraPos.lat, cameraPos.lng, width, height);
  204. const maxRectSize = Math.min(width, height) * 0.35;
  205. const minRectSize = 15;
  206. // 使用高度比例:高度越高,红框越大
  207. const rectSize = Math.max(minRectSize, Math.min(maxRectSize,
  208. (cameraPos.height / 50000) * Math.min(width, height) * 0.2));
  209. viewRectStyle.left = (center.x - rectSize / 2) + 'px';
  210. viewRectStyle.top = (center.y - rectSize / 2) + 'px';
  211. viewRectStyle.width = rectSize + 'px';
  212. viewRectStyle.height = rectSize + 'px';
  213. viewRectStyle.display = 'block';
  214. }
  215. // 更新鹰眼地图视图(固定到福建范围)
  216. function updateMiniViewerView() {
  217. if (!miniViewer) return;
  218. // 固定缩放到福建范围
  219. miniViewer.camera.setView({
  220. destination: Cesium.Rectangle.fromDegrees(
  221. FUJIAN_EXTENT.minLng,
  222. FUJIAN_EXTENT.minLat,
  223. FUJIAN_EXTENT.maxLng,
  224. FUJIAN_EXTENT.maxLat
  225. )
  226. });
  227. }
  228. // 飞转到指定位置
  229. function flyToPosition(lng, lat) {
  230. const mainViewer = getMainViewer();
  231. if (!mainViewer) return;
  232. const height = mainViewer.camera.positionCartographic ?
  233. mainViewer.camera.positionCartographic.height : 10000;
  234. mainViewer.camera.flyTo({
  235. destination: Cesium.Cartesian3.fromDegrees(lng, lat, height),
  236. orientation: {
  237. heading: mainViewer.camera.heading,
  238. pitch: mainViewer.camera.pitch,
  239. roll: 0
  240. },
  241. duration: 0.5
  242. });
  243. }
  244. // 点击鹰眼地图任意位置,飞转到对应经纬度
  245. const onCanvasClick = (event) => {
  246. if (!canvasContainer.value) return;
  247. const rect = canvasContainer.value.getBoundingClientRect();
  248. const clickX = event.clientX - rect.left;
  249. const clickY = event.clientY - rect.top;
  250. // 使用线性映射获取经纬度
  251. const { lng, lat } = containerToLatLng(clickX, clickY, rect.width, rect.height);
  252. // 立即更新红框位置到点击位置(居中显示)
  253. const maxRectSize = Math.min(rect.width, rect.height) * 0.35;
  254. const minRectSize = 15;
  255. // 获取当前相机高度用于计算红框尺寸
  256. const mainViewer = getMainViewer();
  257. const currentHeight = mainViewer && mainViewer.camera.positionCartographic ?
  258. mainViewer.camera.positionCartographic.height : 10000;
  259. const rectSize = Math.max(minRectSize, Math.min(maxRectSize,
  260. (currentHeight / 50000) * Math.min(rect.width, rect.height) * 0.2));
  261. // 将红框居中到点击位置(确保红框中心始终在点击位置)
  262. viewRectStyle.left = (clickX - rectSize / 2) + 'px';
  263. viewRectStyle.top = (clickY - rectSize / 2) + 'px';
  264. viewRectStyle.width = rectSize + 'px';
  265. viewRectStyle.height = rectSize + 'px';
  266. viewRectStyle.display = 'block';
  267. // 让三维场景飞转到对应位置
  268. flyToPosition(lng, lat);
  269. };
  270. const close = () => {
  271. emit('close');
  272. };
  273. // 创建小型Cesium视图
  274. function createMiniViewer() {
  275. if (!cesiumContainer.value) return;
  276. const tiandituKey = '3fb1e9fda20ee995dc815c8243553ce8';
  277. // 使用地理坐标系(EPSG:4326)的瓦片方案
  278. const geographicTilingScheme = new Cesium.GeographicTilingScheme();
  279. const imageryProvider = new Cesium.WebMapTileServiceImageryProvider({
  280. url: `https://t0.tianditu.gov.cn/img_c/wmts?service=WMTS&request=GetTile&version=1.0.0&LAYER=img&tileMatrixSet=c&TileMatrix={TileMatrix}&TileRow={TileRow}&TileCol={TileCol}&style=default&format=tiles&tk=${tiandituKey}`,
  281. layer: 'img',
  282. style: 'default',
  283. format: 'image/jpeg',
  284. tileMatrixSetID: 'c',
  285. subdomains: ['t0', 't1', 't2', 't3', 't4', 't5', 't6', 't7'],
  286. tilingScheme: geographicTilingScheme,
  287. tileMatrixLabels: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19'],
  288. maximumLevel: 15,
  289. show: true
  290. });
  291. // 创建自定义的2D视图投影,使用地理坐标系
  292. const mapProjection = new Cesium.GeographicProjection();
  293. // 创建场景模式配置,强制使用地理坐标系
  294. const sceneMode = Cesium.SceneMode.SCENE2D;
  295. miniViewer = new Cesium.Viewer(cesiumContainer.value, {
  296. imageryProvider: imageryProvider,
  297. baseLayerPicker: false,
  298. timeline: false,
  299. animation: false,
  300. geocoder: false,
  301. homeButton: false,
  302. sceneModePicker: false,
  303. navigationHelpButton: false,
  304. navigationInstructionsInitiallyVisible: false,
  305. fullscreenButton: false,
  306. shadows: false,
  307. terrainProvider: new Cesium.EllipsoidTerrainProvider(),
  308. creditContainer: document.createElement('div')
  309. });
  310. // 设置为2D模式
  311. miniViewer.scene.morphTo2D(0);
  312. // 完全禁用所有相机控制(锁定视图)
  313. miniViewer.scene.screenSpaceCameraController.enableRotate = false;
  314. miniViewer.scene.screenSpaceCameraController.enableZoom = false;
  315. miniViewer.scene.screenSpaceCameraController.enablePan = false;
  316. miniViewer.scene.screenSpaceCameraController.enableTilt = false;
  317. miniViewer.scene.screenSpaceCameraController.enableLook = false;
  318. // 添加注记图层
  319. const labelProvider = new Cesium.WebMapTileServiceImageryProvider({
  320. url: `https://t0.tianditu.gov.cn/cia_c/wmts?service=WMTS&request=GetTile&version=1.0.0&LAYER=cia&tileMatrixSet=c&TileMatrix={TileMatrix}&TileRow={TileRow}&TileCol={TileCol}&style=default&format=tiles&tk=${tiandituKey}`,
  321. layer: 'cia',
  322. style: 'default',
  323. format: 'image/png',
  324. tileMatrixSetID: 'c',
  325. subdomains: ['t0', 't1', 't2', 't3', 't4', 't5', 't6', 't7'],
  326. tilingScheme: geographicTilingScheme,
  327. tileMatrixLabels: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '19'],
  328. maximumLevel: 15,
  329. show: true
  330. });
  331. miniViewer.imageryLayers.addImageryProvider(labelProvider);
  332. // 缩放到全球视图(使用地理坐标系)
  333. miniViewer.camera.setView({
  334. destination: Cesium.Rectangle.fromDegrees(-180, -90, 180, 90)
  335. });
  336. // 延迟resize确保尺寸正确
  337. setTimeout(() => {
  338. if (miniViewer) {
  339. miniViewer.resize();
  340. updateViewRect();
  341. updateMiniViewerView();
  342. }
  343. }, 150);
  344. // 启动定时更新
  345. startUpdateLoop();
  346. }
  347. function startUpdateLoop() {
  348. stopUpdateLoop();
  349. updateInterval = setInterval(() => {
  350. updateViewRect();
  351. updateMiniViewerView();
  352. }, 50);
  353. }
  354. function stopUpdateLoop() {
  355. if (updateInterval) {
  356. clearInterval(updateInterval);
  357. updateInterval = null;
  358. }
  359. }
  360. onMounted(() => {
  361. nextTick(() => {
  362. createMiniViewer();
  363. });
  364. });
  365. onUnmounted(() => {
  366. stopUpdateLoop();
  367. if (miniViewer) {
  368. miniViewer.destroy();
  369. miniViewer = null;
  370. }
  371. });
  372. watch(() => props.visible, (newVal) => {
  373. if (newVal) {
  374. nextTick(() => {
  375. if (!miniViewer) {
  376. createMiniViewer();
  377. } else {
  378. miniViewer.resize();
  379. updateViewRect();
  380. updateMiniViewerView();
  381. }
  382. });
  383. } else {
  384. stopUpdateLoop();
  385. if (miniViewer) {
  386. miniViewer.destroy();
  387. miniViewer = null;
  388. }
  389. }
  390. });
  391. return {
  392. canvasContainer,
  393. cesiumContainer,
  394. viewRectStyle,
  395. onCanvasClick,
  396. close
  397. };
  398. }
  399. };
  400. </script>
  401. <style scoped>
  402. .overview-map-container {
  403. position: fixed;
  404. right: 20px;
  405. bottom: 120px;
  406. width: 350px;
  407. height: 280px;
  408. background: rgba(30, 41, 59, 0.95);
  409. border-radius: 10px;
  410. box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
  411. z-index: 1000;
  412. overflow: hidden;
  413. border: 1px solid rgba(148, 163, 184, 0.1);
  414. }
  415. .overview-map-header {
  416. display: flex;
  417. justify-content: space-between;
  418. align-items: center;
  419. padding: 10px 14px;
  420. background: rgba(51, 65, 85, 0.85);
  421. border-bottom: 1px solid rgba(148, 163, 184, 0.15);
  422. flex-shrink: 0;
  423. }
  424. .overview-title {
  425. color: #e2e8f0;
  426. font-size: 13px;
  427. font-weight: 500;
  428. }
  429. .overview-close {
  430. color: #94a3b8;
  431. font-size: 20px;
  432. cursor: pointer;
  433. line-height: 1;
  434. transition: color 0.2s;
  435. font-weight: 300;
  436. }
  437. .overview-close:hover {
  438. color: #f1f5f9;
  439. }
  440. .overview-map-canvas {
  441. position: relative;
  442. width: 100%;
  443. height: calc(100% - 42px);
  444. cursor: crosshair;
  445. overflow: hidden;
  446. }
  447. .cesium-mini-map {
  448. width: 100%;
  449. height: 100%;
  450. position: absolute;
  451. top: 0;
  452. left: 0;
  453. }
  454. .view-rect {
  455. position: absolute;
  456. border: 2px solid #ef4444;
  457. background: rgba(239, 68, 68, 0.25);
  458. cursor: default;
  459. box-sizing: border-box;
  460. transition: all 0.1s ease;
  461. z-index: 10;
  462. }
  463. .view-rect:hover {
  464. box-shadow: 0 0 15px rgba(239, 68, 68, 0.6);
  465. border-color: #f87171;
  466. background: rgba(239, 68, 68, 0.35);
  467. }
  468. .view-rect.dragging {
  469. border-color: #f97316;
  470. background: rgba(249, 115, 22, 0.4);
  471. box-shadow: 0 0 20px rgba(249, 115, 22, 0.6);
  472. }
  473. </style>