diff --git a/.gitignore b/.gitignore index 86f2c24..16908f3 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ dist-ssr auto-imports.d.ts components.d.ts pnpm-lock.yaml +package-lock.json diff --git a/package.json b/package.json index 245027d..267a6ed 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "sockjs-client": "^1.6.1", "spark-md5": "^3.0.2", "vite-plugin-cesium": "^1.2.22", - "vue": "^3.5.32", + "vue": "^3.5.35", "vue-router": "^4.6.3" }, "devDependencies": { @@ -49,4 +49,4 @@ "vite-plugin-vue-devtools": "^8.0.3", "vue-tsc": "^3.2.6" } -} \ No newline at end of file +} diff --git a/src/component/rain-earthquake/function-child/AroundAnalysis.vue b/src/component/rain-earthquake/function-child/AroundAnalysis.vue index d742845..d02ff97 100644 --- a/src/component/rain-earthquake/function-child/AroundAnalysis.vue +++ b/src/component/rain-earthquake/function-child/AroundAnalysis.vue @@ -15,12 +15,20 @@ diff --git a/src/component/rain-earthquake/function-child/around-analysis/AroundAnalysisDetailComponent.vue b/src/component/rain-earthquake/function-child/around-analysis/AroundAnalysisDetailComponent.vue index 3e93f3b..684458f 100644 --- a/src/component/rain-earthquake/function-child/around-analysis/AroundAnalysisDetailComponent.vue +++ b/src/component/rain-earthquake/function-child/around-analysis/AroundAnalysisDetailComponent.vue @@ -1,7 +1,142 @@ - + + + diff --git a/src/component/rain-earthquake/function-child/around-analysis/ButtonComponent.vue b/src/component/rain-earthquake/function-child/around-analysis/ButtonComponent.vue index 3e93f3b..b11c33e 100644 --- a/src/component/rain-earthquake/function-child/around-analysis/ButtonComponent.vue +++ b/src/component/rain-earthquake/function-child/around-analysis/ButtonComponent.vue @@ -1,7 +1,76 @@ - + + + diff --git a/src/component/rain-earthquake/function-child/around-analysis/SearchComponent.vue b/src/component/rain-earthquake/function-child/around-analysis/SearchComponent.vue index 87f517b..0559b96 100644 --- a/src/component/rain-earthquake/function-child/around-analysis/SearchComponent.vue +++ b/src/component/rain-earthquake/function-child/around-analysis/SearchComponent.vue @@ -1,10 +1,105 @@ - - + + + diff --git a/src/hooks/rain-earthquake/useAnalysisButton.ts b/src/hooks/rain-earthquake/useAnalysisButton.ts new file mode 100644 index 0000000..7cc5a10 --- /dev/null +++ b/src/hooks/rain-earthquake/useAnalysisButton.ts @@ -0,0 +1,375 @@ +import { ref, reactive, onUnmounted, watch, computed } from 'vue'; +import { useStatusStore } from '@/stores/useStatusStore'; +import { useLoadingResourceStore } from '@/stores/useLoadingResourceStore'; +import { LoadingResource } from '@/types/common/LoadingResourceType'; +import type { + PointResource, + PointResourceCategory, + ButtonResourceConfig, + AnalysisButtonConfig, + DialogPosition, + AnalysisButtonState +} from '@/types/common/useAroundAnalysisType'; +import { CesiumUtilsSingleton } from '@/utils/cesium/CesiumUtils'; +import { + ScreenSpaceEventHandler, + ScreenSpaceEventType, + Cartesian2, + Cartographic, + Cartesian3, +} from 'cesium'; +import { useCircleDrawer } from './useCircleDrawer'; +import { usePulseEffect } from './usePulseEffect'; +import { useMarkerManager } from './useMarkerManager'; + +// ==================== 常量配置 ==================== +const DIALOG_WIDTH = 280; +const DIALOG_HEIGHT = 150; +const DIALOG_PADDING = 10; +const DIALOG_OFFSET = 20; +const EARTH_RADIUS = 6371000; +const MIN_FLY_HEIGHT = 10000; +const FLY_HEIGHT_MULTIPLIER = 6000; +const FLY_DURATION = 2; + +// ==================== Store 实例 ==================== +const statusStore = useStatusStore(); +const loadingResourceStore = useLoadingResourceStore(); + +const poi = computed(() => statusStore.poiLayers); +const map = computed(() => statusStore.mapLayers); +const infra = computed(() => statusStore.infrastructureLayers); + +// ==================== 资源配置 ==================== +const RESOURCE_CONFIGS: ButtonResourceConfig[] = [ + { key: LoadingResource.SCHOOL, category: 'school' }, + { key: LoadingResource.HOSPITAL, category: 'hospital' }, + { key: LoadingResource.DANGEROUS_SOURCE, category: 'danger' }, + { key: LoadingResource.EMERGENCY_SHELTER, category: 'shelter' }, + { key: LoadingResource.FIRE_STATION, category: 'fire' }, + { key: LoadingResource.STORE_POINTS, category: 'store' }, + { key: LoadingResource.SUBWAY_STATION, category: 'subway' }, + { key: LoadingResource.LANDSLIDE_HIDDEN_POINT, category: 'hidden-danger', forcedType: 'landslide' }, + { key: LoadingResource.DEBRIS_FLOW_HIDDEN_POINT, category: 'hidden-danger', forcedType: 'debris_flow' }, + { key: LoadingResource.WATER_LOGGING_HIDDEN_POINT, category: 'hidden-danger', forcedType: 'water_logging' }, + { key: LoadingResource.FLASH_FLOOD_HIDDEN_POINT, category: 'hidden-danger', forcedType: 'flash_flood' }, + { key: LoadingResource.RISK_POINT, category: 'risk-point' }, + { key: LoadingResource.BRIDGE, category: 'bridge' }, + { key: LoadingResource.RESERVOIR, category: 'reservoir' }, +]; + +// ==================== 图层可见性判断 ==================== +const isCategoryVisible = (category: PointResourceCategory, originalType?: string): boolean => { + const visibilityMap: Record boolean> = { + school: () => poi.value.showSchool.show, + hospital: () => poi.value.showHospital.show, + danger: () => poi.value.showDangerSource.show, + shelter: () => poi.value.showRefugeeShelter.show, + fire: () => poi.value.showFireStation.show, + store: () => poi.value.showReservePoint.show, + subway: () => poi.value.showSubwayStation.show, + 'risk-point': () => map.value.riskPointShow.show, + bridge: () => infra.value.showBridge.show, + reservoir: () => infra.value.showReservoir.show, + 'hidden-danger': () => { + const hiddenMap: Record boolean> = { + landslide: () => poi.value.showLandslideHiddenPoint.show, + debris_flow: () => poi.value.showDebrisFlowHiddenPoint.show, + water_logging: () => poi.value.showWaterLoggingHiddenPoint.show, + flash_flood: () => poi.value.showFlashFloodHiddenPoint.show, + }; + return hiddenMap[originalType || '']?.() ?? false; + }, + }; + return visibilityMap[category]?.() ?? false; +}; + +// ==================== 响应式状态 ==================== +export const useAnalysisButton = (): AnalysisButtonState => { + const selectedButtonIndex = ref(-1); + const showAreaDialog = ref(false); + const radius = ref(10); + const dialogPosition = reactive({ x: 0, y: 0 }); + + let clickHandler: ScreenSpaceEventHandler | null = null; + let currentCenterPosition: Cartesian3 | null = null; + + // ==================== 组合子 Hook ==================== + const { drawCircle, clearCircle } = useCircleDrawer(); + const { addPulseEffectToPoints, removePulseEffect } = usePulseEffect(); + const { addMarker, removeMarker } = useMarkerManager(); + + // ==================== 数据加载与计算 ==================== + + const loadAllPointData = (): PointResource[] => { + const resources: PointResource[] = []; + + RESOURCE_CONFIGS.forEach(config => { + const data = loadingResourceStore.getLoadingResource(config.key).info; + if (Array.isArray(data)) { + const convertedData = data.map((item: Record) => { + const id = item.id || item._id || item.uuid || 'unknown_id'; + const safeId = typeof id === 'string' ? id : typeof id === 'number' ? id : 'unknown_id'; + const value = (item.name && String(item.name).trim() !== '') + ? String(item.name) + : String(safeId); + + return { + ...item, + id: safeId, + value, + category: config.category, + originalType: (config.forcedType || (item.type as string) || (item.disasterType as string))?.toLowerCase() + }; + }); + resources.push(...convertedData); + } + }); + + const seenIds = new Map(); + for (const item of resources) { + if (!seenIds.has(item.id)) { + seenIds.set(item.id, item); + } + } + + const uniqueResources = Array.from(seenIds.values()); + console.log('加载的点数据总数:', uniqueResources.length); + return uniqueResources; + }; + + const calculateDistance = ( + centerLon: number, + centerLat: number, + pointLon: unknown, + pointLat: unknown + ): number => { + const pLon = Number(pointLon); + const pLat = Number(pointLat); + + if (isNaN(pLon) || isNaN(pLat)) return Infinity; + + const dLat = (pLat - centerLat) * Math.PI / 180; + const dLon = (pLon - centerLon) * Math.PI / 180; + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(centerLat * Math.PI / 180) * Math.cos(pLat * Math.PI / 180) * + Math.sin(dLon / 2) ** 2; + return EARTH_RADIUS * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + }; + + const getPointsInCircle = (centerPosition: Cartesian3, radiusKm: number): PointResource[] => { + const cartographic = Cartographic.fromCartesian(centerPosition); + const centerLon = cartographic.longitude * (180 / Math.PI); + const centerLat = cartographic.latitude * (180 / Math.PI); + + const allPoints = loadAllPointData(); + const radiusMeters = radiusKm * 1000; + + return allPoints.filter(point => { + if (point.lon === undefined || point.lat === undefined) return false; + + const distance = calculateDistance(centerLon, centerLat, point.lon, point.lat); + return distance <= radiusMeters && isCategoryVisible(point.category as PointResourceCategory, point.originalType); + }); + }; + + /** + * 刷新脉冲效果 + */ + const refreshPulseEffect = () => { + if (!currentCenterPosition) return; + + console.log('刷新脉冲效果...'); + removePulseEffect(); + + const pointsInCircle = getPointsInCircle(currentCenterPosition, radius.value); + addPulseEffectToPoints(pointsInCircle); + }; + + // ==================== 地图事件处理 ==================== + + const registerMapClickHandler = () => { + const viewer = CesiumUtilsSingleton.getViewer(); + if (!viewer) return; + + clickHandler = new ScreenSpaceEventHandler(viewer.scene.canvas); + clickHandler.setInputAction((clickEvent: { position: Cartesian2 }) => { + const cartesian = viewer.camera.pickEllipsoid(clickEvent.position, viewer.scene.globe.ellipsoid); + if (cartesian) { + currentCenterPosition = cartesian; + const cartographic = Cartographic.fromCartesian(cartesian); + const longitude = cartographic.longitude * (180 / Math.PI); + const latitude = cartographic.latitude * (180 / Math.PI); + + console.log('点击位置:', { longitude, latitude }); + addMarker(cartesian); + showAreaDialog.value = true; + calculateDialogPosition(clickEvent.position); + } + }, ScreenSpaceEventType.LEFT_CLICK); + }; + + const removeMapClickHandler = () => { + if (clickHandler) { + clickHandler.destroy(); + clickHandler = null; + } + }; + + const calculateDialogPosition = (clickPosition: Cartesian2) => { + const screenWidth = window.innerWidth; + const screenHeight = window.innerHeight; + + let x = clickPosition.x + DIALOG_OFFSET; + let y = clickPosition.y + DIALOG_OFFSET; + + if (x + DIALOG_WIDTH > screenWidth - DIALOG_PADDING) { + x = clickPosition.x - DIALOG_WIDTH - DIALOG_OFFSET; + } + + if (y + DIALOG_HEIGHT > screenHeight - DIALOG_PADDING) { + y = clickPosition.y - DIALOG_HEIGHT - DIALOG_OFFSET; + } + + dialogPosition.x = Math.max(DIALOG_PADDING, Math.min(x, screenWidth - DIALOG_WIDTH - DIALOG_PADDING)); + dialogPosition.y = Math.max(DIALOG_PADDING, Math.min(y, screenHeight - DIALOG_HEIGHT - DIALOG_PADDING)); + }; + + // ==================== 资源清理 ==================== + + const clearAllAnalysisResources = () => { + removeMarker(); + clearCircle(); + removePulseEffect(); + currentCenterPosition = null; + }; + + // ==================== 事件处理 ==================== + + const handleConfirm = () => { + if (!currentCenterPosition) { + console.error('中心点位置不存在'); + return; + } + + console.log('确认添加区域分析', { + radius: radius.value, + center: currentCenterPosition + }); + + drawCircle(currentCenterPosition, radius.value); + + const pointsInCircle = getPointsInCircle(currentCenterPosition, radius.value); + addPulseEffectToPoints(pointsInCircle); + + const cartographic = Cartographic.fromCartesian(currentCenterPosition); + const longitude = cartographic.longitude * (180 / Math.PI); + const latitude = cartographic.latitude * (180 / Math.PI); + + const flyHeight = Math.max(radius.value * FLY_HEIGHT_MULTIPLIER, MIN_FLY_HEIGHT); + CesiumUtilsSingleton.flyToTarget([longitude, latitude, flyHeight], FLY_DURATION); + showAreaDialog.value = false; + }; + + const handleCancel = () => { + showAreaDialog.value = false; + clearAllAnalysisResources(); + }; + + const handleButtonClick = (index: number, callback: (status: boolean) => void) => { + const isActive = selectedButtonIndex.value === index; + + if (isActive) { + selectedButtonIndex.value = -1; + callback(false); + } else { + if (selectedButtonIndex.value !== -1) { + clearAllAnalysisResources(); + showAreaDialog.value = false; + } + selectedButtonIndex.value = index; + callback(true); + } + }; + + // ==================== 监听器 ==================== + + watch( + () => loadingResourceStore.loadingResource, + () => { + console.log('检测到资源数据变化,刷新脉冲效果'); + refreshPulseEffect(); + }, + { deep: true } + ); + + const layerVisibilityWatchers = [ + () => poi.value.showSchool.show, + () => poi.value.showHospital.show, + () => poi.value.showDangerSource.show, + () => poi.value.showRefugeeShelter.show, + () => poi.value.showFireStation.show, + () => poi.value.showReservePoint.show, + () => poi.value.showSubwayStation.show, + () => poi.value.showLandslideHiddenPoint.show, + () => poi.value.showDebrisFlowHiddenPoint.show, + () => poi.value.showWaterLoggingHiddenPoint.show, + () => poi.value.showFlashFloodHiddenPoint.show, + () => map.value.riskPointShow.show, + () => infra.value.showBridge.show, + () => infra.value.showReservoir.show, + ]; + + watch(layerVisibilityWatchers, () => { + console.log('检测到图层可见性变化,刷新脉冲效果'); + refreshPulseEffect(); + }); + + onUnmounted(() => { + clearAllAnalysisResources(); + removeMapClickHandler(); + }); + + // ==================== 按钮配置 ==================== + const analysisButtons: AnalysisButtonConfig[] = [ + { + name: '标记区域分析', + activeName: '取消区域分析', + callback: (status: boolean) => { + console.log('标记区域分析', status); + const viewer = CesiumUtilsSingleton.getViewer(); + if (!viewer?.canvas) return; + + statusStore.cursorStyle = status ? 'crosshair' : 'default'; + viewer.canvas.style.cursor = status ? 'crosshair' : 'default'; + + if (status) { + registerMapClickHandler(); + } else { + removeMapClickHandler(); + clearAllAnalysisResources(); + showAreaDialog.value = false; + } + }, + }, + { + name: '隐藏行政区划', + callback: (status: boolean) => { + console.log('隐藏行政区划', status); + useStatusStore().mapLayers.showAdministrativeDivision.show = !status; + }, + }, + ]; + + return { + selectedButtonIndex, + showAreaDialog, + radius, + dialogPosition, + analysisButtons, + handleButtonClick, + handleConfirm, + handleCancel, + refreshPulseEffect, + }; +}; diff --git a/src/hooks/rain-earthquake/useAroundAnalysis.ts b/src/hooks/rain-earthquake/useAroundAnalysis.ts new file mode 100644 index 0000000..8e2cab6 --- /dev/null +++ b/src/hooks/rain-earthquake/useAroundAnalysis.ts @@ -0,0 +1,163 @@ +import { computed, ref } from 'vue'; +import { CesiumUtilsSingleton } from '@/utils/cesium/CesiumUtils'; +import { useStatusStore } from '@/stores/useStatusStore'; +import { useLoadingResourceStore } from '@/stores/useLoadingResourceStore'; +import { LoadingResource } from '@/types/common/LoadingResourceType'; +import type { PointResource, ResourceConfig } from '@/types/common/useAroundAnalysisType'; + +/** + * 周边分析搜索组件钩子函数 + * @returns 搜索相关的状态和方法 + */ +export const useAroundAnalysis = () => { + const statusStore = useStatusStore();//用于访问图层显示状态 + const loadingResourceStore = useLoadingResourceStore();//用于访问各类点位数据 + // 计算属性:获取图层的显示状态 + const poi = computed(() => statusStore.poiLayers); + const map = computed(() => statusStore.mapLayers); + const infra = computed(() => statusStore.infrastructureLayers); + + /** + * 资源配置列表 + */ + const RESOURCE_CONFIGS: ResourceConfig[] = [ + { key: LoadingResource.SCHOOL, category: 'school', isVisible: () => poi.value.showSchool.show }, + { key: LoadingResource.HOSPITAL, category: 'hospital', isVisible: () => poi.value.showHospital.show }, + { key: LoadingResource.DANGEROUS_SOURCE, category: 'danger', isVisible: () => poi.value.showDangerSource.show }, + { key: LoadingResource.EMERGENCY_SHELTER, category: 'shelter', isVisible: () => poi.value.showRefugeeShelter.show }, + { key: LoadingResource.FIRE_STATION, category: 'fire', isVisible: () => poi.value.showFireStation.show }, + { key: LoadingResource.STORE_POINTS, category: 'store', isVisible: () => poi.value.showReservePoint.show }, + { key: LoadingResource.SUBWAY_STATION, category: 'subway', isVisible: () => poi.value.showSubwayStation.show }, + { key: LoadingResource.LANDSLIDE_HIDDEN_POINT, category: 'hidden-danger', forcedType: 'landslide', isVisible: () => poi.value.showLandslideHiddenPoint.show }, + { key: LoadingResource.DEBRIS_FLOW_HIDDEN_POINT, category: 'hidden-danger', forcedType: 'debris_flow', isVisible: () => poi.value.showDebrisFlowHiddenPoint.show }, + { key: LoadingResource.WATER_LOGGING_HIDDEN_POINT, category: 'hidden-danger', forcedType: 'water_logging', isVisible: () => poi.value.showWaterLoggingHiddenPoint.show }, + { key: LoadingResource.FLASH_FLOOD_HIDDEN_POINT, category: 'hidden-danger', forcedType: 'flash_flood', isVisible: () => poi.value.showFlashFloodHiddenPoint.show }, + { key: LoadingResource.RISK_POINT, category: 'risk-point', isVisible: () => map.value.riskPointShow.show }, + { key: LoadingResource.BRIDGE, category: 'bridge', isVisible: () => infra.value.showBridge.show }, + { key: LoadingResource.RESERVOIR, category: 'reservoir', isVisible: () => infra.value.showReservoir.show }, + ]; + + /** + * 所有资源数据 + */ + const allResources = ref([]); + + /** + * 计算属性:判断是否允许搜索 + */ + const canSearch = computed(() => { + return RESOURCE_CONFIGS.some(config => config.isVisible()); + }); + + /** + * 查询建议回调 + * @param queryString - 搜索字符串 + * @param cb - 回调函数 + */ + function querySearch(queryString: string, cb: (results: PointResource[]) => void) { + if (!canSearch.value) { + cb([]); + return; + } + + const lowerQuery = queryString.toLowerCase(); + const filteredResults = allResources.value.filter(item => { + const config = RESOURCE_CONFIGS.find(c => c.category === item.category); + let isVisible = false; + if (config) { + if (item.category === 'hidden-danger') { + const type = (item.originalType as string)?.toLowerCase(); + isVisible = (type === config.forcedType?.toLowerCase()) && config.isVisible(); + } else { + isVisible = config.isVisible(); + } + } + + if (!isVisible) return false; + if (!queryString) return true; + const matchStr = (item.value || '').toLowerCase(); + return matchStr.includes(lowerQuery); + }); + + cb(filteredResults); + } + + /** + * 选择建议回调 + * @param item - 选中的点资源 + */ + function handleSelect(item: PointResource) { + if (item.lon != null && item.lat != null) { + CesiumUtilsSingleton.flyToTarget([item.lon, item.lat, 6000]); + } + } + + /** + * 处理聚焦事件,重新加载数据 + */ + function handleFocus() { + loadAllPointData(); + } + + /** + * 数据处理:将 Store 数据转换为资源格式 + * @param infoList - 原始数据列表 + * @param category - 资源分类 + * @param forcedType - 强制类型 + * @returns 转换后的点资源数组 + */ + function convertStoreDataToResources( + infoList: Record[], + category: PointResource['category'], + forcedType?: string, + ): PointResource[] { + if (!Array.isArray(infoList)) return []; + + return infoList.map((item: Record) => { + const id = item.id || item._id || item.uuid || 'unknown_id'; + const safeId = typeof id === 'string' ? id : typeof id === 'number' ? id : 'unknown_id'; + + const value = (item.name && String(item.name).trim() !== '') + ? String(item.name) + : String(safeId); + + return { + ...item, + id: safeId, + value: value, + category: category, + originalType: (forcedType || (item.type as string) || (item.disasterType as string))?.toLowerCase(), + }; + }); + } + + /** + * 加载所有点类数据 + */ + function loadAllPointData() { + const resources: PointResource[] = []; + + RESOURCE_CONFIGS.forEach(config => { + const data = loadingResourceStore.getLoadingResource(config.key).info; + resources.push(...convertStoreDataToResources(data, config.category, config.forcedType)); + }); + + const seenIds = new Map(); + const uniqueResources: PointResource[] = []; + for (const item of resources) { + if (!seenIds.has(item.id)) { + seenIds.set(item.id, item); + uniqueResources.push(item); + } + } + + allResources.value = uniqueResources; + } + + /** + * 搜索框的值 + */ + const state = ref(''); + + return { state, allResources, canSearch, querySearch, handleSelect, handleFocus, loadAllPointData }; +}; diff --git a/src/hooks/rain-earthquake/useCircleDrawer.ts b/src/hooks/rain-earthquake/useCircleDrawer.ts new file mode 100644 index 0000000..ccc33ab --- /dev/null +++ b/src/hooks/rain-earthquake/useCircleDrawer.ts @@ -0,0 +1,86 @@ +import { Cartesian3, Color, HeightReference } from 'cesium'; +import { CesiumUtilsSingleton } from '@/utils/cesium/CesiumUtils'; +import type { CircleAnalysisOptions } from '@/types/cesium/EntityOptions'; + +/** + * 圆形区域绘制管理 Hook + * @returns 圆形绘制相关方法 + */ +export const useCircleDrawer = () => { + /** + * 绘制圆形区域 + * @param centerPosition - 中心点位置 + * @param radiusKm - 半径(公里) + * @param options - 可选配置 + */ + const drawCircle = ( + centerPosition: Cartesian3, + radiusKm: number, + options?: Partial + ): void => { + const viewer = CesiumUtilsSingleton.getViewer(); + if (!viewer) return; + + const radiusMeters = radiusKm * 1000; + + // 默认配置 + const fillColor = options?.fillColor || Color.RED; + const fillAlpha = options?.fillAlpha ?? 0.1; + const outlineColor = options?.outlineColor || Color.RED; + const outlineAlpha = options?.outlineAlpha ?? 0.9; + const outlineWidth = options?.outlineWidth ?? 3; + const height = options?.height ?? 0; + const heightReference = options?.heightReference ?? HeightReference.CLAMP_TO_GROUND; + + const circleConfig = { + position: centerPosition, + ellipse: { + semiMajorAxis: radiusMeters, + semiMinorAxis: radiusMeters, + height, + heightReference, + }, + }; + + const circleFillEntity = viewer.entities.add({ + ...circleConfig, + ellipse: { + ...circleConfig.ellipse, + material: fillColor.withAlpha(fillAlpha), + }, + }); + + const circleOutlineEntity = viewer.entities.add({ + ...circleConfig, + ellipse: { + ...circleConfig.ellipse, + material: Color.TRANSPARENT, + outline: true, + outlineColor: outlineColor.withAlpha(outlineAlpha), + outlineWidth, + }, + }); + + circleFillEntity ._isAnalysisCircle = true; + circleOutlineEntity._isAnalysisCircle = true; + + console.log(`已添加圆形图层, 半径: ${radiusKm}公里`); + }; + + /** + * 清除所有圆形区域 + */ + const clearCircle = (): void => { + const viewer = CesiumUtilsSingleton.getViewer(); + if (!viewer) return; + + viewer.entities.values + .filter(entity => entity._isAnalysisCircle) + .forEach(entity => viewer.entities.remove(entity)); + }; + + return { + drawCircle, + clearCircle, + }; +}; diff --git a/src/hooks/rain-earthquake/useMarkerManager.ts b/src/hooks/rain-earthquake/useMarkerManager.ts new file mode 100644 index 0000000..cd87581 --- /dev/null +++ b/src/hooks/rain-earthquake/useMarkerManager.ts @@ -0,0 +1,72 @@ +import { Cartesian3, Entity, VerticalOrigin, HorizontalOrigin, HeightReference } from 'cesium'; +import { CesiumUtilsSingleton } from '@/utils/cesium/CesiumUtils'; + +// 十字准心标记 SVG +const MARKER_SVG = ` + + + + + + + +`; + +/** + * 标记管理 Hook + * @returns 标记相关方法 + */ +export const useMarkerManager = () => { + let currentMarkerEntity: Entity | null = null; + + /** + * 添加标记 + * @param position - 标记位置 + */ + const addMarker = (position: Cartesian3): void => { + const viewer = CesiumUtilsSingleton.getViewer(); + if (!viewer) return; + + // 移除旧标记 + if (currentMarkerEntity) { + viewer.entities.remove(currentMarkerEntity); + } + + const markerDataUrl = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(MARKER_SVG)}`; + currentMarkerEntity = viewer.entities.add({ + position, + billboard: { + image: markerDataUrl, + scale: 1.0, + verticalOrigin: VerticalOrigin.CENTER, + horizontalOrigin: HorizontalOrigin.CENTER, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + heightReference: HeightReference.CLAMP_TO_GROUND, + }, + }); + }; + + /** + * 移除标记 + */ + const removeMarker = (): void => { + const viewer = CesiumUtilsSingleton.getViewer(); + if (!viewer || !currentMarkerEntity) return; + + viewer.entities.remove(currentMarkerEntity); + currentMarkerEntity = null; + }; + + /** + * 获取当前标记实体 + */ + const getCurrentMarker = (): Entity | null => { + return currentMarkerEntity; + }; + + return { + addMarker, + removeMarker, + getCurrentMarker, + }; +}; diff --git a/src/hooks/rain-earthquake/usePulseEffect.ts b/src/hooks/rain-earthquake/usePulseEffect.ts new file mode 100644 index 0000000..cc47c00 --- /dev/null +++ b/src/hooks/rain-earthquake/usePulseEffect.ts @@ -0,0 +1,122 @@ +import { + Cartesian3, + Color, + Entity, + VerticalOrigin, + HorizontalOrigin, + HeightReference, + CallbackProperty, + JulianDate +} from 'cesium'; +import { CesiumUtilsSingleton } from '@/utils/cesium/CesiumUtils'; +import type { PointResource } from '@/types/common/useAroundAnalysisType'; + +/** + * 脉冲效果管理 Hook + * @returns 脉冲效果相关方法 + */ +export const usePulseEffect = () => { + let pulseEntities: Entity[] = []; + + /** + * 创建红色圆形纹理 + * @param radius - 半径 + * @param lineWidth - 线宽 + * @param opacity - 透明度 + * @returns Base64 图片数据 + */ + const createRedCircleTexture = (radius = 15, lineWidth = 2, opacity = 0.8): string => { + const canvas = document.createElement('canvas'); + const size = radius * 2 + 10; + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + + if (!ctx) return ''; + + ctx.clearRect(0, 0, size, size); + ctx.beginPath(); + ctx.arc(size / 2, size / 2, radius, 0, 2 * Math.PI); + ctx.strokeStyle = `rgba(255, 0, 0, ${opacity})`; + ctx.lineWidth = lineWidth; + ctx.stroke(); + + return canvas.toDataURL(); + }; + + /** + * 为单个点添加脉冲效果 + * @param point - 点资源 + * @param startTime - 开始时间(秒) + */ + const addPulseEffectToPoint = (point: PointResource, startTime: number): void => { + const viewer = CesiumUtilsSingleton.getViewer(); + if (!viewer || point.lon === undefined || point.lat === undefined) return; + + const lon = Number(point.lon); + const lat = Number(point.lat); + if (isNaN(lon) || isNaN(lat)) return; + + const position = Cartesian3.fromDegrees(lon, lat, 0); + const baseTexture = createRedCircleTexture(10, 3, 1.0); + + const dynamicScale = new CallbackProperty((time: JulianDate) => { + const elapsed = ((time.secondsOfDay - startTime) % 1 + 1) % 1; + return 1.1 + 0.2 * Math.sin(elapsed * Math.PI * 2); + }, false); + + const pulseEntity = viewer.entities.add({ + position, + billboard: { + image: baseTexture, + scale: dynamicScale, + color: new CallbackProperty((time: JulianDate) => { + const elapsed = ((time.secondsOfDay - startTime) % 1 + 1) % 1; + const alpha = 0.6 + 0.4 * Math.sin(elapsed * Math.PI * 2); + return Color.fromBytes(255, 0, 0, Math.floor(alpha * 255)); + }, false), + verticalOrigin: VerticalOrigin.CENTER, + horizontalOrigin: HorizontalOrigin.CENTER, + heightReference: HeightReference.CLAMP_TO_GROUND, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + }); + + pulseEntities.push(pulseEntity); + }; + + /** + * 为多个点添加脉冲效果 + * @param points - 点资源数组 + */ + const addPulseEffectToPoints = (points: PointResource[]): void => { + const startTime = JulianDate.now().secondsOfDay; + points.forEach(point => addPulseEffectToPoint(point, startTime)); + console.log(`已为 ${points.length} 个点添加脉冲效果`); + }; + + /** + * 移除所有脉冲效果 + */ + const removePulseEffect = (): void => { + const viewer = CesiumUtilsSingleton.getViewer(); + if (!viewer) return; + + pulseEntities.forEach(entity => viewer.entities.remove(entity)); + pulseEntities = []; + }; + + /** + * 获取当前脉冲实体数量 + */ + const getPulseCount = (): number => { + return pulseEntities.length; + }; + + return { + addPulseEffectToPoint, + addPulseEffectToPoints, + removePulseEffect, + getPulseCount, + }; +}; diff --git a/src/stores/useStatusStore.ts b/src/stores/useStatusStore.ts index 9650e0d..445ee4c 100644 --- a/src/stores/useStatusStore.ts +++ b/src/stores/useStatusStore.ts @@ -195,6 +195,11 @@ export const useStatusStore = defineStore('status', () => { }, }); + /** + * 鼠标样式状态 + */ + const cursorStyle = ref('default'); + /** * 恢复默认值 */ @@ -338,6 +343,7 @@ export const useStatusStore = defineStore('status', () => { infrastructureLayers, weatherLayers, functionStatus, + cursorStyle, reset, resetScene, }; diff --git a/src/types/cesium/EntityOptions.ts b/src/types/cesium/EntityOptions.ts index e818c3c..ef31567 100644 --- a/src/types/cesium/EntityOptions.ts +++ b/src/types/cesium/EntityOptions.ts @@ -75,3 +75,70 @@ export interface EntityOptions { /** 自定义属性(用于存储额外信息) */ attributes?: Record; } +/** + * 椭圆配置选项 + * 用于绘制椭圆形区域(圆形是椭圆的特例:semiMajorAxis = semiMinorAxis) + */ +export interface EllipseOptions { + /** 椭圆中心位置 */ + position: Cartesian3; + /** 半长轴(米) */ + semiMajorAxis: number; + /** 半短轴(米) */ + semiMinorAxis: number; + /** 旋转角度(弧度),默认0 */ + rotation?: number; + /** 高度,默认0 */ + height?: number; + /** 拉伸高度,默认0 */ + extrudedHeight?: number; + /** 高度参考,默认CLAMP_TO_GROUND */ + heightReference?: HeightReference; + /** 填充材质,默认白色 */ + material?: MaterialProperty | Color; + /** 是否显示轮廓,默认false */ + outline?: boolean; + /** 轮廓颜色,默认黑色 */ + outlineColor?: Color; + /** 轮廓宽度,默认1 */ + outlineWidth?: number; + /** 分段数(控制平滑度),默认128 */ + granularity?: number; +} + +/** + * 圆形区域配置选项(基于椭圆配置) + * 用于周边分析功能的圆形绘制 + */ +export interface CircleAnalysisOptions { + /** 圆心位置 */ + position: Cartesian3; + /** 半径(公里) */ + radiusKm: number; + /** 填充颜色,默认半透明红色 */ + fillColor?: Color; + /** 填充透明度,默认0.1 */ + fillAlpha?: number; + /** 轮廓颜色,默认红色 */ + outlineColor?: Color; + /** 轮廓透明度,默认0.9 */ + outlineAlpha?: number; + /** 轮廓宽度,默认3 */ + outlineWidth?: number; + /** 高度,默认0 */ + height?: number; + /** 高度参考,默认CLAMP_TO_GROUND */ + heightReference?: HeightReference; +} +// ==================== Cesium Entity 类型扩展 ==================== + +/** + * Cesium Entity 类型扩展 + * 为周边分析功能添加自定义属性 + */ +declare module 'cesium' { + interface Entity { + /** 是否为分析圆形标记 */ + _isAnalysisCircle?: boolean; + } +} \ No newline at end of file diff --git a/src/types/common/useAroundAnalysisType.ts b/src/types/common/useAroundAnalysisType.ts new file mode 100644 index 0000000..f08e0ab --- /dev/null +++ b/src/types/common/useAroundAnalysisType.ts @@ -0,0 +1,114 @@ +import type { LoadingResource } from './LoadingResourceType'; +import type { Ref } from 'vue'; +/** + * 周边分析组件相关类型定义 + */ + +/** + * 点资源分类类型 + */ +export type PointResourceCategory = + | 'school' + | 'hospital' + | 'danger' + | 'shelter' + | 'fire' + | 'store' + | 'hidden-danger' + | 'risk-point' + | 'bridge' + | 'reservoir' + | 'subway'; + +/** + * 点资源数据结构 + */ +export interface PointResource { + /** 点ID */ + id: string | number; + /** 显示值(名称) */ + value: string; + /** 经度 */ + lon?: number; + /** 纬度 */ + lat?: number; + /** 资源分类 */ + category?: PointResourceCategory; + /** 原始类型(用于隐患点子类型区分) */ + originalType?: string; + /** 其他任意属性 */ + [key: string]: unknown; +} + +/** + * 资源配置接口(用于 SearchComponent) + */ +export interface ResourceConfig { + /** 加载资源键 */ + key: LoadingResource; + /** 资源分类 */ + category: PointResourceCategory; + /** 强制类型(用于隐患点) */ + forcedType?: string; + /** 是否可见的判断函数 */ + isVisible: () => boolean; +} + +/** + * 资源配置接口(用于 ButtonComponent,不含 isVisible) + */ +export interface ButtonResourceConfig { + /** 加载资源键 */ + key: LoadingResource; + /** 资源分类 */ + category: PointResourceCategory; + /** 强制类型(用于隐患点) */ + forcedType?: string; +} + +/** + * 分析按钮配置接口 + */ +export interface AnalysisButtonConfig { + /** 按钮默认名称 */ + name: string; + /** 按钮激活时的名称(可选) */ + activeName?: string; + /** 按钮点击回调函数 */ + callback: (status: boolean) => void; +} + +/** + * 对话框位置接口 + */ +export interface DialogPosition { + /** X 坐标 */ + x: number; + /** Y 坐标 */ + y: number; +} + +/** + * 周边分析按钮状态接口(用于 provide/inject 共享) + * 注意:响应式属性使用 Ref 类型 + */ +export interface AnalysisButtonState { + /** 当前选中的按钮索引 */ + selectedButtonIndex: Ref; + /** 是否显示区域选择弹窗 */ + showAreaDialog: Ref; + /** 区域半径(公里) */ + radius: Ref; + /** 弹窗位置 */ + dialogPosition: DialogPosition; + /** 分析按钮配置列表 */ + analysisButtons: AnalysisButtonConfig[]; + /** 按钮点击处理函数 */ + handleButtonClick: (index: number, callback: (status: boolean) => void) => void; + /** 确认添加区域分析 */ + handleConfirm: () => void; + /** 取消区域分析 */ + handleCancel: () => void; + /** 刷新脉冲效果 */ + refreshPulseEffect: () => void; +}