You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
563 lines
16 KiB
563 lines
16 KiB
<template>
|
|
<div class="forest-grass-wet ecological-protection">
|
|
<map-scene
|
|
ref="mapSceneRef"
|
|
:active-layer="activeLayer"
|
|
:map-ranking="mapRanking"
|
|
@feature-loading="featureLoading = $event"
|
|
@feature-info="handleFeatureInfo"
|
|
@layer-status="handleLayerStatus"
|
|
@draw-boundary="handleDrawBoundary"
|
|
/>
|
|
|
|
<div class="forest-grass-wet-ui ecological-protection-ui">
|
|
<div v-if="!isEngineeringAnalysis" class="fgw-left">
|
|
<div class="fgw-side-stack is-left">
|
|
<m-card
|
|
v-for="panel in leftPanels"
|
|
:key="getPanelRenderKey(panel, 'left')"
|
|
class="fgw-side-card left-card"
|
|
:class="{ 'is-double': isDoublePanel(panel) }"
|
|
:title="panel.title"
|
|
:width="398"
|
|
:height="getPanelHeight(panel)"
|
|
>
|
|
<business-panel-renderer :panel="panel" compact @row-select="handlePanelRowSelect(panel, $event)" />
|
|
</m-card>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="!isEngineeringAnalysis" class="fgw-right">
|
|
<div class="fgw-side-stack is-right">
|
|
<m-card
|
|
v-for="panel in rightPanels"
|
|
:key="getPanelRenderKey(panel, 'right')"
|
|
class="fgw-side-card right-card"
|
|
:class="{ 'is-double': isDoublePanel(panel) }"
|
|
:title="panel.title"
|
|
:width="398"
|
|
:height="getPanelHeight(panel)"
|
|
>
|
|
<business-panel-renderer :panel="panel" compact @row-select="handlePanelRowSelect(panel, $event)" />
|
|
</m-card>
|
|
</div>
|
|
</div>
|
|
|
|
<engineering-analysis-panel
|
|
v-if="isEngineeringAnalysis"
|
|
ref="engineeringAnalysisRef"
|
|
:drawing-point-count="drawingPointCount"
|
|
@drawing-change="handleDrawingChange"
|
|
@finish-drawing="handleFinishDrawing"
|
|
@undo-point="handleUndoDrawingPoint"
|
|
@clear-boundary="handleClearAnalysisBoundary"
|
|
@analysis-result="handleAnalysisResult"
|
|
@focus-project="handleAnalysisProjectFocus"
|
|
/>
|
|
|
|
<div
|
|
class="fgw-map-layer-tools"
|
|
:class="{
|
|
'is-analysis': isEngineeringAnalysis,
|
|
'is-national-park': isNationalPark,
|
|
}"
|
|
>
|
|
<layer-panel
|
|
v-model="activeLayerKey"
|
|
v-model:selected-legend-key="selectedLegendKey"
|
|
:topic-key="currentView"
|
|
:legends="activeLayer?.legends || []"
|
|
:width="layerPanelWidth"
|
|
:height="layerPanelHeight"
|
|
:legend-only="isNationalPark"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="featurePanelVisible" class="fgw-map-feature-info" :style="featurePanelStyle">
|
|
<feature-info-panel
|
|
:feature="selectedFeature"
|
|
:empty="featureEmpty"
|
|
:loading="featureLoading"
|
|
:width="460"
|
|
:height="featurePanelHeight"
|
|
/>
|
|
</div>
|
|
|
|
<div class="fgw-bottom-topic ecological-bottom-topic">
|
|
<topic-switcher
|
|
:model-value="currentView"
|
|
:topics="ecologicalProtectionTopics"
|
|
@update:model-value="handleViewSelect"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loadingVisible" class="loading">
|
|
<div class="loading-text">
|
|
<span style="--index: 1">L</span>
|
|
<span style="--index: 2">O</span>
|
|
<span style="--index: 3">A</span>
|
|
<span style="--index: 4">D</span>
|
|
<span style="--index: 5">I</span>
|
|
<span style="--index: 6">N</span>
|
|
<span style="--index: 7">G</span>
|
|
</div>
|
|
<div class="loading-progress">
|
|
<span class="value">{{ progress }}</span>
|
|
<span class="unit">%</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
|
import gsap from "gsap"
|
|
import mCard from "@/components/mCard/index.vue"
|
|
import { ecologicalProtectionTopics, getLayerByKey, getLayersByTopic } from "@/config/layers"
|
|
import { Assets } from "@/views/forestGrassWet/assets"
|
|
import MapScene from "@/views/forestGrassWet/map.vue"
|
|
import LayerPanel from "@/views/forestGrassWet/components/LayerPanel.vue"
|
|
import FeatureInfoPanel from "@/views/forestGrassWet/components/FeatureInfoPanel.vue"
|
|
import BusinessPanelRenderer from "@/views/forestGrassWet/components/BusinessPanelRenderer.vue"
|
|
import TopicSwitcher from "@/views/forestGrassWet/components/TopicSwitcher.vue"
|
|
import EngineeringAnalysisPanel from "./components/EngineeringAnalysisPanel.vue"
|
|
import {
|
|
engineeringAnalysisPanels,
|
|
getEcologicalMapRanking,
|
|
getEcologicalTopStats,
|
|
parkPanels,
|
|
restorationPanels,
|
|
} from "./data"
|
|
|
|
const props = defineProps({
|
|
activeView: {
|
|
type: String,
|
|
default: "national-park",
|
|
},
|
|
showLoading: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
active: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits(["module-stats", "view-change"])
|
|
const mapSceneRef = ref(null)
|
|
const engineeringAnalysisRef = ref(null)
|
|
const activeLayerKey = ref("")
|
|
const selectedLegendKey = ref("")
|
|
const selectedFeature = ref(null)
|
|
const featureEmpty = ref(false)
|
|
const featureLoading = ref(false)
|
|
const featurePanelPoint = ref(null)
|
|
const progress = ref(0)
|
|
const loadingVisible = ref(props.showLoading)
|
|
const mapRanking = ref(null)
|
|
const drawingPointCount = ref(0)
|
|
const layerStatus = ref({
|
|
type: "idle",
|
|
text: "等待生态保护图层加载",
|
|
})
|
|
|
|
let assets = null
|
|
let destroyed = false
|
|
let mapAssetsReady = false
|
|
let mapLoadStarted = false
|
|
let mapLoadTimer = null
|
|
|
|
const currentView = computed(() =>
|
|
["national-park", "restoration", "engineering-analysis"].includes(props.activeView) ? props.activeView : "national-park"
|
|
)
|
|
const isNationalPark = computed(() => currentView.value === "national-park")
|
|
const isEngineeringAnalysis = computed(() => currentView.value === "engineering-analysis")
|
|
const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(currentView.value, activeLayerKey.value) : null)
|
|
const selectedLegend = computed(() =>
|
|
(baseActiveLayer.value?.legends || []).find((item) => item.key === selectedLegendKey.value) || null
|
|
)
|
|
const activeLayer = computed(() => {
|
|
const layer = baseActiveLayer.value
|
|
if (!layer) return layer
|
|
if (!selectedLegend.value) return layer
|
|
return {
|
|
...layer,
|
|
cqlFilter: selectedLegend.value.cqlFilter || layer.cqlFilter,
|
|
activeLegend: selectedLegend.value,
|
|
name: selectedLegend.value.name || layer.name,
|
|
}
|
|
})
|
|
const currentPanels = computed(() => {
|
|
if (currentView.value === "restoration") return restorationPanels
|
|
if (currentView.value === "engineering-analysis") return engineeringAnalysisPanels
|
|
return parkPanels
|
|
})
|
|
const leftPanels = computed(() => currentPanels.value.left || [])
|
|
const rightPanels = computed(() => currentPanels.value.right || [])
|
|
const screenViewportHeight = ref(typeof window === "undefined" ? 1080 : window.innerHeight)
|
|
const sidePanelTop = 128
|
|
const sidePanelGap = 12
|
|
const sidePanelBottomGap = 8
|
|
const sidePanelMinHeight = 227
|
|
const panelHeight = computed(() => {
|
|
const viewportHeight = Math.max(1080, Number(screenViewportHeight.value) || 1080)
|
|
const fittedHeight = Math.floor((viewportHeight - sidePanelTop - sidePanelBottomGap - sidePanelGap * 3) / 4)
|
|
return Math.max(sidePanelMinHeight, fittedHeight)
|
|
})
|
|
const panelGap = 12
|
|
const doublePanelHeight = computed(() => panelHeight.value * 2 + panelGap)
|
|
const layerPanelWidth = computed(() => isNationalPark.value ? 178 : 248)
|
|
const layerPanelHeight = computed(() => isNationalPark.value ? 118 : 430)
|
|
const featurePanelVisible = computed(() => Boolean(featurePanelPoint.value))
|
|
const featurePanelHeight = computed(() => {
|
|
if (featureLoading.value || featureEmpty.value || !selectedFeature.value) return 186
|
|
const rowCount = estimateFeatureRowCount(selectedFeature.value)
|
|
return Math.min(480, Math.max(300, 86 + Math.min(rowCount, 12) * 34))
|
|
})
|
|
const featurePanelStyle = computed(() => {
|
|
if (!featurePanelPoint.value) return {}
|
|
const panelWidth = 460
|
|
const panelHeight = featurePanelHeight.value
|
|
const margin = 24
|
|
const point = toScreenLocalPoint(featurePanelPoint.value)
|
|
const xOffset = point.x > 1420 ? -panelWidth - 28 : 28
|
|
const x = Math.min(Math.max(point.x + xOffset, margin), 1920 - panelWidth - margin)
|
|
const y = Math.min(Math.max(point.y - panelHeight - 72, 188), 1080 - panelHeight - 132)
|
|
return {
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
right: "auto",
|
|
bottom: "auto",
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
updateScreenViewportHeight()
|
|
window.addEventListener("resize", updateScreenViewportHeight)
|
|
document.addEventListener("pointerdown", handleOutsideFeaturePointerDown, true)
|
|
initAssets()
|
|
syncView(currentView.value)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
destroyed = true
|
|
if (mapLoadTimer) window.clearTimeout(mapLoadTimer)
|
|
window.removeEventListener("resize", updateScreenViewportHeight)
|
|
document.removeEventListener("pointerdown", handleOutsideFeaturePointerDown, true)
|
|
})
|
|
|
|
watch(currentView, (viewKey) => {
|
|
syncView(viewKey)
|
|
})
|
|
|
|
watch(activeLayerKey, () => {
|
|
selectedLegendKey.value = ""
|
|
closeFeaturePanel()
|
|
})
|
|
|
|
watch(selectedLegendKey, () => {
|
|
closeFeaturePanel()
|
|
})
|
|
|
|
watch(
|
|
() => props.active,
|
|
(active) => {
|
|
if (!mapSceneRef.value) return
|
|
if (active) {
|
|
scheduleMapLoadIfActive()
|
|
mapSceneRef.value.resume?.()
|
|
} else {
|
|
mapSceneRef.value.pause?.()
|
|
}
|
|
},
|
|
)
|
|
|
|
function syncView(viewKey) {
|
|
const topic = ecologicalProtectionTopics.find((item) => item.key === viewKey) || ecologicalProtectionTopics[0]
|
|
const layers = getLayersByTopic(viewKey)
|
|
selectedLegendKey.value = ""
|
|
activeLayerKey.value = topic.defaultLayerKey || layers[0]?.key || ""
|
|
mapRanking.value = getEcologicalMapRanking(viewKey)
|
|
drawingPointCount.value = 0
|
|
mapSceneRef.value?.setBoundaryDrawing?.(false)
|
|
if (viewKey !== "engineering-analysis") mapSceneRef.value?.clearAnalysisBoundary?.()
|
|
closeFeaturePanel()
|
|
emit("module-stats", {
|
|
moduleKey: "ecological-protection",
|
|
viewKey,
|
|
items: getEcologicalTopStats(viewKey),
|
|
})
|
|
}
|
|
|
|
function handleViewSelect(viewKey) {
|
|
const nextKey = ["national-park", "restoration", "engineering-analysis"].includes(viewKey) ? viewKey : "national-park"
|
|
if (nextKey === currentView.value) return
|
|
emit("view-change", nextKey)
|
|
}
|
|
|
|
function initAssets() {
|
|
assets = new Assets()
|
|
const params = { progress: 0 }
|
|
assets.instance.on("onProgress", (path, itemsLoaded, itemsTotal) => {
|
|
const value = Math.floor((itemsLoaded / itemsTotal) * 100)
|
|
gsap.to(params, {
|
|
progress: value,
|
|
onUpdate: () => {
|
|
progress.value = Math.floor(params.progress)
|
|
},
|
|
})
|
|
})
|
|
const markAssetsReady = () => {
|
|
if (destroyed || mapAssetsReady) return
|
|
mapAssetsReady = true
|
|
scheduleMapLoadIfActive()
|
|
}
|
|
assets.instance.on("onLoad", markAssetsReady)
|
|
assets.ready?.then(markAssetsReady).catch(() => {
|
|
if (destroyed) return
|
|
layerStatus.value = {
|
|
type: "error",
|
|
text: "地图资源加载失败",
|
|
}
|
|
})
|
|
}
|
|
|
|
function scheduleMapLoadIfActive() {
|
|
if (destroyed || !props.active || !mapAssetsReady || mapLoadStarted) return
|
|
mapLoadStarted = true
|
|
if (mapLoadTimer) window.clearTimeout(mapLoadTimer)
|
|
mapLoadTimer = window.setTimeout(() => {
|
|
mapLoadTimer = null
|
|
loadMapOnce()
|
|
}, 600)
|
|
}
|
|
|
|
async function loadMapOnce(attempt = 0) {
|
|
if (destroyed) return
|
|
if (!props.active) {
|
|
mapLoadStarted = false
|
|
return
|
|
}
|
|
await nextTick()
|
|
if (!props.active) {
|
|
mapLoadStarted = false
|
|
return
|
|
}
|
|
const mapLoaded = Boolean(mapSceneRef.value?.loadMap?.(assets) || mapSceneRef.value?.hasMap?.())
|
|
if (mapLoaded) {
|
|
await hideLoading()
|
|
await nextTick()
|
|
mapSceneRef.value?.play()
|
|
return
|
|
}
|
|
if (attempt < 24) {
|
|
window.setTimeout(() => loadMapOnce(attempt + 1), 120)
|
|
return
|
|
}
|
|
mapLoadStarted = false
|
|
}
|
|
|
|
function toScreenLocalPoint(point) {
|
|
const screen = document.querySelector(".ecological-protection")
|
|
const rect = screen?.getBoundingClientRect()
|
|
if (!rect?.width || !rect?.height) return point
|
|
return {
|
|
x: ((point.x - rect.left) / rect.width) * 1920,
|
|
y: ((point.y - rect.top) / rect.height) * 1080,
|
|
}
|
|
}
|
|
|
|
function updateScreenViewportHeight() {
|
|
screenViewportHeight.value = typeof window === "undefined" ? 1080 : window.innerHeight
|
|
}
|
|
|
|
function getPanelRenderKey(panel, side) {
|
|
return `${currentView.value}-${side}-${panel?.key || panel?.title || "panel"}`
|
|
}
|
|
|
|
function isDoublePanel(panel) {
|
|
return panel?.span === 2
|
|
}
|
|
|
|
function getPanelHeight(panel) {
|
|
return isDoublePanel(panel) ? doublePanelHeight.value : panelHeight.value
|
|
}
|
|
|
|
function estimateFeatureRowCount(feature) {
|
|
const properties = feature?.properties || {}
|
|
return Object.keys(properties).filter((key) => {
|
|
const value = properties[key]
|
|
if (value === undefined || value === null || value === "") return false
|
|
return !["geom", "wkt", "geometry", "the_geom", "raw_properties"].includes(String(key).toLowerCase())
|
|
}).length
|
|
}
|
|
|
|
function handleFeatureInfo(payload) {
|
|
const feature = payload?.feature ?? payload ?? null
|
|
if (!feature) {
|
|
closeFeaturePanel()
|
|
return
|
|
}
|
|
selectedFeature.value = feature
|
|
featureEmpty.value = false
|
|
if (payload?.screen) {
|
|
featurePanelPoint.value = payload.screen
|
|
}
|
|
}
|
|
|
|
function handleLayerStatus(status) {
|
|
layerStatus.value = status
|
|
}
|
|
|
|
function handlePanelRowSelect(panel, row) {
|
|
if (currentView.value === "national-park" && row?.name) {
|
|
closeFeaturePanel()
|
|
mapSceneRef.value?.focusTown?.(row.name, {
|
|
distanceScale: 0.66,
|
|
targetLocalZ: 0.84,
|
|
cameraOffset: { x: 0, y: 1.05, z: 0 },
|
|
})
|
|
return
|
|
}
|
|
if (currentView.value !== "restoration" || panel?.key !== "restoration-projects") return
|
|
const projectName = row?.projectName || row?.name
|
|
if (!projectName) return
|
|
closeFeaturePanel()
|
|
mapSceneRef.value?.focusProject?.(projectName)
|
|
}
|
|
|
|
function handleDrawingChange(enabled) {
|
|
closeFeaturePanel()
|
|
drawingPointCount.value = 0
|
|
mapSceneRef.value?.setBoundaryDrawing?.(enabled)
|
|
}
|
|
|
|
function handleFinishDrawing() {
|
|
mapSceneRef.value?.finishBoundaryDrawing?.()
|
|
}
|
|
|
|
function handleUndoDrawingPoint() {
|
|
mapSceneRef.value?.undoBoundaryPoint?.()
|
|
}
|
|
|
|
function handleClearAnalysisBoundary() {
|
|
drawingPointCount.value = 0
|
|
mapSceneRef.value?.clearAnalysisBoundary?.()
|
|
}
|
|
|
|
function handleDrawBoundary(payload = {}) {
|
|
drawingPointCount.value = payload.pointCount || payload.points?.length || 0
|
|
if (payload.status === "complete" && payload.feature) {
|
|
drawingPointCount.value = 0
|
|
engineeringAnalysisRef.value?.applyDrawnBoundary?.(payload.feature)
|
|
}
|
|
if (payload.status === "clear") drawingPointCount.value = 0
|
|
}
|
|
|
|
function handleAnalysisResult(payload = {}) {
|
|
mapSceneRef.value?.showAnalysisResult?.({
|
|
boundary: payload.boundary,
|
|
matches: payload.matches,
|
|
preserveView: currentView.value === "engineering-analysis",
|
|
})
|
|
if (currentView.value === "engineering-analysis") return
|
|
}
|
|
|
|
function handleAnalysisProjectFocus(item) {
|
|
const matches = (item?.features || []).filter(Boolean)
|
|
mapSceneRef.value?.showAnalysisResult?.({
|
|
matches,
|
|
})
|
|
}
|
|
|
|
function closeFeaturePanel() {
|
|
selectedFeature.value = null
|
|
featureEmpty.value = false
|
|
featurePanelPoint.value = null
|
|
mapSceneRef.value?.clearSelectedFeature?.()
|
|
}
|
|
|
|
function handleOutsideFeaturePointerDown(event) {
|
|
if (!featurePanelVisible.value) return
|
|
const panel = document.querySelector(".fgw-map-feature-info")
|
|
const target = event.target
|
|
if (panel && target instanceof Node && panel.contains(target)) return
|
|
closeFeaturePanel()
|
|
}
|
|
|
|
function hideLoading() {
|
|
if (!loadingVisible.value) return Promise.resolve()
|
|
return new Promise((resolve) => {
|
|
gsap.to(".ecological-protection .loading", {
|
|
opacity: 0,
|
|
duration: 0.8,
|
|
delay: 0.2,
|
|
onComplete: () => {
|
|
loadingVisible.value = false
|
|
resolve()
|
|
},
|
|
})
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.ecological-protection {
|
|
.fgw-map-layer-tools {
|
|
top: 372px;
|
|
right: 458px;
|
|
width: 248px;
|
|
height: 430px;
|
|
|
|
&.is-analysis {
|
|
top: 282px;
|
|
right: 72px;
|
|
transform: scale(0.92);
|
|
transform-origin: right top;
|
|
}
|
|
|
|
&.is-national-park {
|
|
top: auto;
|
|
left: auto;
|
|
right: 452px;
|
|
bottom: 144px;
|
|
width: 178px;
|
|
height: 118px;
|
|
opacity: 0.92;
|
|
|
|
.layer-panel-legend {
|
|
min-height: 0;
|
|
}
|
|
|
|
.layer-panel-legend-row {
|
|
height: 28px;
|
|
}
|
|
|
|
.m-card-hd-title {
|
|
right: 42px !important;
|
|
font-size: 15px !important;
|
|
letter-spacing: 1px !important;
|
|
}
|
|
}
|
|
}
|
|
|
|
.ecological-bottom-topic {
|
|
bottom: 48px;
|
|
|
|
.topic-switcher {
|
|
width: 650px;
|
|
}
|
|
|
|
.topic-switcher-item {
|
|
width: 146px;
|
|
}
|
|
|
|
.topic-name {
|
|
font-size: 15px;
|
|
letter-spacing: 0;
|
|
}
|
|
}
|
|
}
|
|
</style>
|
|
|