|
@@ -0,0 +1,1102 @@
|
|
|
+<template>
|
|
|
+ <!-- 台风图例面板 -->
|
|
|
+ <ul class="legend" v-if="visible">
|
|
|
+ <li>
|
|
|
+ <span class="dot green"></span>
|
|
|
+ <span>热带低压</span>
|
|
|
+ </li>
|
|
|
+ <li>
|
|
|
+ <span class="dot blue"></span>
|
|
|
+ <span>热带风暴</span>
|
|
|
+ </li>
|
|
|
+ <li>
|
|
|
+ <span class="dot yellow"></span>
|
|
|
+ <span>强热带风暴</span>
|
|
|
+ </li>
|
|
|
+ <li>
|
|
|
+ <span class="dot orange"></span>
|
|
|
+ <span>台风</span>
|
|
|
+ </li>
|
|
|
+ <li>
|
|
|
+ <span class="dot red"></span>
|
|
|
+ <span>超强台风</span>
|
|
|
+ </li>
|
|
|
+ </ul>
|
|
|
+
|
|
|
+ <!-- 台风路径点信息弹窗 -->
|
|
|
+ <div v-if="infoVisible && visible" class="typhoon-popup" :style="{
|
|
|
+ left: `${popupPosition.x}px`,
|
|
|
+ top: `${popupPosition.y}px`
|
|
|
+ }">
|
|
|
+ <div class="popup-content" :class="{ 'bottom': popupPosition.bottom }">
|
|
|
+ <h3>{{ info.name }}({{ info.enname }})</h3>
|
|
|
+ <p>{{ info.time }}</p>
|
|
|
+ <p><strong>中心位置:</strong> {{ info.lng }}° / {{ info.lat }}°</p>
|
|
|
+ <p><strong>风速风力:</strong> {{ info.speed }} m/s ({{ info.power }}级)</p>
|
|
|
+ <p><strong>中心气压:</strong> {{ info.pressure }} hPa</p>
|
|
|
+ <p><strong>移速移向:</strong> {{ info.movedirection }} {{ info.movespeed }} km/h</p>
|
|
|
+ <p><strong>七级半径:</strong> {{ info.radius7 || '--' }}</p>
|
|
|
+ <p><strong>十级半径:</strong> {{ info.radius10 || '--' }}</p>
|
|
|
+ <p><strong>十二级半径:</strong> {{ info.radius12 || '--' }}</p>
|
|
|
+ <div class="popup-arrow"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
|
|
|
+import * as Cesium from 'cesium';
|
|
|
+import axios from 'axios'
|
|
|
+
|
|
|
+// 接收父组件传递的Cesium viewer实例和显示状态
|
|
|
+const props = defineProps({
|
|
|
+ viewer: {
|
|
|
+ type: Object,
|
|
|
+ required: false, // 不再强制要求
|
|
|
+ default: null // 默认值为null
|
|
|
+ },
|
|
|
+ visible: {
|
|
|
+ type: Boolean,
|
|
|
+ default: true
|
|
|
+ },
|
|
|
+ dataUrl: {
|
|
|
+ type: String,
|
|
|
+ default: '../assets/Data/202508.json'
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 暴露事件
|
|
|
+const emits = defineEmits(['onToggle']);
|
|
|
+
|
|
|
+// 台风相关状态
|
|
|
+const infoVisible = ref(false);
|
|
|
+const info = ref({});
|
|
|
+const popupPosition = ref({ x: 0, y: 0, bottom: false });
|
|
|
+const pointDataMap = ref(new Map());
|
|
|
+const currentEntity = ref(null);
|
|
|
+const entityCollection = ref(null);
|
|
|
+const tbentity = ref(null);
|
|
|
+const iii = ref(0);
|
|
|
+const currentPointObj = ref(null);
|
|
|
+let typhoonInterval = null;
|
|
|
+const fengquanLayers = ref([]);
|
|
|
+const relatedEntities = ref({
|
|
|
+ paths: [],
|
|
|
+ forecasts: [],
|
|
|
+ warnings: []
|
|
|
+});
|
|
|
+const popupUpdateCallback = ref(null);
|
|
|
+
|
|
|
+// 获取Cesium容器元素
|
|
|
+const getCesiumContainer = () => {
|
|
|
+ return document.getElementById('cesiumContainer');
|
|
|
+};
|
|
|
+
|
|
|
+// 获取Cesium容器的位置信息
|
|
|
+const getCesiumContainerRect = () => {
|
|
|
+ const container = getCesiumContainer();
|
|
|
+ return container ? container.getBoundingClientRect() : { left: 0, top: 0, width: 0, height: 0 };
|
|
|
+};
|
|
|
+
|
|
|
+// 更新台风弹窗位置
|
|
|
+const updatePopupPosition = (entity) => {
|
|
|
+ if (popupUpdateCallback.value) {
|
|
|
+ props.viewer.scene.postRender.removeEventListener(popupUpdateCallback.value);
|
|
|
+ popupUpdateCallback.value = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!entity || !entity.position || !infoVisible.value) return;
|
|
|
+
|
|
|
+ popupUpdateCallback.value = () => {
|
|
|
+ const container = getCesiumContainer();
|
|
|
+ const containerRect = container.getBoundingClientRect();
|
|
|
+ const entityPosition = props.viewer.scene.cartesianToCanvasCoordinates(
|
|
|
+ entity.position.getValue(props.viewer.clock.currentTime)
|
|
|
+ );
|
|
|
+
|
|
|
+ if (entityPosition) {
|
|
|
+ nextTick(() => {
|
|
|
+ const popupEl = document.querySelector('.typhoon-popup .popup-content');
|
|
|
+ if (!popupEl) return;
|
|
|
+
|
|
|
+ // 获取弹窗尺寸和样式信息
|
|
|
+ const popupRect = popupEl.getBoundingClientRect();
|
|
|
+ const popupWidth = popupRect.width;
|
|
|
+ const popupHeight = popupRect.height;
|
|
|
+ const arrowHeight = 10; // 箭头高度
|
|
|
+
|
|
|
+ // 获取台风点大小
|
|
|
+ const pointSize = entity.point?.pixelSize || getDynamicPointSize();
|
|
|
+ const pointRadius = pointSize / 2;
|
|
|
+
|
|
|
+ // 计算弹窗水平位置
|
|
|
+ const offsetX = -15; // 向左微调
|
|
|
+ let x = (entityPosition.x - containerRect.left) - (popupWidth / 2) + offsetX;
|
|
|
+
|
|
|
+ // 计算弹窗垂直位置
|
|
|
+ let y, bottom = false;
|
|
|
+ const topSpacing = 35;
|
|
|
+ y = (entityPosition.y - containerRect.top) - popupHeight - arrowHeight - pointRadius - topSpacing;
|
|
|
+
|
|
|
+ // 如果上方空间不足,显示在点的下方
|
|
|
+ if (y < 0) {
|
|
|
+ y = (entityPosition.y - containerRect.top) + pointRadius + arrowHeight;
|
|
|
+ bottom = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 边界检查
|
|
|
+ if (x < 0) x = 0;
|
|
|
+ if (x + popupWidth > containerRect.width) {
|
|
|
+ x = containerRect.width - popupWidth;
|
|
|
+ }
|
|
|
+
|
|
|
+ popupPosition.value = { x, y, bottom };
|
|
|
+ });
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ props.viewer.scene.postRender.addEventListener(popupUpdateCallback.value);
|
|
|
+ popupUpdateCallback.value();
|
|
|
+};
|
|
|
+
|
|
|
+// 隐藏台风弹窗
|
|
|
+const hidePopup = () => {
|
|
|
+ infoVisible.value = false;
|
|
|
+ if (popupUpdateCallback.value) {
|
|
|
+ props.viewer.scene.postRender.removeEventListener(popupUpdateCallback.value);
|
|
|
+ popupUpdateCallback.value = null;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 获取缩放比例
|
|
|
+const getScaleRatio = () => {
|
|
|
+ const designWidth = 1920;
|
|
|
+ const designHeight = 1080;
|
|
|
+ return Math.min(window.innerWidth / designWidth, window.innerHeight / designHeight);
|
|
|
+};
|
|
|
+
|
|
|
+// 获取像素比例
|
|
|
+const getPixelRatio = () => {
|
|
|
+ return window.devicePixelRatio || 1;
|
|
|
+};
|
|
|
+
|
|
|
+// 获取动态点大小
|
|
|
+const getDynamicPointSize = () => {
|
|
|
+ const baseSize = 8;
|
|
|
+ const scaleRatio = getScaleRatio();
|
|
|
+ const pixelRatio = getPixelRatio();
|
|
|
+ return Math.max(baseSize * scaleRatio * pixelRatio, 6);
|
|
|
+};
|
|
|
+
|
|
|
+// 获取动态拾取半径
|
|
|
+const getDynamicPickRadius = () => {
|
|
|
+ const baseRadius = 10;
|
|
|
+ const scaleRatio = getScaleRatio();
|
|
|
+ return baseRadius * (1 / scaleRatio);
|
|
|
+};
|
|
|
+
|
|
|
+// 检查点是否在点击范围内
|
|
|
+const isPointInClickRange = (clickPosition, pointPosition, radius) => {
|
|
|
+ const dx = clickPosition.x - pointPosition.x;
|
|
|
+ const dy = clickPosition.y - pointPosition.y;
|
|
|
+ return Math.sqrt(dx * dx + dy * dy) <= radius;
|
|
|
+};
|
|
|
+
|
|
|
+// 检查台风点点击
|
|
|
+const checkPointClick = (clickPosition) => {
|
|
|
+ if (!entityCollection.value || !entityCollection.value.entities) return null;
|
|
|
+
|
|
|
+ const radius = getDynamicPickRadius();
|
|
|
+ const pixelRatio = getPixelRatio();
|
|
|
+ const scaledClickPos = {
|
|
|
+ x: clickPosition.x * pixelRatio,
|
|
|
+ y: clickPosition.y * pixelRatio
|
|
|
+ };
|
|
|
+
|
|
|
+ const entities = entityCollection.value.entities.values;
|
|
|
+ for (let i = 0; i < entities.length; i++) {
|
|
|
+ const entity = entities[i];
|
|
|
+ if (entity.id && entity.id.startsWith('typhoon-point-') && entity.position) {
|
|
|
+ const pointPosition = props.viewer.scene.cartesianToCanvasCoordinates(entity.position._value);
|
|
|
+ if (pointPosition && isPointInClickRange(scaledClickPos, pointPosition, radius)) {
|
|
|
+ return entity;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return null;
|
|
|
+};
|
|
|
+
|
|
|
+// 更新台风点大小
|
|
|
+const updatePointSizes = () => {
|
|
|
+ if (!entityCollection.value || !entityCollection.value.entities) return;
|
|
|
+
|
|
|
+ const newSize = getDynamicPointSize();
|
|
|
+ const entities = entityCollection.value.entities.values;
|
|
|
+
|
|
|
+ for (let i = 0; i < entities.length; i++) {
|
|
|
+ const entity = entities[i];
|
|
|
+ if (entity.id && entity.id.startsWith('typhoon-point-') && entity.point) {
|
|
|
+ entity.point.pixelSize = newSize;
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 处理台风数据
|
|
|
+const processPoints = (points, typhoonData) => {
|
|
|
+ const lineArr = [];
|
|
|
+ const pointSize = getDynamicPointSize();
|
|
|
+
|
|
|
+ // 清除现有台风点
|
|
|
+ if (entityCollection.value && entityCollection.value.entities) {
|
|
|
+ entityCollection.value.entities.removeAll();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重新添加所有台风点
|
|
|
+ points.forEach((element, index) => {
|
|
|
+ const lng = Number(element.lng);
|
|
|
+ const lat = Number(element.lat);
|
|
|
+
|
|
|
+ let color = Cesium.Color.RED;
|
|
|
+ switch (element.strong) {
|
|
|
+ case "热带低压":
|
|
|
+ color = Cesium.Color.GREEN;
|
|
|
+ break;
|
|
|
+ case "热带风暴":
|
|
|
+ color = Cesium.Color.BLUE;
|
|
|
+ break;
|
|
|
+ case "强热带风暴":
|
|
|
+ color = Cesium.Color.YELLOW;
|
|
|
+ break;
|
|
|
+ case "台风":
|
|
|
+ color = Cesium.Color.fromCssColorString("#FBC712");
|
|
|
+ break;
|
|
|
+ case "强台风":
|
|
|
+ color = Cesium.Color.PLUM;
|
|
|
+ break;
|
|
|
+ case "超强台风":
|
|
|
+ color = Cesium.Color.RED;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ lineArr.push(lng, lat);
|
|
|
+ const pointId = `typhoon-point-${index}`;
|
|
|
+ const entity = new Cesium.Entity({
|
|
|
+ id: pointId,
|
|
|
+ position: Cesium.Cartesian3.fromDegrees(lng, lat),
|
|
|
+ point: {
|
|
|
+ pixelSize: pointSize,
|
|
|
+ color: color,
|
|
|
+ outlineColor: Cesium.Color.WHITE,
|
|
|
+ outlineWidth: 2,
|
|
|
+ heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+ entityCollection.value.entities.add(entity);
|
|
|
+
|
|
|
+ pointDataMap.value.set(pointId, {
|
|
|
+ name: typhoonData.name || '',
|
|
|
+ enname: typhoonData.enname || '',
|
|
|
+ time: element.time || '',
|
|
|
+ lng: element.lng,
|
|
|
+ lat: element.lat,
|
|
|
+ speed: element.speed,
|
|
|
+ power: element.power,
|
|
|
+ pressure: element.pressure,
|
|
|
+ movedirection: element.movedirection,
|
|
|
+ movespeed: element.movespeed,
|
|
|
+ radius7: element.radius7 || '--',
|
|
|
+ radius10: element.radius10 || '--',
|
|
|
+ radius12: element.radius12 || '--'
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 清除现有路径
|
|
|
+ relatedEntities.value.paths.forEach(entity => {
|
|
|
+ props.viewer.entities.remove(entity);
|
|
|
+ });
|
|
|
+ relatedEntities.value.paths = [];
|
|
|
+
|
|
|
+ // 创建新路径
|
|
|
+ if (lineArr.length >= 4) {
|
|
|
+ const pathEntity = props.viewer.entities.add({
|
|
|
+ polyline: {
|
|
|
+ positions: Cesium.Cartesian3.fromDegreesArray(lineArr),
|
|
|
+ width: 3,
|
|
|
+ clampToGround: true,
|
|
|
+ material: Cesium.Color.RED,
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+ relatedEntities.value.paths.push(pathEntity);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (points.length > 0) {
|
|
|
+ // 清除现有预报
|
|
|
+ relatedEntities.value.forecasts.forEach(entity => {
|
|
|
+ props.viewer.entities.remove(entity);
|
|
|
+ });
|
|
|
+ relatedEntities.value.forecasts = [];
|
|
|
+
|
|
|
+ initForeast(points[points.length - 1]);
|
|
|
+
|
|
|
+ // 初始化动画
|
|
|
+ iii.value = 0;
|
|
|
+ removes();
|
|
|
+ adds(points, typhoonData);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化预报
|
|
|
+const initForeast = (data) => {
|
|
|
+ const forecast = data.forecast || [];
|
|
|
+ const colorArr = [
|
|
|
+ Cesium.Color.fromCssColorString("#2D12FB"),
|
|
|
+ Cesium.Color.fromCssColorString("#15E5E7"),
|
|
|
+ Cesium.Color.fromCssColorString("#15E74A"),
|
|
|
+ Cesium.Color.fromCssColorString("#E76F15"),
|
|
|
+ Cesium.Color.fromCssColorString("#15D9E7")
|
|
|
+ ];
|
|
|
+
|
|
|
+ forecast.forEach((ele, ii) => {
|
|
|
+ if (ele.forecastpoints && ele.forecastpoints.length > 0) {
|
|
|
+ const lineArr = [];
|
|
|
+ ele.forecastpoints.forEach(e => {
|
|
|
+ const lng = Number(e.lng);
|
|
|
+ const lat = Number(e.lat);
|
|
|
+ lineArr.push(lng, lat);
|
|
|
+
|
|
|
+ const entity = new Cesium.Entity({
|
|
|
+ position: Cesium.Cartesian3.fromDegrees(lng, lat),
|
|
|
+ point: {
|
|
|
+ pixelSize: getDynamicPointSize() * 0.8,
|
|
|
+ color: colorArr[ii % colorArr.length]
|
|
|
+ }
|
|
|
+ });
|
|
|
+ entityCollection.value.entities.add(entity);
|
|
|
+ });
|
|
|
+
|
|
|
+ const forecastEntity = props.viewer.entities.add({
|
|
|
+ polyline: {
|
|
|
+ positions: Cesium.Cartesian3.fromDegreesArray(lineArr),
|
|
|
+ width: 2,
|
|
|
+ clampToGround: true,
|
|
|
+ material: new Cesium.PolylineDashMaterialProperty({
|
|
|
+ color: colorArr[ii % colorArr.length]
|
|
|
+ })
|
|
|
+ }
|
|
|
+ });
|
|
|
+ relatedEntities.value.forecasts.push(forecastEntity);
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化警戒线
|
|
|
+const initWarningLines = () => {
|
|
|
+ // 清除现有警戒线
|
|
|
+ relatedEntities.value.warnings.forEach(entity => {
|
|
|
+ props.viewer.entities.remove(entity);
|
|
|
+ });
|
|
|
+ relatedEntities.value.warnings = [];
|
|
|
+
|
|
|
+ // 24小时警戒线
|
|
|
+ const line24h = props.viewer.entities.add({
|
|
|
+ name: '24小时警戒线',
|
|
|
+ polyline: {
|
|
|
+ positions: Cesium.Cartesian3.fromDegreesArray([
|
|
|
+ 127, 34, 127, 22, 119, 18, 119, 11, 113, 4.5, 105, 0
|
|
|
+ ]),
|
|
|
+ width: 2,
|
|
|
+ material: Cesium.Color.RED,
|
|
|
+ clampToGround: true,
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+ relatedEntities.value.warnings.push(line24h);
|
|
|
+
|
|
|
+ // 48小时警戒线
|
|
|
+ const line48h = props.viewer.entities.add({
|
|
|
+ name: '48小时警戒线',
|
|
|
+ polyline: {
|
|
|
+ positions: Cesium.Cartesian3.fromDegreesArray([
|
|
|
+ 132, 34, 132, 22, 119, 0, 105, 0
|
|
|
+ ]),
|
|
|
+ width: 2,
|
|
|
+ material: Cesium.Color.YELLOW,
|
|
|
+ clampToGround: true,
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+ relatedEntities.value.warnings.push(line48h);
|
|
|
+
|
|
|
+ // 警戒线标签
|
|
|
+ const label24h = props.viewer.entities.add({
|
|
|
+ position: Cesium.Cartesian3.fromDegrees(126.129019, 29.104287),
|
|
|
+ label: {
|
|
|
+ fillColor: Cesium.Color.RED,
|
|
|
+ text: '24小时警戒线',
|
|
|
+ font: '14pt monospace',
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+ relatedEntities.value.warnings.push(label24h);
|
|
|
+
|
|
|
+ const label48h = props.viewer.entities.add({
|
|
|
+ position: Cesium.Cartesian3.fromDegrees(132, 20),
|
|
|
+ label: {
|
|
|
+ fillColor: Cesium.Color.YELLOW,
|
|
|
+ text: '48小时警戒线',
|
|
|
+ font: '14pt monospace',
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+ relatedEntities.value.warnings.push(label48h);
|
|
|
+};
|
|
|
+
|
|
|
+// 移除台风相关元素
|
|
|
+const removes = () => {
|
|
|
+ // 移除风圈
|
|
|
+ fengquanLayers.value.forEach(entity => {
|
|
|
+ props.viewer.entities.remove(entity);
|
|
|
+ });
|
|
|
+ fengquanLayers.value = [];
|
|
|
+
|
|
|
+ // 移除风眼图标
|
|
|
+ if (tbentity.value) {
|
|
|
+ props.viewer.entities.remove(tbentity.value);
|
|
|
+ tbentity.value = null;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 添加台风动画
|
|
|
+const adds = (data, typhoonData) => {
|
|
|
+ addEye(typhoonData);
|
|
|
+
|
|
|
+ // 清除可能存在的旧定时器
|
|
|
+ if (typhoonInterval) {
|
|
|
+ clearInterval(typhoonInterval);
|
|
|
+ typhoonInterval = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重置计数器
|
|
|
+ iii.value = 0;
|
|
|
+
|
|
|
+ // 创建新的定时器,只播放一次
|
|
|
+ typhoonInterval = setInterval(() => {
|
|
|
+ if (!props.visible) return;
|
|
|
+
|
|
|
+ // 当计数器达到数据长度时,清除定时器
|
|
|
+ if (iii.value >= data.length) {
|
|
|
+ clearInterval(typhoonInterval);
|
|
|
+ typhoonInterval = null;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const kkk = iii.value * 2;
|
|
|
+ const currentData = data[iii.value];
|
|
|
+ currentPointObj.value = {
|
|
|
+ lon: Number(currentData.lng),
|
|
|
+ lat: Number(currentData.lat),
|
|
|
+ circle7: {
|
|
|
+ radius1: 350 - kkk,
|
|
|
+ radius2: 450 - kkk,
|
|
|
+ radius3: 400 - kkk,
|
|
|
+ radius4: 350 - kkk
|
|
|
+ },
|
|
|
+ circle10: {
|
|
|
+ radius1: 250 - kkk,
|
|
|
+ radius2: 270 - kkk,
|
|
|
+ radius3: 250 - kkk,
|
|
|
+ radius4: 220 - kkk
|
|
|
+ },
|
|
|
+ circle12: {
|
|
|
+ radius1: 170 - kkk,
|
|
|
+ radius2: 150 - kkk,
|
|
|
+ radius3: 150 - kkk,
|
|
|
+ radius4: 170 - kkk
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ if (tbentity.value) {
|
|
|
+ tbentity.value.position = Cesium.Cartesian3.fromDegrees(
|
|
|
+ Number(currentData.lng),
|
|
|
+ Number(currentData.lat)
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 继续下一个帧
|
|
|
+ iii.value++;
|
|
|
+ removeTFLayer();
|
|
|
+ if (props.visible) {
|
|
|
+ addTyphoonCircle();
|
|
|
+ }
|
|
|
+ }, 200);
|
|
|
+};
|
|
|
+
|
|
|
+// 添加台风眼
|
|
|
+const addEye = (typhoonData) => {
|
|
|
+ const SuperGif = window.SuperGif;
|
|
|
+ if (typeof SuperGif !== 'function') {
|
|
|
+ console.warn('SuperGif未加载成功,使用默认标记');
|
|
|
+ useDefaultMarker(typhoonData);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const img = document.createElement('img');
|
|
|
+ img.src = '/tf.gif';
|
|
|
+
|
|
|
+ img.onload = () => {
|
|
|
+ try {
|
|
|
+ const rub = new SuperGif({ gif: img });
|
|
|
+ rub.load(() => {
|
|
|
+ tbentity.value = props.viewer.entities.add({
|
|
|
+ position: Cesium.Cartesian3.fromDegrees(
|
|
|
+ Number(typhoonData.centerlng || 123.75),
|
|
|
+ Number(typhoonData.centerlat || 28.95)
|
|
|
+ ),
|
|
|
+ billboard: {
|
|
|
+ image: new Cesium.CallbackProperty(() => {
|
|
|
+ return rub.get_canvas().toDataURL('image/png');
|
|
|
+ }, false),
|
|
|
+ scale: 0.1 * getScaleRatio(),
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ console.error('GIF处理失败,使用默认标记', error);
|
|
|
+ useDefaultMarker(typhoonData);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ img.onerror = () => {
|
|
|
+ console.error('GIF加载失败,使用默认标记');
|
|
|
+ useDefaultMarker(typhoonData);
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+// 使用默认标记
|
|
|
+const useDefaultMarker = (typhoonData) => {
|
|
|
+ tbentity.value = props.viewer.entities.add({
|
|
|
+ position: Cesium.Cartesian3.fromDegrees(
|
|
|
+ Number(typhoonData.centerlng || 123.75),
|
|
|
+ Number(typhoonData.centerlat || 28.95)
|
|
|
+ ),
|
|
|
+ point: {
|
|
|
+ pixelSize: getDynamicPointSize() * 1.5,
|
|
|
+ color: Cesium.Color.RED,
|
|
|
+ outlineColor: Cesium.Color.WHITE,
|
|
|
+ outlineWidth: 2,
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 移除台风风圈图层
|
|
|
+const removeTFLayer = () => {
|
|
|
+ fengquanLayers.value.forEach(entity => {
|
|
|
+ props.viewer.entities.remove(entity);
|
|
|
+ });
|
|
|
+ fengquanLayers.value = [];
|
|
|
+};
|
|
|
+
|
|
|
+// 添加台风风圈
|
|
|
+const addTyphoonCircle = () => {
|
|
|
+ if (!currentPointObj.value || !props.visible) return;
|
|
|
+
|
|
|
+ const circles = ['circle7', 'circle10', 'circle12'];
|
|
|
+ circles.forEach(item => {
|
|
|
+ const entity = props.viewer.entities.add({
|
|
|
+ id: `tf_polygon_${item}`,
|
|
|
+ polygon: {
|
|
|
+ hierarchy: new Cesium.CallbackProperty(() => {
|
|
|
+ const points = currentPointObj.value[item]
|
|
|
+ ? getTyphoonPolygonPoints(currentPointObj.value, item)
|
|
|
+ : [];
|
|
|
+ return new Cesium.PolygonHierarchy(
|
|
|
+ Cesium.Cartesian3.fromDegreesArray(points)
|
|
|
+ );
|
|
|
+ }, false),
|
|
|
+ material: Cesium.Color.ORANGE.withAlpha(0.05),
|
|
|
+ extrudedHeight: 1000,
|
|
|
+ outline: true,
|
|
|
+ outlineColor: Cesium.Color.ORANGE,
|
|
|
+ outlineWidth: 2,
|
|
|
+ show: props.visible
|
|
|
+ },
|
|
|
+ polyline: {
|
|
|
+ positions: new Cesium.CallbackProperty(() => {
|
|
|
+ const points = currentPointObj.value[item]
|
|
|
+ ? getTyphoonPolygonPoints(currentPointObj.value, item)
|
|
|
+ : [];
|
|
|
+ return Cesium.Cartesian3.fromDegreesArray(points);
|
|
|
+ }, false),
|
|
|
+ material: Cesium.Color.ORANGE,
|
|
|
+ width: 2,
|
|
|
+ height: 1000,
|
|
|
+ show: props.visible
|
|
|
+ }
|
|
|
+ });
|
|
|
+ fengquanLayers.value.push(entity);
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 获取台风风圈多边形点
|
|
|
+const getTyphoonPolygonPoints = (pointObj, cNum) => {
|
|
|
+ const points = [];
|
|
|
+ const center = [Number(pointObj.lon), Number(pointObj.lat)];
|
|
|
+ const radiusList = [
|
|
|
+ pointObj[cNum].radius1,
|
|
|
+ pointObj[cNum].radius2,
|
|
|
+ pointObj[cNum].radius3,
|
|
|
+ pointObj[cNum].radius4
|
|
|
+ ];
|
|
|
+ const startAngleList = [0, 90, 180, 270];
|
|
|
+ let fx, fy;
|
|
|
+
|
|
|
+ startAngleList.forEach((startAngle, index) => {
|
|
|
+ const radius = radiusList[index] / 100;
|
|
|
+ const pointNum = 90;
|
|
|
+ const endAngle = startAngle + 90;
|
|
|
+
|
|
|
+ for (let i = 0; i <= pointNum; i++) {
|
|
|
+ const angle = startAngle + ((endAngle - startAngle) * i) / pointNum;
|
|
|
+ const sin = Math.sin((angle * Math.PI) / 180);
|
|
|
+ const cos = Math.cos((angle * Math.PI) / 180);
|
|
|
+ const x = center[0] + radius * sin;
|
|
|
+ const y = center[1] + radius * cos;
|
|
|
+ points.push(x, y);
|
|
|
+
|
|
|
+ if (startAngle === 0 && i === 0) {
|
|
|
+ fx = x;
|
|
|
+ fy = y;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ points.push(fx, fy);
|
|
|
+ return points;
|
|
|
+};
|
|
|
+
|
|
|
+// 加载台风数据
|
|
|
+const loadTyphoonData = () => {
|
|
|
+ const jsonUrl = new URL(props.dataUrl, import.meta.url).href;
|
|
|
+
|
|
|
+ axios.get(jsonUrl)
|
|
|
+ .then(response => {
|
|
|
+ const typhoonData = response.data;
|
|
|
+ processPoints(typhoonData.points, typhoonData);
|
|
|
+ updatePointSizes();
|
|
|
+ })
|
|
|
+ .catch(error => {
|
|
|
+ console.error('加载台风JSON数据失败,使用测试数据', error);
|
|
|
+ const testTyphoonData = {
|
|
|
+ "name": "竹节草",
|
|
|
+ "enname": "CO-MAY",
|
|
|
+ "points": [
|
|
|
+ {
|
|
|
+ "time": "2025-07-23 14:00:00",
|
|
|
+ "lng": "119.20",
|
|
|
+ "lat": "18.40",
|
|
|
+ "strong": "热带低压",
|
|
|
+ "power": "7",
|
|
|
+ "speed": "15",
|
|
|
+ "pressure": "998",
|
|
|
+ "movespeed": "14",
|
|
|
+ "movedirection": "南西",
|
|
|
+ "radius7": "180|120|200|150",
|
|
|
+ "radius10": "",
|
|
|
+ "radius12": "",
|
|
|
+ "forecast": [
|
|
|
+ {
|
|
|
+ "tm": "中国",
|
|
|
+ "forecastpoints": [
|
|
|
+ { "lng": 121, "lat": 19 },
|
|
|
+ { "lng": 122, "lat": 18 }
|
|
|
+ ]
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ },
|
|
|
+ {
|
|
|
+ "time": "2025-07-23 17:00:00",
|
|
|
+ "lng": "119.10",
|
|
|
+ "lat": "18.00",
|
|
|
+ "strong": "热带低压",
|
|
|
+ "power": "7",
|
|
|
+ "speed": "15",
|
|
|
+ "pressure": "996",
|
|
|
+ "movespeed": "13",
|
|
|
+ "movedirection": "南南西",
|
|
|
+ "radius7": "180|120|200|150",
|
|
|
+ "radius10": "",
|
|
|
+ "radius12": "",
|
|
|
+ "forecast": []
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ processPoints(testTyphoonData.points, testTyphoonData);
|
|
|
+ updatePointSizes();
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化台风可视化
|
|
|
+const initVisualization = () => {
|
|
|
+ // 初始化实体集合
|
|
|
+ if (entityCollection.value) {
|
|
|
+ props.viewer.dataSources.remove(entityCollection.value);
|
|
|
+ }
|
|
|
+ entityCollection.value = new Cesium.CustomDataSource("typhoonPoints");
|
|
|
+ props.viewer.dataSources.add(entityCollection.value);
|
|
|
+
|
|
|
+ // 初始化警戒线
|
|
|
+ initWarningLines();
|
|
|
+
|
|
|
+ // 加载台风数据
|
|
|
+ loadTyphoonData();
|
|
|
+};
|
|
|
+
|
|
|
+// 监听visible属性变化
|
|
|
+watch(() => props.visible, (newVal, oldVal) => {
|
|
|
+ if (newVal !== oldVal) {
|
|
|
+ // 更新所有实体的可见性
|
|
|
+ if (entityCollection.value) {
|
|
|
+ entityCollection.value.show = newVal;
|
|
|
+ }
|
|
|
+
|
|
|
+ fengquanLayers.value.forEach(entity => {
|
|
|
+ if (entity) {
|
|
|
+ entity.show = newVal;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ relatedEntities.value.paths.forEach(entity => {
|
|
|
+ if (entity) {
|
|
|
+ entity.show = newVal;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ relatedEntities.value.forecasts.forEach(entity => {
|
|
|
+ if (entity) {
|
|
|
+ entity.show = newVal;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ relatedEntities.value.warnings.forEach(entity => {
|
|
|
+ if (entity) {
|
|
|
+ entity.show = newVal;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (tbentity.value) {
|
|
|
+ tbentity.value.show = newVal;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果显示,则重新初始化
|
|
|
+ if (newVal) {
|
|
|
+ initVisualization();
|
|
|
+ } else {
|
|
|
+ // 如果隐藏,清除定时器和弹窗
|
|
|
+ hidePopup();
|
|
|
+ if (typhoonInterval) {
|
|
|
+ clearInterval(typhoonInterval);
|
|
|
+ typhoonInterval = null;
|
|
|
+ }
|
|
|
+ removeTFLayer();
|
|
|
+ }
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 初始化事件监听
|
|
|
+const initEventListeners = () => {
|
|
|
+ // 窗口大小变化事件
|
|
|
+ window.addEventListener('resize', updatePointSizes);
|
|
|
+
|
|
|
+ // 点击事件处理
|
|
|
+ const handler = new Cesium.ScreenSpaceEventHandler(props.viewer.scene.canvas);
|
|
|
+
|
|
|
+ // 左键点击事件
|
|
|
+ handler.setInputAction((click) => {
|
|
|
+ if (!props.visible) return;
|
|
|
+
|
|
|
+ const scaleRatio = getScaleRatio();
|
|
|
+ const correctedX = click.position.x / scaleRatio;
|
|
|
+ const correctedY = click.position.y / scaleRatio;
|
|
|
+
|
|
|
+ // 尝试标准拾取
|
|
|
+ const pickedObject = props.viewer.scene.pick(new Cesium.Cartesian2(correctedX, correctedY));
|
|
|
+ // 尝试自定义范围拾取台风点
|
|
|
+ const typhoonPoint = pickedObject ? null : checkPointClick(click.position);
|
|
|
+ const finalPickedObject = pickedObject || typhoonPoint;
|
|
|
+
|
|
|
+ // 隐藏弹窗
|
|
|
+ hidePopup();
|
|
|
+
|
|
|
+ if (Cesium.defined(finalPickedObject) && Cesium.defined(finalPickedObject.id)) {
|
|
|
+ const entityId = finalPickedObject.id.id;
|
|
|
+ const data = pointDataMap.value.get(entityId);
|
|
|
+
|
|
|
+ if (data && entityId.startsWith('typhoon-point-')) {
|
|
|
+ // 处理台风点点击
|
|
|
+ currentEntity.value = finalPickedObject.id;
|
|
|
+ info.value = data;
|
|
|
+ infoVisible.value = true;
|
|
|
+ updatePopupPosition(finalPickedObject.id);
|
|
|
+
|
|
|
+ props.viewer.flyTo(finalPickedObject.id, {
|
|
|
+ offset: new Cesium.HeadingPitchRange(0, -0.5, 1500)
|
|
|
+ });
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ currentEntity.value = null;
|
|
|
+ }
|
|
|
+ }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
|
|
+
|
|
|
+ // 鼠标移动事件
|
|
|
+ handler.setInputAction((movement) => {
|
|
|
+ if (!props.visible) {
|
|
|
+ hidePopup();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const scaleRatio = getScaleRatio();
|
|
|
+ const correctedX = movement.endPosition.x / scaleRatio;
|
|
|
+ const correctedY = movement.endPosition.y / scaleRatio;
|
|
|
+
|
|
|
+ // 尝试标准拾取
|
|
|
+ const pickedObject = props.viewer.scene.pick(new Cesium.Cartesian2(correctedX, correctedY));
|
|
|
+ // 尝试自定义范围拾取台风点
|
|
|
+ const typhoonPoint = pickedObject ? null : checkPointClick(movement.endPosition);
|
|
|
+ const finalPickedObject = pickedObject || typhoonPoint;
|
|
|
+
|
|
|
+ // 检查是否悬停在台风路径点上
|
|
|
+ if (Cesium.defined(finalPickedObject) && Cesium.defined(finalPickedObject.id)) {
|
|
|
+ const entityId = finalPickedObject.id.id;
|
|
|
+ if (entityId && entityId.startsWith('typhoon-point-')) {
|
|
|
+ const typhoonData = pointDataMap.value.get(entityId);
|
|
|
+ if (typhoonData) {
|
|
|
+ currentEntity.value = finalPickedObject.id;
|
|
|
+ info.value = typhoonData;
|
|
|
+ infoVisible.value = true;
|
|
|
+ updatePopupPosition(finalPickedObject.id);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 不是台风路径点,隐藏弹窗
|
|
|
+ hidePopup();
|
|
|
+ }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
|
|
|
+
|
|
|
+ return handler;
|
|
|
+};
|
|
|
+
|
|
|
+// 组件挂载时初始化
|
|
|
+onMounted(() => {
|
|
|
+ if (props.viewer) {
|
|
|
+ const handler = initEventListeners();
|
|
|
+
|
|
|
+ // 初始加载台风数据
|
|
|
+ if (props.visible) {
|
|
|
+ initVisualization();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 在组件卸载时清理事件处理器
|
|
|
+ onUnmounted(() => {
|
|
|
+ if (handler) {
|
|
|
+ handler.destroy();
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+// 组件卸载时清理
|
|
|
+onUnmounted(() => {
|
|
|
+ // 清除定时器
|
|
|
+ if (typhoonInterval) {
|
|
|
+ clearInterval(typhoonInterval);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 移除弹窗更新回调
|
|
|
+ if (popupUpdateCallback.value) {
|
|
|
+ props.viewer.scene.postRender.removeEventListener(popupUpdateCallback.value);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 移除事件监听
|
|
|
+ window.removeEventListener('resize', updatePointSizes);
|
|
|
+
|
|
|
+ // 清理所有实体
|
|
|
+ if (entityCollection.value) {
|
|
|
+ props.viewer.dataSources.remove(entityCollection.value);
|
|
|
+ }
|
|
|
+
|
|
|
+ relatedEntities.value.paths.forEach(entity => {
|
|
|
+ props.viewer.entities.remove(entity);
|
|
|
+ });
|
|
|
+
|
|
|
+ relatedEntities.value.forecasts.forEach(entity => {
|
|
|
+ props.viewer.entities.remove(entity);
|
|
|
+ });
|
|
|
+
|
|
|
+ relatedEntities.value.warnings.forEach(entity => {
|
|
|
+ props.viewer.entities.remove(entity);
|
|
|
+ });
|
|
|
+
|
|
|
+ removes();
|
|
|
+});
|
|
|
+
|
|
|
+// 暴露切换台风显示状态的方法
|
|
|
+const toggleTyphoon = () => {
|
|
|
+ emits('onToggle', !props.visible);
|
|
|
+};
|
|
|
+
|
|
|
+defineExpose({
|
|
|
+ toggleTyphoon
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+/* 台风图例样式 */
|
|
|
+.legend {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 100;
|
|
|
+ bottom: 30px;
|
|
|
+ right: 10px;
|
|
|
+ color: #fff;
|
|
|
+ background: rgba(0, 0, 0, 0.8);
|
|
|
+ list-style: none;
|
|
|
+ padding: 10px 15px;
|
|
|
+ border-radius: 4px;
|
|
|
+ margin: 0;
|
|
|
+ transition: opacity 0.3s ease, transform 0.3s ease;
|
|
|
+}
|
|
|
+
|
|
|
+.legend li {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ margin: 5px 0;
|
|
|
+}
|
|
|
+
|
|
|
+.dot {
|
|
|
+ border-radius: 50%;
|
|
|
+ width: 10px;
|
|
|
+ height: 10px;
|
|
|
+ display: inline-block;
|
|
|
+ margin-right: 8px;
|
|
|
+}
|
|
|
+
|
|
|
+.green {
|
|
|
+ background: green;
|
|
|
+}
|
|
|
+
|
|
|
+.blue {
|
|
|
+ background: blue;
|
|
|
+}
|
|
|
+
|
|
|
+.yellow {
|
|
|
+ background: yellow;
|
|
|
+}
|
|
|
+
|
|
|
+.orange {
|
|
|
+ background: #FBC712;
|
|
|
+}
|
|
|
+
|
|
|
+.red {
|
|
|
+ background: red;
|
|
|
+}
|
|
|
+
|
|
|
+/* 台风弹窗样式 */
|
|
|
+.typhoon-popup {
|
|
|
+ position: absolute;
|
|
|
+ z-index: 1000;
|
|
|
+ pointer-events: none;
|
|
|
+ top: 0;
|
|
|
+ left: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-content {
|
|
|
+ background-color: white;
|
|
|
+ border-radius: 5px;
|
|
|
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
|
|
+ padding: 10px 15px;
|
|
|
+ width: 240px;
|
|
|
+ pointer-events: all;
|
|
|
+ min-width: 200px;
|
|
|
+ box-sizing: border-box;
|
|
|
+ position: relative;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-arrow {
|
|
|
+ position: absolute;
|
|
|
+ width: 0;
|
|
|
+ height: 0;
|
|
|
+ border-left: 10px solid transparent;
|
|
|
+ border-right: 10px solid transparent;
|
|
|
+ left: 50%;
|
|
|
+ transform: translateX(-50%);
|
|
|
+}
|
|
|
+
|
|
|
+/* 上方弹窗的箭头(指向下方节点) */
|
|
|
+.popup-content:not(.bottom) .popup-arrow {
|
|
|
+ bottom: -10px;
|
|
|
+ border-top: 10px solid white;
|
|
|
+}
|
|
|
+
|
|
|
+/* 下方弹窗的箭头(指向上方节点) */
|
|
|
+.popup-content.bottom .popup-arrow {
|
|
|
+ top: -10px;
|
|
|
+ border-bottom: 10px solid white;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-content h3 {
|
|
|
+ margin-top: 0;
|
|
|
+ margin-bottom: 8px;
|
|
|
+ color: #333;
|
|
|
+ font-size: 16px;
|
|
|
+}
|
|
|
+
|
|
|
+.popup-content p {
|
|
|
+ margin: 5px 0;
|
|
|
+ color: #666;
|
|
|
+ font-size: 14px;
|
|
|
+}
|
|
|
+
|
|
|
+/* 响应式调整 */
|
|
|
+@media (max-width: 1200px) {
|
|
|
+ .legend {
|
|
|
+ padding: 5px 10px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .legend li {
|
|
|
+ font-size: 12px;
|
|
|
+ margin: 3px 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@media (max-width: 768px) {
|
|
|
+ .popup-content {
|
|
|
+ width: 200px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .popup-content h3 {
|
|
|
+ font-size: 14px;
|
|
|
+ }
|
|
|
+
|
|
|
+ .popup-content p {
|
|
|
+ font-size: 12px;
|
|
|
+ margin: 3px 0;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* 高DPI屏幕适配 */
|
|
|
+@media (min-resolution: 192dpi) {
|
|
|
+ .popup-content {
|
|
|
+ transform: scale(0.95);
|
|
|
+ }
|
|
|
+
|
|
|
+ .popup-content.bottom {
|
|
|
+ transform-origin: center bottom;
|
|
|
+ }
|
|
|
+}
|
|
|
+</style>
|