3 Commits

Author SHA1 Message Date
zxyroy bf0e8f2e26 脉冲点列表和导出 2026-06-17 20:27:57 +08:00
zxyroy 73c2fc2ffa Merge remote-tracking branch 'origin/main' into zxy
# Conflicts:
#	.gitignore
2026-06-12 11:25:39 +08:00
zxyroy 32d95f323f 将周边分析的按钮功能和搜索功能进行拆分化设计 2026-05-28 13:53:37 +08:00
14 changed files with 1976 additions and 18 deletions
+4 -1
View File
@@ -17,18 +17,21 @@
"@types/spark-md5": "^3.0.5", "@types/spark-md5": "^3.0.5",
"axios": "^1.12.2", "axios": "^1.12.2",
"cesium": "1.101.0", "cesium": "1.101.0",
"docx": "^9.7.1",
"element-plus": "^2.13.6", "element-plus": "^2.13.6",
"file-saver": "^2.0.5",
"gm-crypto": "^0.1.12", "gm-crypto": "^0.1.12",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"proj4": "^2.20.8", "proj4": "^2.20.8",
"sockjs-client": "^1.6.1", "sockjs-client": "^1.6.1",
"spark-md5": "^3.0.2", "spark-md5": "^3.0.2",
"vite-plugin-cesium": "^1.2.22", "vite-plugin-cesium": "^1.2.22",
"vue": "^3.5.32", "vue": "^3.5.35",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node22": "^22.0.2", "@tsconfig/node22": "^22.0.2",
"@types/file-saver": "^2.0.7",
"@types/node": "^24.12.2", "@types/node": "^24.12.2",
"@types/proj4": "^2.19.0", "@types/proj4": "^2.19.0",
"@types/sockjs-client": "^1.5.4", "@types/sockjs-client": "^1.5.4",
@@ -11,16 +11,29 @@
<!-- 具体功能组件 --> <!-- 具体功能组件 -->
<AroundAnalysisDetailComponent /> <AroundAnalysisDetailComponent />
<!-- 脉冲点列表组件 -->
<PulsePointListComponent />
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useStatusStore } from '@/stores/useStatusStore'; import { provide } from 'vue';//从Vue导入provide函数,用于依赖注入
import AroundAnalysisDetailComponent from './around-analysis/AroundAnalysisDetailComponent.vue'; import { useStatusStore } from '@/stores/useStatusStore';
import ButtonComponent from './around-analysis/ButtonComponent.vue'; import { useAnalysisButton } from '@/hooks/rain-earthquake/useAnalysisButton';
import SearchComponent from './around-analysis/SearchComponent.vue'; import AroundAnalysisDetailComponent from './around-analysis/AroundAnalysisDetailComponent.vue';
import ButtonComponent from './around-analysis/ButtonComponent.vue';
import SearchComponent from './around-analysis/SearchComponent.vue';
import PulsePointListComponent from './around-analysis/PulsePointListComponent.vue';
const statusStore = useStatusStore();
const statusStore = useStatusStore();
// 在父组件中创建唯一的 Hook 实例,包含所有周边分析相关的状态和方法
const analysisButtonState = useAnalysisButton();
// 通过 provide 共享给所有子组件(让 TypeScript 自动推断类型)
provide('analysisButtonState', analysisButtonState);
</script> </script>
<style scoped></style> <style scoped></style>
@@ -1,7 +1,142 @@
<template> <template>
<div></div> <div v-if="showAreaDialog" class="area-dialog" :style="{ left: dialogPosition.x + 'px', top: dialogPosition.y + 'px' }">
<div class="dialog-header">选择区域</div>
<div class="dialog-content">
<div class="radius-input-group">
<span class="label">半径</span>
<input
v-model.number="radius"
type="number"
class="radius-input"
min="1"
max="100"
/>
<span class="unit">公里</span>
</div>
</div>
<div class="dialog-footer">
<button class="confirm-btn" @click="handleConfirm">确认添加</button>
<button class="cancel-btn" @click="handleCancel">取消</button>
</div>
</div>
</template> </template>
<script lang="ts" setup></script> <script lang="ts" setup>
import { inject } from 'vue';//从Vue导入inject函数,用于依赖注入
import type { AnalysisButtonState } from '@/types/common/useAroundAnalysisType';
<style scoped></style> // 从父组件注入共享状态,明确指定类型
const analysisButtonState = inject<AnalysisButtonState>('analysisButtonState');
const {
showAreaDialog,
radius,
dialogPosition,
handleConfirm,
handleCancel
} = analysisButtonState!;//使用非空断言操作符,告诉TypeScript该值一定存在
</script>
<style scoped>
/* 对话框容器样式 */
.area-dialog {
position: absolute;
z-index: 100001;
min-width: 200px;
padding: 0;
background: linear-gradient(180deg, rgba(0, 60, 120, 0.95), rgba(0, 40, 80, 0.95));
border: 2px solid #00b4ff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 180, 255, 0.3);
color: white;
overflow: hidden;
}
/* 对话框标题栏样式 */
.dialog-header {
padding: 8px 12px;
font-size: 16px;
font-weight: bold;
text-align: center;
color: white;
background: linear-gradient(90deg, #00b4ff, #0080cc);
}
.dialog-content {
padding: 10px 6px;
display: flex;
justify-content: center;
align-items: center;
}
.radius-input-group {
display: flex;
align-items: center;
gap: 6px;
}
/* 标签和单位文字的样式 */
.radius-input-group .label,
.radius-input-group .unit {
font-size: 14px;
color: white;
}
.radius-input {
width: 50px;
height: 30px;
padding: 2px 5px;
font-size: 14px;
text-align: center;
color: white;
background: rgba(0, 100, 180, 0.6);
border: 1px solid #00b4ff;
border-radius: 3px;
outline: none;
}
/* 隐藏数字输入框的上下调节按钮(针对WebKit浏览器) */
.radius-input::-webkit-inner-spin-button,
.radius-input::-webkit-outer-spin-button {
opacity: 0;
-webkit-appearance: none;
appearance: none;
margin: 0;
}
.dialog-footer {
display: flex;
justify-content: center;
gap: 8px;
padding: 10px 10px 8px;
}
.confirm-btn,
.cancel-btn {
padding: 6px 15px;
font-size: 14px;
font-weight: bold;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
transition: background 0.3s, box-shadow 0.3s;
}
.confirm-btn {
background: linear-gradient(180deg, #2d8a4e, #1e6b3a);
border: 1px solid #3da862;
}
.confirm-btn:hover {
background: linear-gradient(180deg, #3da862, #2d8a4e);
box-shadow: 0 2px 8px rgba(45, 138, 78, 0.5);
}
.cancel-btn {
background: linear-gradient(180deg, #c0392b, #96281b);
border: 1px solid #e74c3c;
}
.cancel-btn:hover {
background: linear-gradient(180deg, #e74c3c, #c0392b);
box-shadow: 0 2px 8px rgba(192, 57, 43, 0.5);
}
</style>
@@ -1,7 +1,76 @@
<template> <template>
<div></div> <div class="analysis-button-box">
<ul class="analysis-button-ul">
<li v-for="(buttonItem, index) in analysisButtons" :key="index">
<button
@click="handleButtonClick(index, buttonItem.callback)"
:style="{
'background-image': `url(${selectedButtonIndex === index ? rightOrangeButton : rightBlueButton})`,
}"
>
{{ selectedButtonIndex === index && buttonItem.activeName ? buttonItem.activeName : buttonItem.name }}
</button>
</li>
</ul>
</div>
</template> </template>
<script lang="ts" setup></script> <script lang="ts" setup>
import { inject } from 'vue';
import { rightBlueButton, rightOrangeButton } from '@/assets';
import type { AnalysisButtonState } from '@/types/common/useAroundAnalysisType';
<style scoped></style> // 从父组件注入共享状态,明确指定类型
const analysisButtonState = inject<AnalysisButtonState>('analysisButtonState');
const {
selectedButtonIndex,
analysisButtons,
handleButtonClick
} = analysisButtonState!;
</script>
<style scoped>
.analysis-button-box {
position: absolute;
top: 95px;
right: 40px;
z-index: 1000;
width: 180px;
padding: 15px 0;
border-radius: 8px;
}
.analysis-button-ul {
list-style: none;
padding: 0;
margin: 0;
}
.analysis-button-ul li {
margin: 15px 0 0;
text-align: center;
}
.analysis-button-ul li button {
width: 190px;
height: 30px;
padding: 5px;
color: white;
cursor: pointer;
font-size: 16px;
white-space: nowrap;
min-width: 100px;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
background-color: transparent;
background-size: 100%;
background-repeat: no-repeat;
background-position: 20px center;
border: none;
box-shadow: none;
border-radius: 0;
}
</style>
@@ -0,0 +1,595 @@
<template>
<div
v-if="showPulsePointList"
ref="listRef"
class="pulse-point-list"
:style="{ right: position.right + 'px', bottom: position.bottom + 'px' }"
>
<div class="list-header" @mousedown="startDrag">
<span>脉冲点列表 ({{ pulsePoints.length }})</span>
<el-button
type="primary"
size="small"
@click="exportToWord"
class="export-btn"
:icon="Download"
>
导出
</el-button>
</div>
<div class="search-box">
<el-input
v-model="searchKeyword"
placeholder="搜索点位..."
clearable
size="small"
prefix-icon="Search"
/>
</div>
<div class="point-list-content">
<el-table
:data="filteredPoints"
border
stripe
max-height="300"
style="width: 100%"
empty-text="暂无数据"
>
<!-- 名称列 - 悬浮时文字换行展开 -->
<el-table-column
prop="value"
label="名称"
width="150"
>
<template #default="{ row }">
<span class="name-cell">
{{ (row as any).value || '未命名' }}
</span>
</template>
</el-table-column>
<el-table-column prop="category" label="类型" width="80" align="center">
<template #default="{ row }">
<el-tag size="small" :type="getCategoryType(row.category)">
{{ getCategoryName((row as any).category) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="坐标" width="135" align="center">
<template #default="{ row }">
<div class="coordinate">
{{ (row as any).lon?.toFixed(4) || '-' }}, {{ (row as any).lat?.toFixed(4) || '-' }}
</div>
</template>
</el-table-column>
</el-table>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, inject, reactive, watch, nextTick } from 'vue';
import { Download } from '@element-plus/icons-vue';
import { Document, Packer, Paragraph, Table, TableRow, TableCell, WidthType, AlignmentType, TextRun, HeadingLevel } from 'docx';
import { saveAs } from 'file-saver';
import type { AnalysisButtonState } from '@/types/common/useAroundAnalysisType';
const analysisButtonState = inject<AnalysisButtonState>('analysisButtonState');
const pulsePoints = computed(() => analysisButtonState?.pulsePoints.value || []);
const showPulsePointList = computed(() => analysisButtonState?.showPulsePointList.value || false);
const searchKeyword = ref('');
const listRef = ref<HTMLElement | null>(null);
// 使用 right 和 bottom 定位(距离右下角的偏移量)
const position = reactive({ right: 20, bottom: 20 });
const isDragging = ref(false);
const dragStart = reactive({ x: 0, y: 0 });
const initialPosition = reactive({ right: 0, bottom: 0 });
const listSize = reactive({ width: 375, height: 420 });
// 获取列表的实际尺寸
const updateListSize = () => {
if (listRef.value) {
const rect = listRef.value.getBoundingClientRect();
listSize.width = rect.width;
listSize.height = rect.height;
}
};
// 边界限制函数
const constrainPosition = (right: number, bottom: number) => {
updateListSize();
const actualLeft = window.innerWidth - listSize.width - right;
const actualTop = window.innerHeight - listSize.height - bottom;
let constrainedRight = right;
let constrainedBottom = bottom;
if (actualLeft < 0) {
constrainedRight = window.innerWidth - listSize.width;
}
if (actualLeft > window.innerWidth - listSize.width) {
constrainedRight = 0;
}
if (actualTop < 100) {
constrainedBottom = window.innerHeight - listSize.height - 100;
}
if (actualTop > window.innerHeight - listSize.height) {
constrainedBottom = 0;
}
constrainedRight = Math.max(0, Math.min(constrainedRight, window.innerWidth - listSize.width));
constrainedBottom = Math.max(0, Math.min(constrainedBottom, window.innerHeight - listSize.height));
return {
right: constrainedRight,
bottom: constrainedBottom
};
};
// 重置位置到右下角
const resetPosition = () => {
nextTick(() => {
updateListSize();
position.right = 20;
position.bottom = 20;
});
};
// 监听列表显示状态
watch(showPulsePointList, async (newValue) => {
if (newValue) {
await nextTick();
resetPosition();
}
});
// 监听窗口大小变化
watch(() => window.innerWidth, () => {
if (showPulsePointList.value) {
resetPosition();
}
});
watch(() => window.innerHeight, () => {
if (showPulsePointList.value) {
resetPosition();
}
});
const filteredPoints = computed(() => {
if (!searchKeyword.value) return pulsePoints.value;
const keyword = searchKeyword.value.toLowerCase();
return pulsePoints.value.filter(point =>
point.value?.toLowerCase().includes(keyword) ||
point.category?.toLowerCase().includes(keyword)
);
});
// 导出为 Word 文档
const exportToWord = async () => {
try {
const points = filteredPoints.value;
// 统计各类别数量
const categoryCount: Record<string, number> = {};
points.forEach(point => {
const category = getCategoryName(point.category);
categoryCount[category] = (categoryCount[category] || 0) + 1;
});
// 创建表格行数据
const tableRows = [
new TableRow({
children: [
new TableCell({
children: [new Paragraph({ text: '序号', alignment: AlignmentType.CENTER })],
width: { size: 8, type: WidthType.PERCENTAGE },
}),
new TableCell({
children: [new Paragraph({ text: '名称', alignment: AlignmentType.CENTER })],
width: { size: 35, type: WidthType.PERCENTAGE },
}),
new TableCell({
children: [new Paragraph({ text: '类型', alignment: AlignmentType.CENTER })],
width: { size: 15, type: WidthType.PERCENTAGE },
}),
new TableCell({
children: [new Paragraph({ text: '坐标', alignment: AlignmentType.CENTER })],
width: { size: 42, type: WidthType.PERCENTAGE },
}),
],
}),
];
// 添加数据行
points.forEach((point, index) => {
tableRows.push(
new TableRow({
children: [
new TableCell({
children: [new Paragraph({ text: String(index + 1), alignment: AlignmentType.CENTER })],
}),
new TableCell({
children: [new Paragraph({ text: point.value || '未命名' })],
}),
new TableCell({
children: [new Paragraph({ text: getCategoryName(point.category), alignment: AlignmentType.CENTER })],
}),
new TableCell({
children: [new Paragraph({
text: `${point.lon?.toFixed(4) || '-'}, ${point.lat?.toFixed(4) || '-'}`,
alignment: AlignmentType.CENTER
})],
}),
],
})
);
});
// 创建统计数据段落
const statsParagraphs = Object.entries(categoryCount).map(([category, count]) => {
return new Paragraph({
children: [
new TextRun({ text: `${category}: `, bold: true }),
new TextRun({ text: `${count}` }),
],
});
});
// 创建文档
const doc = new Document({
sections: [
{
properties: {},
children: [
// 标题
new Paragraph({
text: '脉冲点列表导出报告',
heading: HeadingLevel.HEADING_1,
alignment: AlignmentType.CENTER,
}),
// 空行
new Paragraph({}),
// 总数统计
new Paragraph({
children: [
new TextRun({ text: '总点数: ', bold: true }),
new TextRun({ text: `${points.length}` }),
],
}),
// 空行
new Paragraph({}),
// 分类统计标题
new Paragraph({
text: '分类统计:',
heading: HeadingLevel.HEADING_2,
}),
// 分类统计数据
...statsParagraphs,
// 空行
new Paragraph({}),
// 详细数据标题
new Paragraph({
text: '详细数据:',
heading: HeadingLevel.HEADING_2,
}),
// 空行
new Paragraph({}),
// 表格
new Table({
rows: tableRows,
width: { size: 100, type: WidthType.PERCENTAGE },
}),
],
},
],
});
// 生成并下载文档
const blob = await Packer.toBlob(doc);
const fileName = `脉冲点列表_${new Date().toISOString().slice(0, 10)}.docx`;
saveAs(blob, fileName);
} catch (error) {
console.error('导出失败:', error);
alert('导出失败,请重试');
}
};
// 开始拖动
const startDrag = (e: MouseEvent) => {
// Deleted:if ((e.target as HTMLElement).closest('.close-btn')) return;
isDragging.value = true;
dragStart.x = e.clientX;
dragStart.y = e.clientY;
initialPosition.right = position.right;
initialPosition.bottom = position.bottom;
document.addEventListener('mousemove', onDrag);
document.addEventListener('mouseup', stopDrag);
e.preventDefault();
};
// 拖动中
const onDrag = (e: MouseEvent) => {
if (!isDragging.value) return;
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
updateListSize();
const currentLeft = window.innerWidth - listSize.width - position.right;
const currentTop = window.innerHeight - listSize.height - position.bottom;
let newLeft = currentLeft + deltaX;
let newTop = currentTop + deltaY;
newLeft = Math.max(0, newLeft);
newLeft = Math.min(newLeft, window.innerWidth - listSize.width);
newTop = Math.max(100, newTop);
newTop = Math.min(newTop, window.innerHeight - listSize.height);
const newRight = window.innerWidth - listSize.width - newLeft;
const newBottom = window.innerHeight - listSize.height - newTop;
const constrained = constrainPosition(newRight, newBottom);
position.right = constrained.right;
position.bottom = constrained.bottom;
dragStart.x = e.clientX;
dragStart.y = e.clientY;
initialPosition.right = position.right;
initialPosition.bottom = position.bottom;
};
// 停止拖动
const stopDrag = () => {
isDragging.value = false;
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', stopDrag);
};
const getCategoryName = (category?: string): string => {
const nameMap: Record<string, string> = {
'school': '学校',
'hospital': '医院',
'danger': '危险源',
'shelter': '避难所',
'fire': '消防站',
'store': '储备点',
'subway': '地铁站',
'hidden-danger': '隐患点',
'risk-point': '风险点',
'bridge': '桥梁',
'reservoir': '水库'
};
return nameMap[category || ''] || category || '未知';
};
const getCategoryType = (category?: string): string => {
const typeMap: Record<string, string> = {
'school': 'primary',
'hospital': 'success',
'danger': 'danger',
'shelter': 'warning',
'fire': 'danger',
'store': 'info',
'subway': '',
'hidden-danger': 'warning',
'risk-point': 'danger',
'bridge': 'info',
'reservoir': 'success'
};
return typeMap[category || ''] || '';
};
</script>
<style scoped>
.pulse-point-list {
position: fixed;
z-index: 100000;
background: rgba(14, 52, 98, 0.95);
border: 1px solid rgba(0, 225, 255, 0.5);
border-radius: 4px;
padding: 15px;
width: auto;
min-width: 350px;
max-width: 450px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(10px);
user-select: none;
display: flex;
flex-direction: column;
max-height: calc(100vh - 40px);
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(0, 225, 255, 0.3);
cursor: move;
flex-shrink: 0;
}
.list-header span {
color: white;
font-size: 16px;
font-weight: bold;
}
.export-btn {
background-color: rgba(0, 225, 255, 0.3);
border-color: rgba(0, 225, 255, 0.5);
color: white;
}
.export-btn:hover {
background-color: rgba(0, 225, 255, 0.5);
border-color: rgba(0, 225, 255, 0.8);
}
.search-box {
margin-bottom: 12px;
flex-shrink: 0;
}
.point-list-content {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.coordinate {
font-size: 13px;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
font-family: monospace;
}
/* 表格样式 */
:deep(.el-table) {
background-color: transparent;
--el-table-tr-bg-color: transparent;
--el-table-header-bg-color: rgba(86, 204, 242, 0.3);
--el-table-row-hover-bg-color: rgba(0, 225, 255, 0.2);
--el-table-border-color: rgba(255, 255, 255, 0.2);
--el-table-text-color: white;
--el-table-header-text-color: white;
}
:deep(.el-table th.el-table__cell) {
background: linear-gradient(
180deg,
rgb(86, 204, 242) 0%,
rgb(47, 128, 237) 100%
);
color: white;
font-weight: bold;
padding: 8px 0 !important;
}
:deep(.el-table td.el-table__cell) {
background-color: rgba(15, 61, 118, 0.4);
color: white;
transition: background-color 0.2s ease;
padding: 6px 0 !important;
}
:deep(.el-table--striped .el-table__body tr.el-table__row--striped td) {
background-color: rgba(15, 61, 118, 0.6);
}
:deep(.el-table__body tr:hover) > td {
background-color: rgba(0, 225, 255, 0.15) !important;
}
/* 单元格内容样式 */
:deep(.el-table .cell) {
padding-left: 8px !important;
padding-right: 8px !important;
overflow: visible;
white-space: normal;
word-break: break-word;
}
:deep(.el-input__wrapper) {
background-color: rgba(15, 61, 118, 0.6);
border: 1px solid rgba(0, 225, 255, 0.3);
box-shadow: none;
}
:deep(.el-input__inner) {
color: white;
}
:deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px rgba(0, 225, 255, 0.8) inset;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-thumb {
background: rgba(0, 225, 255, 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 225, 255, 0.5);
}
::-webkit-scrollbar-track {
background: rgba(15, 61, 118, 0.3);
border-radius: 3px;
}
/* 隐藏外层容器的滚动条 */
.pulse-point-list::-webkit-scrollbar {
display: none;
}
/* 表格内部滚动条样式 */
.point-list-content::-webkit-scrollbar {
width: 6px;
}
.point-list-content::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
.point-list-content::-webkit-scrollbar-thumb:hover {
background: #666;
}
.point-list-content::-webkit-scrollbar-track {
background: transparent;
}
/* 名称单元格样式 - 默认截断,悬浮时换行展开 */
.name-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: white;
}
/* 悬浮时展开文字,换行显示完整内容 */
.name-cell:hover {
white-space: normal;
word-break: break-word;
}
/* 强制表格头部文字居中 */
:deep(.el-table th.el-table__cell .cell) {
justify-content: center !important;
text-align: center !important;
}
</style>
@@ -1,10 +1,105 @@
<!-- 搜索组件 -->
<template> <template>
<div> <div class="search-component-box">
搜索 <el-autocomplete
v-model="state"
:fetch-suggestions="querySearch"
popper-class="my-autocomplete"
placeholder="搜索地点"
@select="handleSelect"
@focus="handleFocus"
clearable
:disabled="!canSearch"
:teleported="false"
>
<template #suffix>
<el-icon class="el-input__icon">
<edit />
</el-icon>
</template>
<template #default="{ item }">
<div class="value">{{ item.value }}</div>
<span class="link">{{ item.lon?.toFixed(4) }}, {{ item.lat?.toFixed(4) }}</span>
</template>
</el-autocomplete>
</div> </div>
</template> </template>
<script lang="ts"></script> <script lang="ts" setup>
import { onMounted } from 'vue';
import { Edit } from '@element-plus/icons-vue';
import { useAroundAnalysis } from '@/hooks/rain-earthquake/useAroundAnalysis';
<style scoped></style> const {
state,
canSearch,
querySearch,
handleSelect,
handleFocus,
loadAllPointData
} = useAroundAnalysis();
onMounted(() => {
loadAllPointData();
});
</script>
<style scoped>
.search-component-box {
position: absolute;
top: 125px;
right: 210px;
z-index: 10000;
width: 220px;
}
.search-component-box :deep(.el-input__wrapper) {
background: rgba(60, 99, 147, 0.9) ;
box-shadow: 0 0 0 1px rgba(160, 173, 192, 0.5) ;
border-radius: 7px;
}
.search-component-box :deep(.el-input__inner) {
color: #fff;
}
.search-component-box :deep(.el-input__inner::placeholder) {
color: #9ca9b9;
}
.search-component-box :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 2px rgba(79, 131, 194, 0.9);
}
.search-component-box :deep(.my-autocomplete) {
background: rgba(26, 58, 95, 0.95);
border: 1px solid #2a3d58;
}
.search-component-box :deep(.my-autocomplete li) {
padding: 2px 6px;
display: flex;
justify-content: space-between;
cursor: pointer;
color: #e6edf3;
font-size: 13px;
}
.search-component-box :deep(.my-autocomplete li .value) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
}
.search-component-box :deep(.my-autocomplete li .link) {
font-size: 12px;
margin-left: 8px;
flex-shrink: 0;
}
.search-component-box :deep(.my-autocomplete li:hover),
.search-component-box :deep(.my-autocomplete li.highlighted) {
background: rgba(58, 112, 169, 0.7);
color: #fff;
}
</style>
@@ -0,0 +1,414 @@
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<string, () => 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<string, () => 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<number>(-1);
const showAreaDialog = ref(false);
const radius = ref(10);
const dialogPosition = reactive<DialogPosition>({ x: 0, y: 0 });
const pulsePoints = ref<PointResource[]>([]);
const showPulsePointList = ref(false);
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<string, unknown>) => {
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<string | number, PointResource>();
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);
pulsePoints.value = pointsInCircle;
showPulsePointList.value = true;
};
// ==================== 地图事件处理 ====================
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;
pulsePoints.value = [];
showPulsePointList.value = false;
};
/**
* 仅清除视觉效果,保留标记点和中心点位置
*/
const clearVisualEffectsOnly = () => {
clearCircle();
removePulseEffect();
pulsePoints.value = [];
showPulsePointList.value = false;
};
// ==================== 事件处理 ====================
const handleConfirm = () => {
if (!currentCenterPosition) {
console.error('中心点位置不存在');
return;
}
console.log('确认添加区域分析', {
radius: radius.value,
center: currentCenterPosition
});
// 先清除上一次的视觉效果(保留标记点)
clearVisualEffectsOnly();
// 重新绘制当前选择的效果
drawCircle(currentCenterPosition, radius.value);
const pointsInCircle = getPointsInCircle(currentCenterPosition, radius.value);
addPulseEffectToPoints(pointsInCircle);
pulsePoints.value = pointsInCircle;
showPulsePointList.value = true;
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;
// 移除地图点击事件监听器,恢复鼠标默认样式
if (clickHandler) {
clickHandler.destroy();
clickHandler = null;
}
// 恢复鼠标默认样式
const viewer = CesiumUtilsSingleton.getViewer();
if (viewer?.canvas) {
statusStore.cursorStyle = 'default';
viewer.canvas.style.cursor = 'default';
}
};
const handleCancel = () => {
showAreaDialog.value = false;
clearAllAnalysisResources();
pulsePoints.value = [];
showPulsePointList.value = false;
};
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, () => {
// 只有当有中心点位置且脉冲点列表正在显示时,才刷新脉冲效果
if (currentCenterPosition && showPulsePointList.value) {
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,
pulsePoints,
showPulsePointList,
};
};
@@ -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<PointResource[]>([]);
/**
* 计算属性:判断是否允许搜索
*/
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<string, unknown>[],
category: PointResource['category'],
forcedType?: string,
): PointResource[] {
if (!Array.isArray(infoList)) return [];
return infoList.map((item: Record<string, unknown>) => {
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<string | number, PointResource>();
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 };
};
@@ -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<CircleAnalysisOptions>
): 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,
};
};
@@ -0,0 +1,72 @@
import { Cartesian3, Entity, VerticalOrigin, HorizontalOrigin, HeightReference } from 'cesium';
import { CesiumUtilsSingleton } from '@/utils/cesium/CesiumUtils';
// 十字准心标记 SVG
const MARKER_SVG = `
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="6" fill="#FF0000" stroke="#FFFFFF" stroke-width="2"/>
<path d="M 15 20 L 15 15 L 20 15" stroke="#00FF00" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M 65 20 L 65 15 L 60 15" stroke="#00FF00" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M 15 60 L 15 65 L 20 65" stroke="#00FF00" stroke-width="4" fill="none" stroke-linecap="round"/>
<path d="M 65 60 L 65 65 L 60 65" stroke="#00FF00" stroke-width="4" fill="none" stroke-linecap="round"/>
</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,
};
};
+122
View File
@@ -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,
};
};
+6
View File
@@ -195,6 +195,11 @@ export const useStatusStore = defineStore('status', () => {
}, },
}); });
/**
* 鼠标样式状态
*/
const cursorStyle = ref<string>('default');
/** /**
* 恢复默认值 * 恢复默认值
*/ */
@@ -338,6 +343,7 @@ export const useStatusStore = defineStore('status', () => {
infrastructureLayers, infrastructureLayers,
weatherLayers, weatherLayers,
functionStatus, functionStatus,
cursorStyle,
reset, reset,
resetScene, resetScene,
}; };
+67
View File
@@ -75,3 +75,70 @@ export interface EntityOptions {
/** 自定义属性(用于存储额外信息) */ /** 自定义属性(用于存储额外信息) */
attributes?: Record<string, unknown>; attributes?: Record<string, unknown>;
} }
/**
* 椭圆配置选项
* 用于绘制椭圆形区域(圆形是椭圆的特例: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;
}
}
+118
View File
@@ -0,0 +1,118 @@
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<number>;
/** 是否显示区域选择弹窗 */
showAreaDialog: Ref<boolean>;
/** 区域半径(公里) */
radius: Ref<number>;
/** 弹窗位置 */
dialogPosition: DialogPosition;
/** 分析按钮配置列表 */
analysisButtons: AnalysisButtonConfig[];
/** 按钮点击处理函数 */
handleButtonClick: (index: number, callback: (status: boolean) => void) => void;
/** 确认添加区域分析 */
handleConfirm: () => void;
/** 取消区域分析 */
handleCancel: () => void;
/** 刷新脉冲效果 */
refreshPulseEffect: () => void;
/** 脉冲点列表 */
pulsePoints: Ref<PointResource[]>;
/** 是否显示脉冲点列表 */
showPulsePointList: Ref<boolean>;
}