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.
1110 lines
32 KiB
1110 lines
32 KiB
<template>
|
|
<div class="forest-grass-wet">
|
|
<map-scene
|
|
ref="mapSceneRef"
|
|
:active-layer="activeLayer"
|
|
:map-ranking="mapRanking"
|
|
:admin-boundary-line-scale="2.4"
|
|
:initial-zoom="9.55"
|
|
hide-town-label-stats
|
|
:enable-imagery-feature-pick="false"
|
|
:enable-town-polygon-pick="false"
|
|
@feature-loading="featureLoading = $event"
|
|
@feature-info="handleFeatureInfo"
|
|
@map-click="handleMapClick"
|
|
@layer-status="handleLayerStatus"
|
|
/>
|
|
|
|
<div class="forest-grass-wet-ui" id="forest-grass-wet-ui">
|
|
<div 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="sideCardWidth"
|
|
:height="getPanelHeight(panel)"
|
|
>
|
|
<business-panel-renderer :panel="panel" compact column-label-standard />
|
|
</m-card>
|
|
</div>
|
|
</div>
|
|
|
|
<div 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="sideCardWidth"
|
|
:height="getPanelHeight(panel)"
|
|
>
|
|
<business-panel-renderer :panel="panel" compact column-label-standard />
|
|
</m-card>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
v-if="layerPanelVisible"
|
|
class="fgw-map-layer-tools"
|
|
:style="{ height: `${layerPanelHeight}px` }"
|
|
>
|
|
<layer-panel
|
|
v-model="layerPanelValue"
|
|
v-model:selected-legend-key="selectedLegendKey"
|
|
:topic-key="activeTopicKey"
|
|
:layers="layerPanelLayers"
|
|
:legends="activeLayerLegends"
|
|
:multiple="activeTopicKey === 'overview'"
|
|
:width="248"
|
|
:height="layerPanelHeight"
|
|
:compact="layerPanelCompact"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="ndviTimelineVisible" class="fgw-ndvi-timeline">
|
|
<grassland-ndvi-timeline
|
|
v-model="selectedNdviTimelineKey"
|
|
:items="ndviTimelineRows"
|
|
:loading="ndviTimelineStatus === 'loading'"
|
|
/>
|
|
</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">
|
|
<topic-switcher v-model="activeTopicKey" />
|
|
</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 { Assets } from "./assets"
|
|
import {
|
|
forestGrassWetTopics,
|
|
getLayerByKey,
|
|
getLayersByTopic,
|
|
getTopicConfig,
|
|
} from "@/config/layers"
|
|
import { getBasicGrasslandClickInfoByWkt, getLayerTownRanking, getTopicBusinessStats, getWoodlandClickInfoByWkt } from "@/services/openApi"
|
|
import { getLandClickInfo, getWetlandClickInfo } from "@/services/pastureApi"
|
|
import { getGrasslandNdviTimeline } from "@/services/ndviTimeline"
|
|
import { SCREEN2_LAYOUT, getScreen2SideCardHeight, getScreen2SpanHeight } from "@/utils/screen2Standard"
|
|
import MapScene from "@/components/CesiumMap/index.vue"
|
|
import TopicSwitcher from "./components/TopicSwitcher.vue"
|
|
import LayerPanel from "./components/LayerPanel.vue"
|
|
import FeatureInfoPanel from "./components/FeatureInfoPanel.vue"
|
|
import BusinessPanelRenderer from "./components/BusinessPanelRenderer.vue"
|
|
import GrasslandNdviTimeline from "./components/GrasslandNdviTimeline.vue"
|
|
import mCard from "@/components/mCard/index.vue"
|
|
|
|
const props = defineProps({
|
|
showLoading: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
active: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits(["topic-stats"])
|
|
const mapSceneRef = ref(null)
|
|
const activeTopicKey = ref(forestGrassWetTopics[0].key)
|
|
const activeLayerKey = ref("")
|
|
const overviewActiveLayerKeys = ref([])
|
|
const coreStats = ref([])
|
|
const coreStatus = ref("idle")
|
|
const coreLoading = ref(false)
|
|
const businessLeft = ref({ title: "业务统计", rows: [] })
|
|
const businessRight = ref({ title: "业务统计", rows: [] })
|
|
const leftPanels = ref([])
|
|
const rightPanels = ref([])
|
|
const mapRanking = ref(null)
|
|
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 ndviTimelineRows = ref([])
|
|
const ndviTimelineStatus = ref("idle")
|
|
const selectedNdviTimelineKey = ref("")
|
|
const layerStatus = ref({
|
|
type: "idle",
|
|
text: "等待 GeoServer 图层加载",
|
|
})
|
|
|
|
let assets = null
|
|
let destroyed = false
|
|
let mapAssetsReady = false
|
|
let mapLoadStarted = false
|
|
let mapLoadTimer = null
|
|
let rankingRequestId = 0
|
|
let businessRequestId = 0
|
|
let ndviTimelineRequestId = 0
|
|
let featureQueryRequestId = 0
|
|
let resettingTopicLayer = false
|
|
let resettingLegend = false
|
|
|
|
const magicDetailTitleFieldMap = {
|
|
图斑编号: "patch_no",
|
|
图斑号: "patch_no",
|
|
湿地编号: "land_number",
|
|
草地编号: "land_number",
|
|
林地类型: "dlmc",
|
|
地类: "landUseName",
|
|
地类名称: "landUseName",
|
|
所在乡镇: "townName",
|
|
所在区域: "full_name",
|
|
面积: "acreage",
|
|
图斑面积: "acreage",
|
|
湿地面积: "acreage",
|
|
草场面积: "acreage",
|
|
草原类: "grassTypeName",
|
|
草原型: "grassMinType",
|
|
草原等级: "grasslandLevel",
|
|
草原类型: "basic_type_name",
|
|
小斑干草产量: "xbGcYield",
|
|
小斑鲜草产量: "xbXcYield",
|
|
鲜草产量: "xcYield",
|
|
干草产量: "gcYield",
|
|
海拔: "elevation",
|
|
地貌: "landformName",
|
|
权属单位: "qsdwmc",
|
|
坐落单位: "zldwmc",
|
|
林班: "lin_ban",
|
|
小班: "xiao_ban",
|
|
森林类别: "sen_lin_lb",
|
|
林种: "lin_zhong",
|
|
公益林等级: "gjgyl_bhdj",
|
|
保护等级: "bh_dj",
|
|
质量等级: "zl_dj",
|
|
优势树种: "you_shi_sz",
|
|
起源: "qi_yuan",
|
|
龄组: "ling_zu",
|
|
郁闭度: "yu_bi_du",
|
|
林地权属: "ld_qs",
|
|
土地使用权属: "tdsyqs",
|
|
林木权属: "lmqs",
|
|
生态区位: "stqwmc",
|
|
坡度等级: "slope",
|
|
}
|
|
|
|
const mapRankingLayerKeys = new Set([
|
|
"grassland-distribution",
|
|
"wetland-distribution",
|
|
"woodland-distribution",
|
|
"woodland-protection",
|
|
"woodland-type",
|
|
"woodland-quality",
|
|
"woodland-ownership",
|
|
])
|
|
|
|
const overviewLayerDefinitions = [
|
|
{ topicKey: "wetland", layerKey: "wetland-distribution", name: "湿地分布", legendName: "湿地资源" },
|
|
{ topicKey: "grassland", layerKey: "grassland-distribution", name: "草地分布", legendName: "草地资源" },
|
|
{ topicKey: "woodland", layerKey: "woodland-distribution", name: "林地分布", legendName: "林地资源" },
|
|
]
|
|
|
|
const nonBusinessFeatureKeys = new Set([
|
|
"code",
|
|
"msg",
|
|
"message",
|
|
"success",
|
|
"data",
|
|
"result",
|
|
"grassland",
|
|
"wetland",
|
|
"woodland",
|
|
"basicGrassland",
|
|
"dyColumn",
|
|
"geom",
|
|
"wkt",
|
|
"geometry",
|
|
"the_geom",
|
|
"theGeom",
|
|
"shape",
|
|
"shape_leng",
|
|
"shape_length",
|
|
"length",
|
|
"perimeter",
|
|
"gid",
|
|
"id",
|
|
"objectid",
|
|
"object_id",
|
|
"fid",
|
|
"key_uid",
|
|
"bsm",
|
|
"ysdm",
|
|
].map((key) => key.toLowerCase()))
|
|
|
|
const activeTopic = computed(() => getTopicConfig(activeTopicKey.value))
|
|
const activeTopicLayers = computed(() => getLayersByTopic(activeTopicKey.value))
|
|
const overviewLayerOptions = computed(() => overviewLayerDefinitions
|
|
.map((item) => createOverviewLayerOption(item))
|
|
.filter(Boolean)
|
|
)
|
|
const layerPanelLayers = computed(() => activeTopicKey.value === "overview" ? overviewLayerOptions.value : activeTopicLayers.value)
|
|
const layerPanelValue = computed({
|
|
get() {
|
|
return activeTopicKey.value === "overview" ? overviewActiveLayerKeys.value : activeLayerKey.value
|
|
},
|
|
set(value) {
|
|
if (activeTopicKey.value === "overview") {
|
|
overviewActiveLayerKeys.value = Array.isArray(value) ? value.map(String).filter(Boolean) : []
|
|
return
|
|
}
|
|
activeLayerKey.value = String(value || "")
|
|
},
|
|
})
|
|
const baseActiveLayer = computed(() => activeLayerKey.value ? getLayerByKey(activeTopicKey.value, activeLayerKey.value) : null)
|
|
const ndviTimelineActive = computed(() => activeTopicKey.value === "grassland" && activeLayerKey.value === "grassland-health")
|
|
const ndviTimelineVisible = computed(() => ndviTimelineActive.value)
|
|
const selectedNdviTimelineItem = computed(() =>
|
|
ndviTimelineRows.value.find((item) => item.key === selectedNdviTimelineKey.value) || null
|
|
)
|
|
const selectedLegend = computed(() =>
|
|
(baseActiveLayer.value?.legends || []).find((item) => item.key === selectedLegendKey.value) || null
|
|
)
|
|
const overviewLayerLegends = computed(() => overviewLayerOptions.value.flatMap((layer) => layer.legends || []))
|
|
const activeLayerLegends = computed(() => activeTopicKey.value === "overview" ? overviewLayerLegends.value : baseActiveLayer.value?.legends || [])
|
|
const layerPanelVisible = computed(() => layerPanelLayers.value.length > 0)
|
|
const layerPanelCompact = computed(() =>
|
|
layerPanelLayers.value.length <= 6
|
|
&& activeLayerLegends.value.length <= 8
|
|
)
|
|
const layerPanelHeight = computed(() => {
|
|
if (!layerPanelCompact.value) return 430
|
|
const layerCount = Math.max(1, layerPanelLayers.value.length)
|
|
const legendRows = Math.max(1, Math.ceil(activeLayerLegends.value.length / 2))
|
|
const layerArea = layerCount * 32
|
|
const legendArea = 24 + legendRows * 28
|
|
return Math.max(228, Math.min(390, 48 + layerArea + legendArea))
|
|
})
|
|
const activeLayer = computed(() => {
|
|
if (activeTopicKey.value === "overview") {
|
|
const selectedKeys = new Set(overviewActiveLayerKeys.value)
|
|
const selectedLayers = overviewLayerOptions.value.filter((layer) => selectedKeys.has(layer.key))
|
|
return createLayerStack(selectedLayers)
|
|
}
|
|
const layer = baseActiveLayer.value
|
|
if (!layer) return null
|
|
let visibleLayer = selectedLegend.value?.cqlFilter
|
|
? {
|
|
...layer,
|
|
cqlFilter: selectedLegend.value.cqlFilter,
|
|
activeLegend: selectedLegend.value,
|
|
name: selectedLegend.value.name || layer.name,
|
|
}
|
|
: layer
|
|
const timelineItem = selectedNdviTimelineItem.value
|
|
if (ndviTimelineActive.value && timelineItem?.layerName) {
|
|
visibleLayer = {
|
|
...visibleLayer,
|
|
layerName: timelineItem.layerName,
|
|
name: `${layer.name} ${timelineItem.label}`,
|
|
timelineDate: timelineItem.label,
|
|
timelineItem,
|
|
}
|
|
}
|
|
return visibleLayer
|
|
})
|
|
const screenViewportHeight = ref(typeof window === "undefined" ? 1080 : window.innerHeight)
|
|
const sideCardWidth = SCREEN2_LAYOUT.sideCardWidth
|
|
const panelGap = computed(() => SCREEN2_LAYOUT.sideCardGap)
|
|
const panelHeight = computed(() => getScreen2SideCardHeight(screenViewportHeight.value))
|
|
const doublePanelHeight = computed(() => getScreen2SpanHeight(panelHeight.value, 2))
|
|
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 topLimit = 104
|
|
const verticalGap = 36
|
|
const x = Math.min(Math.max(point.x - panelWidth / 2, margin), 1920 - panelWidth - margin)
|
|
const y = Math.min(Math.max(point.y - panelHeight - verticalGap, topLimit), 1080 - panelHeight - margin)
|
|
return {
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
right: "auto",
|
|
bottom: "auto",
|
|
}
|
|
})
|
|
|
|
function toScreenLocalPoint(point) {
|
|
const screen = document.querySelector(".forest-grass-wet")
|
|
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 createOverviewLayerOption({ topicKey, layerKey, name, legendName }) {
|
|
const layer = getLayerByKey(topicKey, layerKey)
|
|
if (!layer || layer.key !== layerKey) return null
|
|
const firstLegend = layer.legends?.[0] || {}
|
|
return {
|
|
...layer,
|
|
name,
|
|
overviewTopicKey: topicKey,
|
|
legends: [
|
|
{
|
|
...firstLegend,
|
|
key: `overview-${layerKey}`,
|
|
name: legendName,
|
|
selectable: false,
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
function createLayerStack(layers = []) {
|
|
const validLayers = layers.filter(Boolean)
|
|
if (!validLayers.length) return null
|
|
const [primaryLayer, ...overlayLayers] = validLayers
|
|
return {
|
|
...primaryLayer,
|
|
overlayLayers,
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
updateScreenViewportHeight()
|
|
window.addEventListener("resize", updateScreenViewportHeight)
|
|
document.addEventListener("pointerdown", handleOutsideFeaturePointerDown, true)
|
|
initAssets()
|
|
loadBusinessStats(activeTopicKey.value, "")
|
|
loadMapRanking(activeTopicKey.value, "")
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
destroyed = true
|
|
if (mapLoadTimer) window.clearTimeout(mapLoadTimer)
|
|
window.removeEventListener("resize", updateScreenViewportHeight)
|
|
document.removeEventListener("pointerdown", handleOutsideFeaturePointerDown, true)
|
|
})
|
|
|
|
watch(activeTopicKey, (topicKey) => {
|
|
resettingTopicLayer = true
|
|
selectedLegendKey.value = ""
|
|
activeLayerKey.value = ""
|
|
if (topicKey === "overview") overviewActiveLayerKeys.value = []
|
|
closeFeaturePanel()
|
|
layerStatus.value = {
|
|
type: "idle",
|
|
text: "等待 GeoServer 图层加载",
|
|
}
|
|
loadBusinessStats(topicKey, "")
|
|
loadMapRanking(topicKey, "", null)
|
|
nextTick(() => {
|
|
resettingTopicLayer = false
|
|
})
|
|
})
|
|
|
|
watch(activeLayerKey, () => {
|
|
if (resettingTopicLayer) return
|
|
resettingLegend = true
|
|
selectedLegendKey.value = ""
|
|
closeFeaturePanel()
|
|
layerStatus.value = {
|
|
type: activeLayerKey.value ? "loading" : "idle",
|
|
text: activeLayerKey.value ? "正在加载 GeoServer 图层" : "等待 GeoServer 图层加载",
|
|
}
|
|
if (activeTopicKey.value !== "woodland") {
|
|
loadBusinessStats(activeTopicKey.value, activeLayerKey.value)
|
|
}
|
|
loadMapRanking(activeTopicKey.value, activeLayerKey.value, null)
|
|
nextTick(() => {
|
|
resettingLegend = false
|
|
})
|
|
})
|
|
|
|
watch(overviewActiveLayerKeys, () => {
|
|
if (activeTopicKey.value !== "overview") return
|
|
selectedLegendKey.value = ""
|
|
closeFeaturePanel()
|
|
layerStatus.value = {
|
|
type: overviewActiveLayerKeys.value.length ? "loading" : "idle",
|
|
text: overviewActiveLayerKeys.value.length ? "正在加载 GeoServer 图层" : "等待 GeoServer 图层加载",
|
|
}
|
|
mapRanking.value = createEmptyMapRanking("overview", overviewActiveLayerKeys.value.join(","))
|
|
}, { deep: true })
|
|
|
|
watch(selectedLegendKey, () => {
|
|
if (resettingTopicLayer || resettingLegend) return
|
|
closeFeaturePanel()
|
|
loadMapRanking(activeTopicKey.value, activeLayerKey.value, selectedLegend.value)
|
|
})
|
|
|
|
watch(ndviTimelineActive, (active) => {
|
|
if (active) {
|
|
loadNdviTimeline()
|
|
return
|
|
}
|
|
selectedNdviTimelineKey.value = ""
|
|
})
|
|
|
|
watch(selectedNdviTimelineKey, () => {
|
|
if (!ndviTimelineActive.value) return
|
|
closeFeaturePanel()
|
|
})
|
|
|
|
watch(
|
|
() => props.active,
|
|
(active) => {
|
|
if (!mapSceneRef.value) return
|
|
if (active) {
|
|
scheduleMapLoadIfActive()
|
|
mapSceneRef.value.resume?.()
|
|
} else {
|
|
mapSceneRef.value.pause?.()
|
|
}
|
|
},
|
|
)
|
|
|
|
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 clearMapLoadTimer() {
|
|
if (!mapLoadTimer) return
|
|
window.clearTimeout(mapLoadTimer)
|
|
mapLoadTimer = null
|
|
}
|
|
|
|
function refreshMapView() {
|
|
mapSceneRef.value?.resume?.()
|
|
mapSceneRef.value?.refreshView?.()
|
|
}
|
|
|
|
function resetMap() {
|
|
closeFeaturePanel()
|
|
layerStatus.value = {
|
|
type: "loading",
|
|
text: "地图正在复位",
|
|
}
|
|
if (mapSceneRef.value?.hasMap?.()) {
|
|
refreshMapView()
|
|
layerStatus.value = {
|
|
type: "success",
|
|
text: "地图已复位",
|
|
}
|
|
return
|
|
}
|
|
clearMapLoadTimer()
|
|
mapLoadStarted = false
|
|
nextTick(() => {
|
|
scheduleMapLoadIfActive()
|
|
})
|
|
}
|
|
|
|
defineExpose({
|
|
refreshMapView,
|
|
resetMap,
|
|
})
|
|
|
|
async function loadBusinessStats(topicKey, layerKey = activeLayerKey.value) {
|
|
const businessLayerKey = topicKey === "woodland" ? "" : layerKey
|
|
const requestId = ++businessRequestId
|
|
coreLoading.value = true
|
|
coreStatus.value = "loading"
|
|
const result = await getTopicBusinessStats(topicKey, businessLayerKey)
|
|
if (destroyed || requestId !== businessRequestId) return
|
|
if (topicKey !== activeTopicKey.value) return
|
|
if (topicKey !== "woodland" && (businessLayerKey || "") !== (activeLayerKey.value || "")) return
|
|
applyBusinessStats(result, topicKey)
|
|
}
|
|
|
|
async function loadNdviTimeline(force = false) {
|
|
if (!ndviTimelineActive.value) return
|
|
if (!force && ndviTimelineRows.value.length) {
|
|
ensureSelectedNdviTimeline()
|
|
return
|
|
}
|
|
const requestId = ++ndviTimelineRequestId
|
|
ndviTimelineStatus.value = "loading"
|
|
try {
|
|
const rows = await getGrasslandNdviTimeline()
|
|
if (destroyed || requestId !== ndviTimelineRequestId) return
|
|
ndviTimelineRows.value = rows
|
|
ndviTimelineStatus.value = rows.length ? "success" : "empty"
|
|
ensureSelectedNdviTimeline()
|
|
} catch (error) {
|
|
if (destroyed || requestId !== ndviTimelineRequestId) return
|
|
ndviTimelineRows.value = []
|
|
selectedNdviTimelineKey.value = ""
|
|
ndviTimelineStatus.value = "error"
|
|
}
|
|
}
|
|
|
|
function ensureSelectedNdviTimeline() {
|
|
if (!ndviTimelineRows.value.length) {
|
|
selectedNdviTimelineKey.value = ""
|
|
return
|
|
}
|
|
const existing = ndviTimelineRows.value.some((item) => item.key === selectedNdviTimelineKey.value)
|
|
if (!existing) {
|
|
selectedNdviTimelineKey.value = ndviTimelineRows.value[ndviTimelineRows.value.length - 1].key
|
|
}
|
|
}
|
|
|
|
function applyBusinessStats(result, topicKey) {
|
|
const selected = selectTopicBusinessStats(result)
|
|
coreStats.value = selected.core || []
|
|
businessLeft.value = selected.left || { title: "业务统计", rows: [] }
|
|
businessRight.value = selected.right || { title: "业务统计", rows: [] }
|
|
leftPanels.value = selected.leftPanels?.length ? selected.leftPanels : [businessLeft.value]
|
|
rightPanels.value = selected.rightPanels?.length ? selected.rightPanels : [businessRight.value]
|
|
coreStatus.value = selected.status
|
|
coreLoading.value = false
|
|
emit("topic-stats", {
|
|
topicKey,
|
|
topicName: getTopicConfig(topicKey).name,
|
|
items: (selected.top?.length ? selected.top : selected.core || []).slice(0, 4),
|
|
})
|
|
}
|
|
|
|
function selectTopicBusinessStats(result) {
|
|
return result || {}
|
|
}
|
|
|
|
function getPanelRenderKey(panel, side) {
|
|
return `${activeTopicKey.value}-${side}-${panel?.key || panel?.title || "panel"}`
|
|
}
|
|
|
|
function isDoublePanel(panel) {
|
|
const rowCount = panel?.rows?.length || 0
|
|
if (panel?.span === 1) return false
|
|
if (panel?.span === 2) return true
|
|
if (panel?.type === "spotlight") return false
|
|
if (panel?.type === "metric") return false
|
|
if (panel?.type === "column" && rowCount <= 5) return false
|
|
if (panel?.type === "rank" && rowCount > 4) return true
|
|
if (panel?.type === "split" && rowCount > 4) return true
|
|
if (panel?.type === "donut" && rowCount > 5) return true
|
|
if (panel?.type === "structure" && rowCount > 4) return true
|
|
if (panel?.type === "bar" && rowCount > 4) return true
|
|
if (panel?.type === "line" && rowCount > 6) return true
|
|
return false
|
|
}
|
|
|
|
function getPanelHeight(panel) {
|
|
return isDoublePanel(panel) ? doublePanelHeight.value : panelHeight.value
|
|
}
|
|
|
|
function estimateFeatureRowCount(feature) {
|
|
const properties = feature?.properties || {}
|
|
const keys = Object.keys(properties).filter((key) => isDisplayableFeatureProperty(key, properties[key]))
|
|
return keys.length
|
|
}
|
|
|
|
function isDisplayableFeatureProperty(key, value) {
|
|
if (value === undefined || value === null || value === "") return false
|
|
if (Array.isArray(value) || typeof value === "object") return false
|
|
return !nonBusinessFeatureKeys.has(String(key || "").toLowerCase())
|
|
}
|
|
|
|
function getPointQueryLayers() {
|
|
if (activeTopicKey.value === "overview") {
|
|
const selectedKeys = new Set(overviewActiveLayerKeys.value)
|
|
return overviewLayerOptions.value
|
|
.filter((layer) => selectedKeys.has(layer.key))
|
|
.slice()
|
|
.reverse()
|
|
}
|
|
return baseActiveLayer.value ? [baseActiveLayer.value] : []
|
|
}
|
|
|
|
function getPointQueryType(layer = {}) {
|
|
const topicKey = String(layer.overviewTopicKey || activeTopicKey.value || "").toLowerCase()
|
|
const signal = `${layer.key || ""} ${layer.layerName || ""} ${topicKey}`.toLowerCase()
|
|
if (signal.includes("basic-grassland") || signal.includes("t_basic_grassland")) return "basicGrassland"
|
|
if (topicKey === "woodland" || signal.includes("woodland") || signal.includes("t_woodland")) return "woodland"
|
|
if (topicKey === "wetland" || signal.includes("wetland") || signal.includes("shidi")) return "wetland"
|
|
if (topicKey === "grassland" || signal.includes("grassland") || signal.includes("caodi")) return "grassland"
|
|
return ""
|
|
}
|
|
|
|
function buildPointWkt(lngLat = {}) {
|
|
const lng = formatWktCoordinate(lngLat.lng)
|
|
const lat = formatWktCoordinate(lngLat.lat)
|
|
if (lng === "" || lat === "") return ""
|
|
return `POINT(${lng} ${lat})`
|
|
}
|
|
|
|
function formatWktCoordinate(value) {
|
|
const number = Number(value)
|
|
if (!Number.isFinite(number)) return ""
|
|
return number.toFixed(8).replace(/\.?0+$/, "")
|
|
}
|
|
|
|
async function handleMapClick(payload = {}) {
|
|
const lng = Number(payload?.lngLat?.lng)
|
|
const lat = Number(payload?.lngLat?.lat)
|
|
const queryLayers = getPointQueryLayers()
|
|
if (!Number.isFinite(lng) || !Number.isFinite(lat) || !queryLayers.length) {
|
|
closeFeaturePanel()
|
|
return
|
|
}
|
|
|
|
const requestId = ++featureQueryRequestId
|
|
const clickScreen = payload.screen || { x: 960, y: 540 }
|
|
featurePanelPoint.value = null
|
|
selectedFeature.value = null
|
|
featureEmpty.value = false
|
|
featureLoading.value = true
|
|
mapSceneRef.value?.clearSelectedFeature?.()
|
|
|
|
try {
|
|
for (const layer of queryLayers) {
|
|
const feature = await queryMagicPointFeature(layer, { lng, lat })
|
|
if (requestId !== featureQueryRequestId || destroyed) return
|
|
if (feature) {
|
|
selectedFeature.value = feature
|
|
featurePanelPoint.value = clickScreen
|
|
featureEmpty.value = false
|
|
mapSceneRef.value?.highlightFeature?.(feature)
|
|
return
|
|
}
|
|
}
|
|
if (requestId === featureQueryRequestId) closeFeaturePanel()
|
|
} catch (error) {
|
|
if (requestId === featureQueryRequestId) closeFeaturePanel()
|
|
} finally {
|
|
if (requestId === featureQueryRequestId) {
|
|
featureLoading.value = false
|
|
}
|
|
}
|
|
}
|
|
|
|
async function queryMagicPointFeature(layer, lngLat) {
|
|
const queryType = getPointQueryType(layer)
|
|
if (!queryType) return null
|
|
const wkt = buildPointWkt(lngLat)
|
|
let detail = null
|
|
if (queryType === "grassland") {
|
|
detail = await getLandClickInfo({ wkt })
|
|
} else if (queryType === "wetland") {
|
|
detail = await getWetlandClickInfo({ wkt })
|
|
} else if (queryType === "woodland") {
|
|
detail = await getWoodlandClickInfoByWkt(wkt)
|
|
} else if (queryType === "basicGrassland") {
|
|
detail = await getBasicGrasslandClickInfoByWkt(wkt)
|
|
}
|
|
const item = pickMagicPointDetail(detail, queryType)
|
|
if (!hasMagicPointDetail(item)) return null
|
|
const feature = normalizeMagicPointFeature(item, layer, lngLat, queryType)
|
|
return estimateFeatureRowCount(feature) > 0 ? feature : null
|
|
}
|
|
|
|
function pickMagicPointDetail(payload, queryType) {
|
|
const candidates = [
|
|
payload?.[queryType],
|
|
payload?.grassland,
|
|
payload?.wetland,
|
|
payload?.woodland,
|
|
payload?.basicGrassland,
|
|
payload?.data?.[queryType],
|
|
payload?.data?.grassland,
|
|
payload?.data?.wetland,
|
|
payload?.data?.woodland,
|
|
payload?.data?.basicGrassland,
|
|
payload?.data,
|
|
payload?.result,
|
|
payload,
|
|
]
|
|
for (const candidate of candidates) {
|
|
const item = pickFirstObject(candidate)
|
|
if (item) return item
|
|
}
|
|
return null
|
|
}
|
|
|
|
function pickFirstObject(value) {
|
|
if (Array.isArray(value)) return value.find((item) => item && typeof item === "object") || null
|
|
return value && typeof value === "object" ? value : null
|
|
}
|
|
|
|
function hasMagicPointDetail(detail) {
|
|
if (!detail || typeof detail !== "object") return false
|
|
if (Array.isArray(detail.dyColumn) && detail.dyColumn.some(hasMagicColumnValue)) return true
|
|
return Object.keys(detail).some((key) => isDisplayableFeatureProperty(key, detail[key]))
|
|
}
|
|
|
|
function hasMagicColumnValue(column = {}) {
|
|
const value = column?.value
|
|
if (value === undefined || value === null || value === "") return false
|
|
return Boolean(String(column?.field || column?.title || "").trim())
|
|
}
|
|
|
|
function normalizeMagicPointFeature(detail, layer, lngLat, queryType) {
|
|
const properties = normalizeMagicPointProperties(detail)
|
|
const geometry = pickMagicGeometry(detail)
|
|
return {
|
|
id: detail.id || detail.patchNo || detail.patch_no || detail.landNumber || detail.land_number || `${queryType}-${lngLat.lng}-${lngLat.lat}`,
|
|
properties,
|
|
feature: {
|
|
type: "Feature",
|
|
properties,
|
|
geometry,
|
|
},
|
|
geometry,
|
|
layer,
|
|
lngLat,
|
|
}
|
|
}
|
|
|
|
function pickMagicGeometry(detail = {}) {
|
|
return detail.geometry
|
|
|| detail.geom
|
|
|| detail.wkt
|
|
|| detail.the_geom
|
|
|| detail.theGeom
|
|
|| detail.geo
|
|
|| detail.geometryWkt
|
|
|| detail.geometry_wkt
|
|
|| detail.shape
|
|
|| null
|
|
}
|
|
|
|
function normalizeMagicPointProperties(detail = {}) {
|
|
const properties = { ...detail }
|
|
const dyColumns = Array.isArray(detail.dyColumn) ? detail.dyColumn : []
|
|
dyColumns.forEach((column) => {
|
|
const value = column?.value
|
|
if (value === undefined || value === null || value === "") return
|
|
const titleKey = magicDetailTitleFieldMap[String(column?.title || "").trim()]
|
|
const fieldKey = String(column?.field || "").trim()
|
|
const keys = [fieldKey, camelToSnake(fieldKey), titleKey].filter(Boolean)
|
|
keys.forEach((key) => {
|
|
if (properties[key] === undefined || properties[key] === null || properties[key] === "") {
|
|
properties[key] = value
|
|
}
|
|
})
|
|
})
|
|
return properties
|
|
}
|
|
|
|
function camelToSnake(value) {
|
|
return String(value || "").replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`)
|
|
}
|
|
|
|
async function loadMapRanking(topicKey, layerKey, legend = selectedLegend.value) {
|
|
const requestId = ++rankingRequestId
|
|
if (!layerKey || !shouldShowMapRanking(layerKey, legend)) {
|
|
mapRanking.value = createEmptyMapRanking(topicKey, layerKey)
|
|
return
|
|
}
|
|
const result = await getLayerTownRanking(topicKey, layerKey, legend)
|
|
if (destroyed || requestId !== rankingRequestId) return
|
|
if (topicKey !== activeTopicKey.value || layerKey !== activeLayerKey.value) return
|
|
if ((legend?.key || "") !== selectedLegendKey.value) return
|
|
mapRanking.value = {
|
|
...result,
|
|
hideTownLabelStats: true,
|
|
}
|
|
}
|
|
|
|
function shouldShowMapRanking(layerKey, legend = null) {
|
|
return Boolean(legend?.key) || mapRankingLayerKeys.has(layerKey)
|
|
}
|
|
|
|
function createEmptyMapRanking(topicKey, layerKey) {
|
|
return {
|
|
status: "disabled",
|
|
topicKey,
|
|
layerKey,
|
|
title: "",
|
|
unit: "",
|
|
hideTownLabelStats: true,
|
|
rows: [],
|
|
}
|
|
}
|
|
|
|
function handleFeatureInfo(payload) {
|
|
const feature = payload?.feature ?? payload ?? null
|
|
if (!feature || estimateFeatureRowCount(feature) <= 0) {
|
|
closeFeaturePanel()
|
|
return
|
|
}
|
|
selectedFeature.value = feature
|
|
featureEmpty.value = false
|
|
if (payload?.screen) {
|
|
featurePanelPoint.value = payload.screen
|
|
}
|
|
}
|
|
|
|
function handleLayerStatus(status) {
|
|
layerStatus.value = status
|
|
}
|
|
|
|
function closeFeaturePanel() {
|
|
featureQueryRequestId += 1
|
|
selectedFeature.value = null
|
|
featureEmpty.value = false
|
|
featureLoading.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(".forest-grass-wet .loading", {
|
|
opacity: 0,
|
|
duration: 0.8,
|
|
delay: 0.2,
|
|
onComplete: () => {
|
|
loadingVisible.value = false
|
|
resolve()
|
|
},
|
|
})
|
|
})
|
|
}
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.forest-grass-wet {
|
|
position: absolute;
|
|
inset: 0;
|
|
height: 100%;
|
|
overflow: clip;
|
|
overscroll-behavior: none;
|
|
|
|
> .map {
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
> canvas {
|
|
display: block;
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
}
|
|
|
|
> div {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
}
|
|
}
|
|
|
|
&-ui {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 4;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.fgw-left,
|
|
.fgw-right {
|
|
position: absolute;
|
|
top: 128px;
|
|
bottom: 8px;
|
|
z-index: 6;
|
|
width: 398px;
|
|
perspective-origin: 50% 50%;
|
|
pointer-events: all;
|
|
}
|
|
|
|
.fgw-left {
|
|
left: 32px;
|
|
perspective: var(--screen-side-perspective-left, 500px);
|
|
}
|
|
|
|
.fgw-right {
|
|
right: 32px;
|
|
perspective: var(--screen-side-perspective-right, 800px);
|
|
}
|
|
|
|
.fgw-side-stack {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
transform-style: preserve-3d;
|
|
|
|
&.is-left {
|
|
transform: translate3d(0, 0, 0) rotateY(6deg);
|
|
transform-origin: left center;
|
|
}
|
|
|
|
&.is-right {
|
|
transform: translate3d(0, 0, 0) rotateY(-6deg);
|
|
transform-origin: right center;
|
|
}
|
|
}
|
|
|
|
.fgw-side-card {
|
|
flex: 0 0 auto;
|
|
|
|
.m-card-bd-content {
|
|
top: 36px;
|
|
bottom: 0;
|
|
color: #f3feff;
|
|
}
|
|
|
|
}
|
|
|
|
.fgw-map-layer-tools,
|
|
.fgw-map-feature-info {
|
|
position: absolute;
|
|
z-index: 7;
|
|
pointer-events: all;
|
|
|
|
.m-card {
|
|
background: rgba(3, 18, 30, 0.54);
|
|
backdrop-filter: blur(2px);
|
|
}
|
|
}
|
|
|
|
.fgw-map-layer-tools {
|
|
top: auto;
|
|
right: 458px;
|
|
left: auto;
|
|
bottom: 72px;
|
|
width: 248px;
|
|
height: 430px;
|
|
|
|
.m-card {
|
|
width: 100% !important;
|
|
height: 100% !important;
|
|
}
|
|
}
|
|
|
|
.fgw-ndvi-timeline {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 112px;
|
|
z-index: 7;
|
|
transform: translateX(-50%);
|
|
pointer-events: all;
|
|
}
|
|
|
|
.fgw-map-feature-info {
|
|
right: 456px;
|
|
bottom: 150px;
|
|
}
|
|
|
|
.fgw-bottom-topic {
|
|
position: absolute;
|
|
left: 50%;
|
|
bottom: 48px;
|
|
z-index: 8;
|
|
transform: translateX(-50%);
|
|
pointer-events: all;
|
|
}
|
|
}
|
|
</style>
|
|
|