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

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