rainfall-visualization.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. <template>
  2. <!-- 降雨时段切换框 -->
  3. <div v-if="visible" class="rainfall-switcher">
  4. <div
  5. v-for="h in hourOptions"
  6. :key="h"
  7. class="rainfall-switch-item"
  8. :class="{ active: currentHours === h, disabled: loading && currentHours !== h }"
  9. @click="switchHours(h)"
  10. >
  11. 未来{{ h }}h降雨
  12. </div>
  13. </div>
  14. <!-- 加载中 -->
  15. <div v-if="loading" class="rainfall-loading">
  16. <span class="loading-spinner"></span>
  17. <span>降雨数据加载中...</span>
  18. </div>
  19. <!-- 加载失败 -->
  20. <div v-if="error" class="rainfall-error">
  21. <span>降雨加载失败:{{ error }}</span>
  22. </div>
  23. <!-- 鼠标悬停降雨等级提示 -->
  24. <div v-if="hoverInfo.visible" class="rainfall-tooltip" :style="{ left: hoverInfo.x + 'px', top: hoverInfo.y + 'px' }">
  25. {{ hoverInfo.text }}
  26. </div>
  27. </template>
  28. <script setup>
  29. import { ref, watch, onMounted, onUnmounted } from 'vue'
  30. import axios from 'axios'
  31. import { ElMessage } from 'element-plus'
  32. import { PROXY_SLT } from '../../config/server_config.js'
  33. const props = defineProps({
  34. viewer: { type: Object, default: null },
  35. visible: { type: Boolean, default: true },
  36. defaultHours: { type: Number, default: 24 }
  37. })
  38. const hourOptions = [24, 48, 72]
  39. const currentHours = ref(props.defaultHours)
  40. const loading = ref(false)
  41. const error = ref('')
  42. const hoverInfo = ref({ visible: false, x: 0, y: 0, text: '' })
  43. let imageryLayer = null
  44. let hitContours = [] // [{ symbol, level, polyLatlng }] 按 symbol 降序,供悬停命中
  45. let isMounted = false
  46. let hoverHandler = null
  47. // 整个降雨图层叠加在底图上的透明度(透出地形)
  48. const LAYER_ALPHA = 0.8
  49. // 降雨等级配置:按 symbol(降雨量阈值 mm) 升序。
  50. // 注意:API 返回的 value 始终为 0,真正的阈值在 symbol 字段。
  51. const RAIN_LEVELS = [
  52. { min: 0, name: '小雨', color: '#A6F0A6', range: '0~10mm' },
  53. { min: 10, name: '中雨', color: '#3AA402', range: '10~25mm' },
  54. { min: 25, name: '大雨', color: '#71B4E8', range: '25~50mm' },
  55. { min: 50, name: '暴雨', color: '#0033FF', range: '50~100mm' },
  56. { min: 100, name: '大暴雨', color: '#C700CC', range: '100~250mm' },
  57. { min: 250, name: '特大暴雨', color: '#8B0000', range: '≥250mm' }
  58. ]
  59. // 由 symbol 取等级(取 min <= symbol 的最高等级)
  60. const getLevel = (symbol) => {
  61. let lvl = RAIN_LEVELS[0]
  62. for (const l of RAIN_LEVELS) {
  63. if (symbol >= l.min) lvl = l
  64. }
  65. return lvl
  66. }
  67. const getViewer = () => props.viewer || window.viewer
  68. // 射线法判断点 [lat, lng] 是否在多边形 [[lat, lng], ...] 内部(用于悬停命中)
  69. const pointInPolygon = (point, polygon) => {
  70. const [py, px] = point
  71. let inside = false
  72. for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
  73. const [yi, xi] = polygon[i]
  74. const [yj, xj] = polygon[j]
  75. const intersect = ((yi > py) !== (yj > py)) &&
  76. (px < (xj - xi) * (py - yi) / (yj - yi) + xi)
  77. if (intersect) inside = !inside
  78. }
  79. return inside
  80. }
  81. // 清除降雨图层
  82. const clearLayer = () => {
  83. const v = getViewer()
  84. if (v && imageryLayer) {
  85. try { v.imageryLayers.remove(imageryLayer) } catch {}
  86. }
  87. imageryLayer = null
  88. hitContours = []
  89. hoverInfo.value.visible = false
  90. }
  91. // 绘制降雨等值面:把各等级多边形按“小雨先画、高等级后画”的光栅化方式
  92. // 画到一张 canvas 上(2D 画板的画家算法天然保证后画覆盖先画,无 z-fighting、无边界缝),
  93. // 再把这张 canvas 作为单张影像叠加到地形上(imagery 天然贴合地形)。
  94. // 这样彻底避开了 Cesium 地面分类中重叠多边形的 z-fighting 与孔洞边界问题。
  95. const drawContours = (contours) => {
  96. const v = getViewer()
  97. if (!v) return
  98. clearLayer()
  99. // 按 symbol 分组
  100. const bySymbol = new Map()
  101. const presentSymbols = new Set()
  102. const bounds = { west: Infinity, east: -Infinity, south: Infinity, north: -Infinity }
  103. for (const c of contours) {
  104. if (!c.latAndLong || c.latAndLong.length < 3) continue
  105. const symbol = Number(c.symbol ?? 0)
  106. if (!bySymbol.has(symbol)) bySymbol.set(symbol, [])
  107. bySymbol.get(symbol).push(c.latAndLong)
  108. presentSymbols.add(symbol)
  109. for (const [lat, lng] of c.latAndLong) {
  110. if (lng < bounds.west) bounds.west = lng
  111. if (lng > bounds.east) bounds.east = lng
  112. if (lat < bounds.south) bounds.south = lat
  113. if (lat > bounds.north) bounds.north = lat
  114. }
  115. }
  116. if (bounds.west === Infinity) return
  117. // 光栅化 canvas
  118. const spanLng = bounds.east - bounds.west
  119. const spanLat = bounds.north - bounds.south
  120. const maxDim = 2048
  121. let cw, ch
  122. if (spanLng >= spanLat) {
  123. cw = maxDim
  124. ch = Math.max(1, Math.round(maxDim * spanLat / spanLng))
  125. } else {
  126. ch = maxDim
  127. cw = Math.max(1, Math.round(maxDim * spanLng / spanLat))
  128. }
  129. const canvas = document.createElement('canvas')
  130. canvas.width = cw
  131. canvas.height = ch
  132. const ctx = canvas.getContext('2d')
  133. ctx.clearRect(0, 0, cw, ch)
  134. const toX = (lng) => (lng - bounds.west) / spanLng * cw
  135. const toY = (lat) => (bounds.north - lat) / spanLat * ch
  136. // 升序绘制:小雨(范围最大)先画在最底,大暴雨最后画在最上层 —— 画家算法保证覆盖关系
  137. const sortedAsc = [...bySymbol.keys()].sort((a, b) => a - b)
  138. for (const symbol of sortedAsc) {
  139. const level = getLevel(symbol)
  140. ctx.fillStyle = level.color // canvas 内不透明,整体透明度由 imageryLayer.alpha 控制
  141. for (const poly of bySymbol.get(symbol)) {
  142. ctx.beginPath()
  143. for (let i = 0; i < poly.length; i++) {
  144. const [lat, lng] = poly[i]
  145. const x = toX(lng)
  146. const y = toY(lat)
  147. if (i === 0) ctx.moveTo(x, y)
  148. else ctx.lineTo(x, y)
  149. }
  150. ctx.closePath()
  151. ctx.fill()
  152. }
  153. }
  154. // 作为单张影像叠加到地形上(天然贴合地形,单张影像无重叠/无缝隙)
  155. try {
  156. const dataUrl = canvas.toDataURL('image/png')
  157. const provider = new Cesium.SingleTileImageryProvider({
  158. url: dataUrl,
  159. rectangle: Cesium.Rectangle.fromDegrees(bounds.west, bounds.south, bounds.east, bounds.north)
  160. })
  161. imageryLayer = v.imageryLayers.addImageryProvider(provider)
  162. imageryLayer.alpha = LAYER_ALPHA
  163. } catch (e) {
  164. console.error('降雨影像叠加失败:', e)
  165. ElMessage.error('降雨图层绘制失败')
  166. return
  167. }
  168. // 构建悬停命中数据:按 symbol 降序(高等级优先命中,与画面覆盖关系一致)
  169. hitContours = []
  170. for (const symbol of [...presentSymbols].sort((a, b) => b - a)) {
  171. const level = getLevel(symbol)
  172. for (const poly of bySymbol.get(symbol)) {
  173. hitContours.push({ symbol, level, polyLatlng: poly })
  174. }
  175. }
  176. }
  177. // 鼠标悬停拾取降雨等级:用地形交点经纬度,对命中数据从高到低做点-多边形检测
  178. const setupHover = () => {
  179. const v = getViewer()
  180. if (!v || hoverHandler) return
  181. hoverHandler = new Cesium.ScreenSpaceEventHandler(v.scene.canvas)
  182. hoverHandler.setInputAction((movement) => {
  183. if (!hitContours.length) {
  184. hoverInfo.value.visible = false
  185. return
  186. }
  187. try {
  188. const ray = v.camera.getPickRay(movement.endPosition)
  189. let cartesian = ray ? v.scene.globe.pick(ray, v.scene) : undefined
  190. // 地形未加载时回退到椭球面取点,保证悬停始终可用
  191. if (!cartesian) {
  192. cartesian = v.camera.pickEllipsoid(movement.endPosition, Cesium.Ellipsoid.WGS84)
  193. }
  194. if (!cartesian) {
  195. hoverInfo.value.visible = false
  196. return
  197. }
  198. const carto = Cesium.Cartographic.fromCartesian(cartesian)
  199. const lng = Cesium.Math.toDegrees(carto.longitude)
  200. const lat = Cesium.Math.toDegrees(carto.latitude)
  201. let found = null
  202. for (const hc of hitContours) {
  203. if (pointInPolygon([lat, lng], hc.polyLatlng)) { found = hc; break }
  204. }
  205. if (found) {
  206. hoverInfo.value = {
  207. visible: true,
  208. x: movement.endPosition.x + 14,
  209. y: movement.endPosition.y + 14,
  210. text: found.level.name
  211. }
  212. } else {
  213. hoverInfo.value.visible = false
  214. }
  215. } catch {
  216. hoverInfo.value.visible = false
  217. }
  218. }, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
  219. }
  220. const destroyHover = () => {
  221. if (hoverHandler) {
  222. hoverHandler.destroy()
  223. hoverHandler = null
  224. }
  225. hoverInfo.value.visible = false
  226. }
  227. // 获取降雨数据
  228. const fetchRainfall = async (hours) => {
  229. const v = getViewer()
  230. if (!v) {
  231. ElMessage.error('Cesium viewer 未初始化')
  232. return
  233. }
  234. loading.value = true
  235. error.value = ''
  236. clearLayer()
  237. try {
  238. const url = `${PROXY_SLT}/Api/LeastRain/${hours}`
  239. const { data } = await axios.get(url)
  240. let contours = data?.contours
  241. if (typeof contours === 'string') {
  242. contours = JSON.parse(contours)
  243. }
  244. if (!Array.isArray(contours) || contours.length === 0) {
  245. ElMessage.info(`未来${hours}h暂无降雨数据`)
  246. return
  247. }
  248. if (!isMounted) return
  249. drawContours(contours)
  250. setupHover()
  251. ElMessage.success(`已加载未来${hours}h降雨`)
  252. } catch (e) {
  253. console.error('获取降雨数据失败:', e)
  254. error.value = e.message || '请求失败'
  255. ElMessage.error('获取降雨数据失败')
  256. } finally {
  257. loading.value = false
  258. }
  259. }
  260. // 切换时段
  261. const switchHours = (h) => {
  262. if (currentHours.value === h || loading.value) return
  263. currentHours.value = h
  264. fetchRainfall(h)
  265. }
  266. // 可见性变化
  267. watch(() => props.visible, (val) => {
  268. if (val) {
  269. fetchRainfall(currentHours.value)
  270. } else {
  271. clearLayer()
  272. destroyHover()
  273. }
  274. })
  275. onMounted(() => {
  276. isMounted = true
  277. if (props.visible) {
  278. fetchRainfall(currentHours.value)
  279. }
  280. })
  281. onUnmounted(() => {
  282. isMounted = false
  283. destroyHover()
  284. clearLayer()
  285. })
  286. </script>
  287. <style scoped>
  288. .rainfall-switcher {
  289. position: absolute;
  290. top: 70px;
  291. left: 20px;
  292. z-index: 1000;
  293. display: flex;
  294. gap: 4px;
  295. background: rgba(0, 0, 0, 0.55);
  296. border-radius: 4px;
  297. padding: 3px;
  298. }
  299. .rainfall-switch-item {
  300. padding: 4px 10px;
  301. font-size: 12px;
  302. font-weight: bold;
  303. color: #fff;
  304. cursor: pointer;
  305. border-radius: 3px;
  306. user-select: none;
  307. transition: all 0.2s;
  308. border: 1px solid transparent;
  309. white-space: nowrap;
  310. }
  311. .rainfall-switch-item:hover {
  312. background: rgba(255, 255, 255, 0.15);
  313. }
  314. .rainfall-switch-item.active {
  315. background: #409eff;
  316. border-color: #409eff;
  317. }
  318. .rainfall-switch-item.disabled {
  319. opacity: 0.5;
  320. cursor: not-allowed;
  321. }
  322. .rainfall-loading,
  323. .rainfall-error {
  324. position: absolute;
  325. top: 105px;
  326. left: 20px;
  327. z-index: 1000;
  328. background: rgba(0, 0, 0, 0.6);
  329. color: #fff;
  330. padding: 8px 16px;
  331. border-radius: 6px;
  332. font-size: 14px;
  333. display: flex;
  334. align-items: center;
  335. gap: 8px;
  336. }
  337. .loading-spinner {
  338. display: inline-block;
  339. width: 14px;
  340. height: 14px;
  341. border: 2px solid rgba(255, 255, 255, 0.3);
  342. border-top-color: #fff;
  343. border-radius: 50%;
  344. animation: rain-spin 0.8s linear infinite;
  345. }
  346. @keyframes rain-spin {
  347. to { transform: rotate(360deg); }
  348. }
  349. /* 鼠标悬停提示 */
  350. .rainfall-tooltip {
  351. position: absolute;
  352. z-index: 1001;
  353. background: rgba(0, 0, 0, 0.78);
  354. color: #fff;
  355. padding: 5px 10px;
  356. border-radius: 4px;
  357. font-size: 13px;
  358. font-weight: bold;
  359. pointer-events: none;
  360. white-space: nowrap;
  361. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
  362. }
  363. </style>