| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399 |
- <template>
- <!-- 降雨时段切换框 -->
- <div v-if="visible" class="rainfall-switcher">
- <div
- v-for="h in hourOptions"
- :key="h"
- class="rainfall-switch-item"
- :class="{ active: currentHours === h, disabled: loading && currentHours !== h }"
- @click="switchHours(h)"
- >
- 未来{{ h }}h降雨
- </div>
- </div>
- <!-- 加载中 -->
- <div v-if="loading" class="rainfall-loading">
- <span class="loading-spinner"></span>
- <span>降雨数据加载中...</span>
- </div>
- <!-- 加载失败 -->
- <div v-if="error" class="rainfall-error">
- <span>降雨加载失败:{{ error }}</span>
- </div>
- <!-- 鼠标悬停降雨等级提示 -->
- <div v-if="hoverInfo.visible" class="rainfall-tooltip" :style="{ left: hoverInfo.x + 'px', top: hoverInfo.y + 'px' }">
- {{ hoverInfo.text }}
- </div>
- </template>
- <script setup>
- import { ref, watch, onMounted, onUnmounted } from 'vue'
- import axios from 'axios'
- import { ElMessage } from 'element-plus'
- import { PROXY_SLT } from '../../config/server_config.js'
- const props = defineProps({
- viewer: { type: Object, default: null },
- visible: { type: Boolean, default: true },
- defaultHours: { type: Number, default: 24 }
- })
- const hourOptions = [24, 48, 72]
- const currentHours = ref(props.defaultHours)
- const loading = ref(false)
- const error = ref('')
- const hoverInfo = ref({ visible: false, x: 0, y: 0, text: '' })
- let imageryLayer = null
- let hitContours = [] // [{ symbol, level, polyLatlng }] 按 symbol 降序,供悬停命中
- let isMounted = false
- let hoverHandler = null
- // 整个降雨图层叠加在底图上的透明度(透出地形)
- const LAYER_ALPHA = 0.8
- // 降雨等级配置:按 symbol(降雨量阈值 mm) 升序。
- // 注意:API 返回的 value 始终为 0,真正的阈值在 symbol 字段。
- const RAIN_LEVELS = [
- { min: 0, name: '小雨', color: '#A6F0A6', range: '0~10mm' },
- { min: 10, name: '中雨', color: '#3AA402', range: '10~25mm' },
- { min: 25, name: '大雨', color: '#71B4E8', range: '25~50mm' },
- { min: 50, name: '暴雨', color: '#0033FF', range: '50~100mm' },
- { min: 100, name: '大暴雨', color: '#C700CC', range: '100~250mm' },
- { min: 250, name: '特大暴雨', color: '#8B0000', range: '≥250mm' }
- ]
- // 由 symbol 取等级(取 min <= symbol 的最高等级)
- const getLevel = (symbol) => {
- let lvl = RAIN_LEVELS[0]
- for (const l of RAIN_LEVELS) {
- if (symbol >= l.min) lvl = l
- }
- return lvl
- }
- const getViewer = () => props.viewer || window.viewer
- // 射线法判断点 [lat, lng] 是否在多边形 [[lat, lng], ...] 内部(用于悬停命中)
- const pointInPolygon = (point, polygon) => {
- const [py, px] = point
- let inside = false
- for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
- const [yi, xi] = polygon[i]
- const [yj, xj] = polygon[j]
- const intersect = ((yi > py) !== (yj > py)) &&
- (px < (xj - xi) * (py - yi) / (yj - yi) + xi)
- if (intersect) inside = !inside
- }
- return inside
- }
- // 清除降雨图层
- const clearLayer = () => {
- const v = getViewer()
- if (v && imageryLayer) {
- try { v.imageryLayers.remove(imageryLayer) } catch {}
- }
- imageryLayer = null
- hitContours = []
- hoverInfo.value.visible = false
- }
- // 绘制降雨等值面:把各等级多边形按“小雨先画、高等级后画”的光栅化方式
- // 画到一张 canvas 上(2D 画板的画家算法天然保证后画覆盖先画,无 z-fighting、无边界缝),
- // 再把这张 canvas 作为单张影像叠加到地形上(imagery 天然贴合地形)。
- // 这样彻底避开了 Cesium 地面分类中重叠多边形的 z-fighting 与孔洞边界问题。
- const drawContours = (contours) => {
- const v = getViewer()
- if (!v) return
- clearLayer()
- // 按 symbol 分组
- const bySymbol = new Map()
- const presentSymbols = new Set()
- const bounds = { west: Infinity, east: -Infinity, south: Infinity, north: -Infinity }
- for (const c of contours) {
- if (!c.latAndLong || c.latAndLong.length < 3) continue
- const symbol = Number(c.symbol ?? 0)
- if (!bySymbol.has(symbol)) bySymbol.set(symbol, [])
- bySymbol.get(symbol).push(c.latAndLong)
- presentSymbols.add(symbol)
- for (const [lat, lng] of c.latAndLong) {
- if (lng < bounds.west) bounds.west = lng
- if (lng > bounds.east) bounds.east = lng
- if (lat < bounds.south) bounds.south = lat
- if (lat > bounds.north) bounds.north = lat
- }
- }
- if (bounds.west === Infinity) return
- // 光栅化 canvas
- const spanLng = bounds.east - bounds.west
- const spanLat = bounds.north - bounds.south
- const maxDim = 2048
- let cw, ch
- if (spanLng >= spanLat) {
- cw = maxDim
- ch = Math.max(1, Math.round(maxDim * spanLat / spanLng))
- } else {
- ch = maxDim
- cw = Math.max(1, Math.round(maxDim * spanLng / spanLat))
- }
- const canvas = document.createElement('canvas')
- canvas.width = cw
- canvas.height = ch
- const ctx = canvas.getContext('2d')
- ctx.clearRect(0, 0, cw, ch)
- const toX = (lng) => (lng - bounds.west) / spanLng * cw
- const toY = (lat) => (bounds.north - lat) / spanLat * ch
- // 升序绘制:小雨(范围最大)先画在最底,大暴雨最后画在最上层 —— 画家算法保证覆盖关系
- const sortedAsc = [...bySymbol.keys()].sort((a, b) => a - b)
- for (const symbol of sortedAsc) {
- const level = getLevel(symbol)
- ctx.fillStyle = level.color // canvas 内不透明,整体透明度由 imageryLayer.alpha 控制
- for (const poly of bySymbol.get(symbol)) {
- ctx.beginPath()
- for (let i = 0; i < poly.length; i++) {
- const [lat, lng] = poly[i]
- const x = toX(lng)
- const y = toY(lat)
- if (i === 0) ctx.moveTo(x, y)
- else ctx.lineTo(x, y)
- }
- ctx.closePath()
- ctx.fill()
- }
- }
- // 作为单张影像叠加到地形上(天然贴合地形,单张影像无重叠/无缝隙)
- try {
- const dataUrl = canvas.toDataURL('image/png')
- const provider = new Cesium.SingleTileImageryProvider({
- url: dataUrl,
- rectangle: Cesium.Rectangle.fromDegrees(bounds.west, bounds.south, bounds.east, bounds.north)
- })
- imageryLayer = v.imageryLayers.addImageryProvider(provider)
- imageryLayer.alpha = LAYER_ALPHA
- } catch (e) {
- console.error('降雨影像叠加失败:', e)
- ElMessage.error('降雨图层绘制失败')
- return
- }
- // 构建悬停命中数据:按 symbol 降序(高等级优先命中,与画面覆盖关系一致)
- hitContours = []
- for (const symbol of [...presentSymbols].sort((a, b) => b - a)) {
- const level = getLevel(symbol)
- for (const poly of bySymbol.get(symbol)) {
- hitContours.push({ symbol, level, polyLatlng: poly })
- }
- }
- }
- // 鼠标悬停拾取降雨等级:用地形交点经纬度,对命中数据从高到低做点-多边形检测
- const setupHover = () => {
- const v = getViewer()
- if (!v || hoverHandler) return
- hoverHandler = new Cesium.ScreenSpaceEventHandler(v.scene.canvas)
- hoverHandler.setInputAction((movement) => {
- if (!hitContours.length) {
- hoverInfo.value.visible = false
- return
- }
- try {
- const ray = v.camera.getPickRay(movement.endPosition)
- let cartesian = ray ? v.scene.globe.pick(ray, v.scene) : undefined
- // 地形未加载时回退到椭球面取点,保证悬停始终可用
- if (!cartesian) {
- cartesian = v.camera.pickEllipsoid(movement.endPosition, Cesium.Ellipsoid.WGS84)
- }
- if (!cartesian) {
- hoverInfo.value.visible = false
- return
- }
- const carto = Cesium.Cartographic.fromCartesian(cartesian)
- const lng = Cesium.Math.toDegrees(carto.longitude)
- const lat = Cesium.Math.toDegrees(carto.latitude)
- let found = null
- for (const hc of hitContours) {
- if (pointInPolygon([lat, lng], hc.polyLatlng)) { found = hc; break }
- }
- if (found) {
- hoverInfo.value = {
- visible: true,
- x: movement.endPosition.x + 14,
- y: movement.endPosition.y + 14,
- text: found.level.name
- }
- } else {
- hoverInfo.value.visible = false
- }
- } catch {
- hoverInfo.value.visible = false
- }
- }, Cesium.ScreenSpaceEventType.MOUSE_MOVE)
- }
- const destroyHover = () => {
- if (hoverHandler) {
- hoverHandler.destroy()
- hoverHandler = null
- }
- hoverInfo.value.visible = false
- }
- // 获取降雨数据
- const fetchRainfall = async (hours) => {
- const v = getViewer()
- if (!v) {
- ElMessage.error('Cesium viewer 未初始化')
- return
- }
- loading.value = true
- error.value = ''
- clearLayer()
- try {
- const url = `${PROXY_SLT}/Api/LeastRain/${hours}`
- const { data } = await axios.get(url)
- let contours = data?.contours
- if (typeof contours === 'string') {
- contours = JSON.parse(contours)
- }
- if (!Array.isArray(contours) || contours.length === 0) {
- ElMessage.info(`未来${hours}h暂无降雨数据`)
- return
- }
- if (!isMounted) return
- drawContours(contours)
- setupHover()
- ElMessage.success(`已加载未来${hours}h降雨`)
- } catch (e) {
- console.error('获取降雨数据失败:', e)
- error.value = e.message || '请求失败'
- ElMessage.error('获取降雨数据失败')
- } finally {
- loading.value = false
- }
- }
- // 切换时段
- const switchHours = (h) => {
- if (currentHours.value === h || loading.value) return
- currentHours.value = h
- fetchRainfall(h)
- }
- // 可见性变化
- watch(() => props.visible, (val) => {
- if (val) {
- fetchRainfall(currentHours.value)
- } else {
- clearLayer()
- destroyHover()
- }
- })
- onMounted(() => {
- isMounted = true
- if (props.visible) {
- fetchRainfall(currentHours.value)
- }
- })
- onUnmounted(() => {
- isMounted = false
- destroyHover()
- clearLayer()
- })
- </script>
- <style scoped>
- .rainfall-switcher {
- position: absolute;
- top: 70px;
- left: 20px;
- z-index: 1000;
- display: flex;
- gap: 4px;
- background: rgba(0, 0, 0, 0.55);
- border-radius: 4px;
- padding: 3px;
- }
- .rainfall-switch-item {
- padding: 4px 10px;
- font-size: 12px;
- font-weight: bold;
- color: #fff;
- cursor: pointer;
- border-radius: 3px;
- user-select: none;
- transition: all 0.2s;
- border: 1px solid transparent;
- white-space: nowrap;
- }
- .rainfall-switch-item:hover {
- background: rgba(255, 255, 255, 0.15);
- }
- .rainfall-switch-item.active {
- background: #409eff;
- border-color: #409eff;
- }
- .rainfall-switch-item.disabled {
- opacity: 0.5;
- cursor: not-allowed;
- }
- .rainfall-loading,
- .rainfall-error {
- position: absolute;
- top: 105px;
- left: 20px;
- z-index: 1000;
- background: rgba(0, 0, 0, 0.6);
- color: #fff;
- padding: 8px 16px;
- border-radius: 6px;
- font-size: 14px;
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .loading-spinner {
- display: inline-block;
- width: 14px;
- height: 14px;
- border: 2px solid rgba(255, 255, 255, 0.3);
- border-top-color: #fff;
- border-radius: 50%;
- animation: rain-spin 0.8s linear infinite;
- }
- @keyframes rain-spin {
- to { transform: rotate(360deg); }
- }
- /* 鼠标悬停提示 */
- .rainfall-tooltip {
- position: absolute;
- z-index: 1001;
- background: rgba(0, 0, 0, 0.78);
- color: #fff;
- padding: 5px 10px;
- border-radius: 4px;
- font-size: 13px;
- font-weight: bold;
- pointer-events: none;
- white-space: nowrap;
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
- }
- </style>
|