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

<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>