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.
 
 
 
 
 
 

684 lines
18 KiB

<template>
<div class="yak-industry-chain">
<map-scene
ref="mapSceneRef"
class="industry-map-scene"
:active-layer="industryMapLayer"
:map-ranking="mapRanking"
@feature-info="handleMapFeatureInfo"
/>
<div class="industry-map-scrim"></div>
<div v-if="!mapReady" class="industry-map-loading">3D地图加载中</div>
<div v-if="leftStagePanels.length" class="fgw-left industry-side-panel industry-side-panel--left">
<div class="fgw-side-stack industry-side-stack is-left">
<m-card
v-for="panel in leftStagePanels"
:key="panel.key"
class="fgw-side-card industry-side-card left-card"
:title="panel.title"
:width="sideCardWidth"
:height="panel.height"
>
<business-panel-renderer
v-if="panel.renderer === 'business'"
:panel="panel"
compact
@row-select="handleIndustryListSelect"
/>
<div v-else-if="panel.renderer === 'ledger'" class="industry-ledger-list">
<div v-for="item in panel.rows" :key="item.key" class="industry-ledger-row">
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.typeName }} / {{ item.town }}</span>
</div>
<em>{{ item.valueText }}</em>
</div>
</div>
</m-card>
</div>
</div>
<div v-if="rightStagePanels.length" class="fgw-right industry-side-panel industry-side-panel--right">
<div class="fgw-side-stack industry-side-stack is-right">
<m-card
v-for="panel in rightStagePanels"
:key="panel.key"
class="fgw-side-card industry-side-card right-card"
:title="panel.title"
:width="sideCardWidth"
:height="panel.height"
>
<business-panel-renderer
v-if="panel.renderer === 'business'"
:panel="panel"
compact
@row-select="handleIndustryListSelect"
/>
<div v-else-if="panel.renderer === 'ledger'" class="industry-ledger-list">
<div v-for="item in panel.rows" :key="item.key" class="industry-ledger-row">
<div>
<strong>{{ item.name }}</strong>
<span>{{ item.typeName }} / {{ item.town }}</span>
</div>
<em>{{ item.valueText }}</em>
</div>
</div>
</m-card>
</div>
</div>
<div v-if="selectedPoint" class="industry-point-detail" :style="pointDetailStyle">
<div class="industry-point-detail__head">
<span>{{ selectedPointTitle }}</span>
<button type="button" title="关闭" @click="clearSelectedPoint">×</button>
</div>
<div class="industry-point-detail__name">{{ selectedPointName }}</div>
<div class="industry-point-detail__grid">
<div v-for="row in selectedPointRows" :key="row.label" class="industry-point-detail__row">
<span>{{ row.label }}</span>
<strong>{{ row.value }}</strong>
</div>
</div>
</div>
<div class="industry-bottom-switcher">
<topic-switcher
:model-value="activeStageKey"
:topics="stageSwitcherTopics"
@update:model-value="handleStageSelect"
/>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
import mCard from "@/components/mCard/index.vue"
import MapScene from "@/views/forestGrassWet/map.vue"
import BusinessPanelRenderer from "@/views/forestGrassWet/components/BusinessPanelRenderer.vue"
import TopicSwitcher from "@/views/forestGrassWet/components/TopicSwitcher.vue"
import { Assets } from "@/views/forestGrassWet/assets"
import { getLayerByKey } from "@/config/layers"
import {
getStageDef,
getStagePanelConfig,
getPointExtraRows,
industryStageDefs,
getStageTopStats,
} from "./data"
const props = defineProps({
active: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(["module-stats", "loading-progress", "map-ready"])
const sideCardWidth = 398
const activeStageKey = ref("family-pasture")
const mapSceneRef = ref(null)
const mapReady = ref(false)
const selectedPoint = ref(null)
const selectedPointScreen = ref({ x: 0, y: 0 })
let mapAssets = null
let mapAssetsReady = false
let mapLoadStarted = false
let mapLoadTimer = null
let destroyed = false
const activeStage = computed(() => getStageDef(activeStageKey.value))
const activePanelConfig = computed(() => getStagePanelConfig(activeStageKey.value))
const stagePanelGroups = computed(() => normalizeStagePanelGroups(activePanelConfig.value))
const leftStagePanels = computed(() => stagePanelGroups.value.left)
const rightStagePanels = computed(() => stagePanelGroups.value.right)
const industryMapLayer = computed(() => {
const layer = getLayerByKey("yak-industry-chain", "yak-industry-points")
const legend = layer?.legends?.find((item) => item.match?.value === activeStageKey.value)
if (!layer) return layer
return {
...layer,
activeLegend: legend || {
key: `yak-industry-empty-${activeStageKey.value}`,
name: activeStage.value.name,
fillColor: activeStage.value.color,
strokeColor: activeStage.value.color,
match: { field: "stageKey", value: activeStageKey.value },
},
}
})
const stageSwitcherTopics = computed(() =>
industryStageDefs.map((stage) => ({
key: stage.key,
name: stage.name,
color: stage.color,
width: stage.width,
}))
)
const mapRanking = computed(() => ({
showTownNames: true,
showBars: false,
showPointMarkers: true,
title: `${activeStage.value.name}排行`,
metricLabel: activeStage.value.metricLabel,
unit: activeStage.value.unit,
rows: [],
}))
const selectedPointRows = computed(() => {
const point = selectedPoint.value
if (!point) return []
return [
{ label: "所属环节", value: formatDisplayText(point.stageName) },
{ label: "主体类型", value: formatDisplayText(point.categoryName) },
{ label: "所在乡镇", value: formatDisplayText(point.town) },
{ label: "能力规模", value: formatCapacity(point) },
{ label: "运行状态", value: formatDisplayText(point.status) },
...getPointExtraRows(point),
].filter((row) => row.value !== undefined && row.value !== null && String(row.value).trim() !== "")
})
const selectedPointTitle = computed(() => formatDisplayText(selectedPoint.value?.categoryName || selectedPoint.value?.stageName))
const selectedPointName = computed(() => formatDisplayText(selectedPoint.value?.name))
const pointDetailStyle = computed(() => {
const panelWidth = 430
const panelHeight = 252
const margin = 24
const pointerGap = 28
const point = {
x: Math.max(margin, Math.min(1920 - margin, Number(selectedPointScreen.value.x || 0))),
y: Math.max(margin, Math.min(1080 - margin, Number(selectedPointScreen.value.y || 0))),
}
const placeLeft = point.x > 1320
const x = placeLeft
? Math.max(margin, point.x - panelWidth - pointerGap)
: Math.min(1920 - panelWidth - margin, point.x + pointerGap)
const y = Math.min(Math.max(point.y - panelHeight / 2, 128), 1080 - panelHeight - 116)
const arrowTop = Math.min(Math.max(point.y - y, 42), panelHeight - 38)
return {
left: `${x}px`,
top: `${y}px`,
"--point-arrow-top": `${arrowTop}px`,
"--point-arrow-side": placeLeft ? "100%" : "0px",
"--point-arrow-offset": placeLeft ? "-1px" : "-9px",
"--point-arrow-rotate": placeLeft ? "-45deg" : "135deg",
}
})
onMounted(() => {
emitIndustryTopStats()
initIndustryMap()
})
onBeforeUnmount(() => {
destroyed = true
if (mapLoadTimer) window.clearTimeout(mapLoadTimer)
mapSceneRef.value?.destroyMap?.()
})
watch(
() => props.active,
(active) => {
if (!mapSceneRef.value) return
if (active) {
scheduleIndustryMapLoad()
mapSceneRef.value.resume?.()
} else {
mapSceneRef.value.pause?.()
}
},
)
function handleStageSelect(key) {
if (key === activeStageKey.value) return
activeStageKey.value = key
clearSelectedPoint()
emitIndustryTopStats(key)
}
function normalizeStagePanelGroups(config = {}) {
const left = filterDisplayPanels(config.left)
const right = filterDisplayPanels(config.right)
if (right.length) {
return { left, right }
}
const panels = [...left, ...right]
if (panels.length <= 4) {
return { left: panels, right: [] }
}
return { left, right }
}
function filterDisplayPanels(panels = []) {
return panels.filter((panel) => panel?.type !== "iconMetric")
}
function emitIndustryTopStats(stageKey = activeStageKey.value) {
emit("module-stats", {
moduleKey: "yak-industry-chain",
items: getStageTopStats(stageKey),
})
}
function initIndustryMap() {
mapAssets = new Assets()
mapAssetsReady = false
emit("loading-progress", { progress: 0 })
mapAssets.instance?.on?.("onProgress", (path, itemsLoaded, itemsTotal) => {
const total = itemsTotal || 1
emit("loading-progress", {
progress: Math.floor((itemsLoaded / total) * 100),
path,
itemsLoaded,
itemsTotal,
})
})
mapAssets.ready?.then(() => {
mapAssetsReady = true
scheduleIndustryMapLoad()
}).catch(() => {
mapReady.value = true
emit("map-ready")
})
}
function scheduleIndustryMapLoad() {
if (destroyed || !props.active || !mapAssetsReady || mapLoadStarted) return
mapLoadStarted = true
if (mapLoadTimer) window.clearTimeout(mapLoadTimer)
mapLoadTimer = window.setTimeout(() => {
mapLoadTimer = null
loadIndustryMapOnce()
}, 600)
}
async function loadIndustryMapOnce(attempt = 0) {
if (destroyed) return
if (!props.active) {
mapLoadStarted = false
return
}
await nextTick()
if (!props.active) {
mapLoadStarted = false
return
}
const scene = mapSceneRef.value
scene?.loadMap?.(mapAssets)
if (scene?.canvasMap) {
mapReady.value = true
emit("loading-progress", { progress: 100 })
emit("map-ready")
scene.play?.()
return
}
if (attempt < 24) {
window.setTimeout(() => loadIndustryMapOnce(attempt + 1), 120)
return
}
mapLoadStarted = false
mapReady.value = true
emit("map-ready")
}
function handleMapFeatureInfo(payload = {}) {
const properties = payload.feature?.properties || {}
if (properties.stageKey) {
selectedPoint.value = properties
selectedPointScreen.value = normalizeFeatureScreen(payload.screen)
return
}
selectedPoint.value = null
}
async function handleIndustryListSelect(row = {}) {
if (!row.canLocate) return
const stageChanged = Boolean(row.stageKey && row.stageKey !== activeStageKey.value)
if (stageChanged) {
activeStageKey.value = row.stageKey
emitIndustryTopStats(row.stageKey)
await nextTick()
}
const point = buildListPoint(row)
selectedPoint.value = point
selectedPointScreen.value = { x: 960, y: 500 }
window.setTimeout(() => {
const focusResult = mapSceneRef.value?.focusYakIndustryPoint?.(point)
syncSelectedPointScreen(point, focusResult)
window.setTimeout(() => syncSelectedPointScreen(point), 760)
}, stageChanged ? 320 : 80)
}
function clearSelectedPoint() {
selectedPoint.value = null
const canvasMap = mapSceneRef.value?.canvasMap?.value || mapSceneRef.value?.canvasMap
canvasMap?.clearSelectedFeature?.()
}
function buildListPoint(row = {}) {
const longitude = Number(row.longitude)
const latitude = Number(row.latitude)
return {
...row,
id: row.key || row.id,
name: row.fullName || row.name,
stageKey: row.stageKey,
stageName: row.stageName || getStageDef(row.stageKey)?.name,
categoryName: row.categoryName || row.typeName,
capacity: row.value,
capacityUnit: row.unit,
longitude,
latitude,
geometry: Number.isFinite(longitude) && Number.isFinite(latitude)
? { type: "Point", coordinates: [longitude, latitude] }
: undefined,
status: row.status,
}
}
function syncSelectedPointScreen(point, focusResult = null) {
const screen = mapSceneRef.value?.getYakIndustryPointScreen?.(point) || focusResult?.screen
if (screen) selectedPointScreen.value = normalizeFeatureScreen(screen)
}
function formatCapacity(point) {
if (point.capacity === undefined || point.capacity === null || point.capacity === "") return ""
return `${point.capacity}${point.capacityUnit || ""}`
}
function formatDisplayText(value) {
return String(value ?? "")
.replace(/\bcxpt[-_./\s]*/gi, "")
.replace(/\s+/g, " ")
.trim()
}
function normalizeFeatureScreen(screen = {}) {
const root = document.querySelector("#large-screen")
const rect = root?.getBoundingClientRect?.()
const scaleX = rect?.width ? 1920 / rect.width : 1
const scaleY = rect?.height ? 1080 / rect.height : 1
const left = rect?.left || 0
const top = rect?.top || 0
return {
x: (Number(screen.x) - left) * scaleX,
y: (Number(screen.y) - top) * scaleY,
}
}
</script>
<style lang="scss">
.yak-industry-chain {
position: absolute;
inset: 0;
z-index: 1;
overflow: hidden;
color: #fff;
background: #03121c;
}
.industry-map-scene {
position: absolute;
inset: 0;
z-index: 1;
}
.industry-map-scrim {
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
background:
radial-gradient(circle at 50% 48%, rgba(4, 18, 28, 0) 0%, rgba(4, 18, 28, 0.05) 37%, rgba(2, 10, 17, 0.34) 78%, rgba(1, 7, 12, 0.62) 100%),
linear-gradient(90deg, rgba(0, 12, 20, 0.42), transparent 26%, transparent 74%, rgba(0, 12, 20, 0.42));
}
.industry-map-loading {
position: absolute;
left: 50%;
top: 50%;
z-index: 4;
transform: translate(-50%, -50%);
color: rgba(220, 250, 255, 0.78);
font-size: 18px;
}
.industry-side-panel {
position: absolute;
top: 156px;
bottom: 112px;
z-index: 6;
width: 398px;
perspective: 700px;
perspective-origin: 50% 50%;
pointer-events: all;
}
.industry-side-panel--left {
left: 32px;
}
.industry-side-panel--right {
right: 32px;
}
.industry-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;
}
}
.industry-side-card {
flex: 0 0 auto;
.m-card-bd-content {
top: 40px;
bottom: 10px;
color: #f3feff;
overflow: hidden;
}
.m-card-bd-bg {
filter: brightness(1.08) saturate(1.04);
}
.m-card-bd-bg::after {
content: "";
position: absolute;
inset: 4px 5px 6px;
z-index: 0;
pointer-events: none;
border: 1px solid rgba(48, 220, 255, 0.14);
background:
linear-gradient(180deg, rgba(17, 73, 92, 0.16), rgba(6, 29, 44, 0.14)),
linear-gradient(90deg, rgba(48, 220, 255, 0.1), transparent 42%, rgba(48, 220, 255, 0.06));
box-shadow:
inset 0 0 0 1px rgba(48, 220, 255, 0.06),
inset 0 12px 28px rgba(48, 220, 255, 0.04);
}
}
.industry-ledger-list {
display: grid;
gap: 7px;
}
.industry-ledger-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
min-height: 42px;
padding: 7px 8px;
border: 1px solid rgba(48, 220, 255, 0.1);
border-radius: 4px;
background: rgba(4, 20, 31, 0.42);
div {
min-width: 0;
}
strong,
span {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
strong {
color: rgba(241, 254, 255, 0.92);
font-size: 13px;
font-weight: 760;
}
span {
margin-top: 4px;
color: rgba(201, 237, 244, 0.58);
font-size: 11px;
}
em {
color: #ffe06b;
font-size: 12px;
font-style: normal;
font-weight: 800;
white-space: nowrap;
}
}
.industry-point-detail {
position: absolute;
z-index: 7;
width: 430px;
padding: 18px 20px 20px;
border: 1px solid rgba(48, 220, 255, 0.42);
background:
linear-gradient(180deg, rgba(12, 54, 70, 0.86), rgba(5, 24, 37, 0.9)),
radial-gradient(circle at 20% 0%, rgba(36, 246, 196, 0.18), transparent 46%);
box-shadow:
0 0 24px rgba(48, 220, 255, 0.18),
inset 0 0 18px rgba(48, 220, 255, 0.08);
color: #efffff;
pointer-events: all;
}
.industry-point-detail::after {
content: "";
position: absolute;
left: var(--point-arrow-side, 0);
top: var(--point-arrow-top, 50%);
width: 16px;
height: 16px;
border-right: 1px solid rgba(48, 220, 255, 0.42);
border-bottom: 1px solid rgba(48, 220, 255, 0.42);
background: rgba(5, 24, 37, 0.9);
transform: translate(var(--point-arrow-offset, -9px), -50%) rotate(var(--point-arrow-rotate, 135deg));
}
.industry-point-detail__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #ffe06b;
font-size: 16px;
font-weight: 700;
button {
width: 26px;
height: 26px;
border: 1px solid rgba(48, 220, 255, 0.36);
border-radius: 0;
background: rgba(6, 28, 43, 0.72);
color: rgba(239, 255, 255, 0.88);
cursor: pointer;
font-size: 20px;
line-height: 20px;
}
}
.industry-point-detail__name {
margin-top: 12px;
color: #ffffff;
font-size: 22px;
font-weight: 800;
line-height: 1.35;
}
.industry-point-detail__grid {
display: grid;
gap: 8px;
margin-top: 14px;
}
.industry-point-detail__row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
min-height: 32px;
align-items: center;
border-bottom: 1px solid rgba(48, 220, 255, 0.12);
span {
color: rgba(196, 237, 244, 0.74);
font-size: 14px;
}
strong {
overflow: hidden;
color: rgba(246, 255, 255, 0.95);
font-size: 15px;
font-weight: 700;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.industry-bottom-switcher {
position: absolute;
left: 50%;
bottom: 25px;
z-index: 8;
display: flex;
align-items: center;
justify-content: center;
transform: translateX(-50%);
pointer-events: all;
.topic-switcher {
width: 1060px;
}
.topic-switcher-item {
width: var(--topic-item-width, 112px);
}
.topic-switcher-item .topic-name {
font-size: 14px;
}
.topic-switcher-item .topic-title {
font-size: 14px;
line-height: 16px;
}
.topic-switcher-item .topic-name small {
font-size: 10px;
line-height: 11px;
}
}
</style>