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.
 
 

2094 lines
54 KiB

<template>
<BaseCard class="map-card">
<!-- 顶部切换按钮 -->
<div class="map-controls">
<div class="control-buttons">
<button v-for="mode in modes" :key="mode.key" type="button" class="mode-btn"
:class="{ active: currentMode === mode.key }" @click="currentMode = mode.key">
<img class="mode-btn-bg" :src="currentMode === mode.key ? BTN_ACTIVE : BTN_NORMAL" alt="" />
<span class="mode-btn-text">{{ mode.label }}</span>
</button>
</div>
</div>
<!-- 左侧来源分析面板 -->
<div v-if="showSourcePanel && sourceData" class="source-panel floating-panel">
<h3>牦牛来源分析</h3>
<div ref="sourceChartRef" class="panel-chart"></div>
</div>
<!-- 右侧销售分析面板 -->
<div v-if="showSalesPanel && salesData" class="sales-panel floating-panel">
<h3>牦牛销售分析</h3>
<div ref="salesChartRef" class="panel-chart"></div>
</div>
<!-- 交易明细浮动框 -->
<div v-if="showDetailPanel && detailData" class="detail-panel">
<div class="detail-header">
<h3>{{ selectedCity }} - 交易明细统计</h3>
<button @click="closeDetailPanel" class="close-btn">×</button>
</div>
<div class="detail-content">
<table class="detail-table">
<thead>
<tr>
<th>检疫编号</th>
<th>货主</th>
<th>启运地点</th>
<th>到达地点</th>
<th>车牌号</th>
<th>牦牛数量</th>
<th>联系人</th>
<th>联系方式</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in detailData" :key="index">
<td>{{ item.quarantineNo }}</td>
<td>{{ item.owner }}</td>
<td>{{ item.origin }}</td>
<td>{{ item.destination }}</td>
<td>{{ item.vehiclePlate }}</td>
<td>{{ item.yakCount }}</td>
<td>{{ item.contactPerson }}</td>
<td>{{ item.contactPhone }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="map-stage" :class="{ 'map-stage--local': mapStageLocal }">
<!-- 红原模式整页 dashboard-bg 已铺底此处不重复显示避免倾斜后与背景错位 -->
<img
v-show="currentMode !== 'local'"
class="map-world-bg"
:src="mapWorldBgSrc"
alt=""
/>
<div
class="map-tilt-layer"
:class="{ 'map-tilt-layer--switching': isMapContentSwitching }"
>
<div ref="chartRef" class="chart-container"></div>
</div>
<div class="map-legend" aria-hidden="true">
<img class="map-legend-bg" :src="LEGEND_BG" alt="" />
<ul class="map-legend-list">
<li class="map-legend-item">
<span class="legend-icon legend-icon-line"></span>
<span class="legend-label">流向</span>
</li>
<li class="map-legend-item">
<span class="legend-icon legend-icon-city"></span>
<span class="legend-label">交易城市</span>
</li>
<li class="map-legend-item">
<span class="legend-icon legend-icon-node"></span>
<span class="legend-label">重要节点</span>
</li>
</ul>
</div>
</div>
</BaseCard>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import BaseCard from './BaseCard.vue'
import { loadSystemConfig, DEFAULT_MAP_HUB, DEFAULT_MAP_FLOW_LEVELS } from '../utils/systemConfig.js'
const MAP_TRADING_API = '/api/dashboard/map-trading-network'
const FALLBACK_TRADING_JSON = './yak-trading-data.json'
const emit = defineEmits(['mode-change'])
const chartRef = ref(null)
const sourceChartRef = ref(null)
const salesChartRef = ref(null)
const currentMode = ref('outflow')
const isMapContentSwitching = ref(false)
const mapStageLocal = computed(() => (
currentMode.value === 'local' && !isMapContentSwitching.value
))
let chartInstance = null
let sourceChartInstance = null
let salesChartInstance = null
let resizeObserver = null
let chinaMapData = null
let tradingData = null
let mapSystemConfig = null
let cachedOutlineKey = ''
let cachedOutlinePaths = []
let unitedMapKey = ''
const mapBgImageCache = new Map()
const mapBgImageLoading = new Map()
let mapSurfaceTexture = null
let mapSurfaceTextureDataUrl = ''
let mapSurfaceTextureKey = ''
// 浮动面板状态
const showSourcePanel = ref(false)
const showSalesPanel = ref(false)
const sourceData = ref(null)
const salesData = ref(null)
// 交易明细面板状态
const showDetailPanel = ref(false)
const detailData = ref(null)
const selectedCity = ref('')
const modes = [
{ key: 'outflow', label: '销售网络分布图' },
{ key: 'inflow', label: '源地供应分布图' },
{ key: 'local', label: '红原出栏分布图' }
]
const BTN_NORMAL = '/images/按钮.png'
const BTN_ACTIVE = '/images/按钮选中.png'
const LEGEND_BG = '/images/图例bg.png'
const GEO_DATA_INDEX = 4
const WIREFRAME_GEO_INDEX = 5
const MAP_ASPECT_SCALE = 0.82
const MAP_ZOOM_FACTOR = 1
const MAP_LAYOUT_SIZE = '88%'
const MAP_ANCHOR_CENTER = [105.2, 36.8]
const MAP_ANCHOR_ZOOM = 1.08
const MAP_GLOBAL_OFFSET_LNG = 1.2
const MAP_GLOBAL_OFFSET_LAT = 0.85
const MAP_GLOBAL_LAYOUT_X = -2.2
const MAP_GLOBAL_LAYOUT_Y = 2.8
const MAP_SHADOW_OFFSET_FAR = 1.85
const MAP_SHADOW_OFFSET_MID = 1.2
const MAP_SHADOW_OFFSET_NEAR = 0.62
const MAP_SHADOW_OFFSET_LNG = -0.42
const MAP_SHADOW_OFFSET_FAR_LOCAL = 2.6
const MAP_SHADOW_OFFSET_MID_LOCAL = 1.8
const MAP_SHADOW_OFFSET_NEAR_LOCAL = 1.0
const MAP_SHADOW_OFFSET_LNG_LOCAL = -1.5
const MAP_WIREFRAME_OFFSET = -0.72
const MAP_WIREFRAME_OFFSET_LNG = 0.1
const MAP_WIREFRAME_OFFSET_LOCAL = -1.8
const MAP_WIREFRAME_OFFSET_LNG_LOCAL = 0.6
// 国界描边:青蓝霓虹光晕(对齐设计稿,避免过白过曝)
const MAP_WIREFRAME_COLOR = '#B8ECF6'
const MAP_WIREFRAME_HALO_LAYERS = [
{ width: 10, color: 'rgba(128, 208, 228, 0.06)' },
{ width: 7, color: 'rgba(148, 218, 236, 0.12)' },
{ width: 4.6, color: 'rgba(168, 232, 246, 0.22)' }
]
const MAP_WIREFRAME_CORE_WIDTH = 2.6
const MAP_WIREFRAME_CORE_SHADOW_BLUR = 7
const MAP_WIREFRAME_CORE_SHADOW_COLOR = 'rgba(110, 200, 228, 0.22)'
const MAP_INNER_BORDER = 'rgba(1, 12, 22, 0.82)'
const MAP_INNER_BORDER_WIDTH = 0.95
const MAP_INNER_BORDER_WIDTH_LOCAL = 1.2
const MAP_SHADOW_FILL_FAR = '#010204'
const MAP_SHADOW_FILL_MID = '#020408'
const MAP_SHADOW_FILL_NEAR = '#030a12'
const MAP_LABEL_COLOR = '#ffffff'
// 世界地图 / 红原模式底图
const MAP_BG_IMAGE_WORLD = '/images/世界地图背景.png'
const MAP_BG_IMAGE_LOCAL = '/images/红原背景.png'
const MAP_BG_NATIVE_WIDTH = 4318
const MAP_BG_NATIVE_HEIGHT = 1078
const MAP_BG_CROP_CHINA = { x: 1160, y: 108, width: 2000, height: 828 }
const MAP_BG_CROP_LOCAL = { x: 1860, y: 332, width: 820, height: 520 }
const MAP_SURFACE_TEXTURE_WIDTH = 1536
const MAP_SURFACE_TEXTURE_HEIGHT = 1152
const MAP_SURFACE_FALLBACK = 'rgb(49, 183, 183)'
const MAP_SURFACE_COLOR = 'rgba(0, 0, 0, 0)'
const MAP_SURFACE_PAINT_VERSION = 5
// 全国:左亮青 → 右深蓝,地形纹路清晰(color 定色相 + 轻 overlay 提饱和)
const WORLD_SURFACE_PAINT = {
brightness: 1.22,
contrast: 1.26,
lift: {
enabled: true,
composite: 'screen',
color: 'rgba(220, 248, 244, 0.16)'
},
tint: {
composite: 'color',
mode: 'linear',
linearX0: 0,
linearY0: 0.5,
linearX1: 1,
linearY1: 0.5,
stops: [
{ stop: 0, color: 'rgb(85, 212, 205)' },
{ stop: 0.45, color: 'rgb(52, 175, 188)' },
{ stop: 1, color: 'rgb(14, 131, 184)' }
]
},
satBoost: {
enabled: true,
composite: 'overlay',
mode: 'linear',
linearX0: 0,
linearY0: 0.5,
linearX1: 1,
linearY1: 0.5,
stops: [
{ stop: 0, color: 'rgba(145, 245, 236, 0.22)' },
{ stop: 0.5, color: 'rgba(80, 205, 208, 0.12)' },
{ stop: 1, color: 'rgba(18, 135, 180, 0.16)' }
]
},
depth: { enabled: false },
softLight: { enabled: true, color: 'rgba(125, 222, 215, 0.09)' },
glow: {
enabled: true,
centerX: 0.34,
centerY: 0.44,
radius: 0.64,
stops: [
{ stop: 0, color: 'rgba(228, 252, 248, 0.22)' },
{ stop: 0.48, color: 'rgba(185, 238, 232, 0.08)' },
{ stop: 1, color: 'rgba(0, 0, 0, 0)' }
]
}
}
// 红原:中西部亮、边缘深蓝(对齐设计稿径向)
const LOCAL_SURFACE_PAINT = {
brightness: 1.22,
contrast: 1.28,
lift: {
enabled: true,
composite: 'screen',
color: 'rgba(232, 252, 248, 0.18)'
},
tint: {
composite: 'color',
mode: 'radial',
centerX: 0.3,
centerY: 0.47,
radius: 0.78,
stops: [
{ stop: 0, color: 'rgb(188, 246, 238)' },
{ stop: 0.28, color: 'rgb(82, 200, 205)' },
{ stop: 0.62, color: 'rgb(35, 150, 178)' },
{ stop: 1, color: 'rgb(14, 131, 184)' }
]
},
satBoost: {
enabled: true,
composite: 'overlay',
mode: 'radial',
centerX: 0.3,
centerY: 0.47,
radius: 0.74,
stops: [
{ stop: 0, color: 'rgba(225, 252, 246, 0.34)' },
{ stop: 0.35, color: 'rgba(98, 218, 212, 0.17)' },
{ stop: 1, color: 'rgba(14, 131, 184, 0.22)' }
]
},
depth: { enabled: false },
softLight: { enabled: true, color: 'rgba(112, 220, 212, 0.1)' },
glow: {
enabled: true,
centerX: 0.28,
centerY: 0.46,
radius: 0.5,
stops: [
{ stop: 0, color: 'rgba(255, 255, 253, 0.36)' },
{ stop: 0.5, color: 'rgba(205, 242, 236, 0.11)' },
{ stop: 1, color: 'rgba(0, 0, 0, 0)' }
]
}
}
const getMapSurfacePaintConfig = () => (
currentMode.value === 'local' ? LOCAL_SURFACE_PAINT : WORLD_SURFACE_PAINT
)
const createSurfacePaintGradient = (ctx, width, height, layer) => {
if (layer.mode === 'linear') {
return ctx.createLinearGradient(
width * layer.linearX0,
height * layer.linearY0,
width * layer.linearX1,
height * layer.linearY1
)
}
return ctx.createRadialGradient(
width * layer.centerX,
height * layer.centerY,
0,
width * layer.centerX,
height * layer.centerY,
Math.max(width, height) * layer.radius
)
}
const applySurfacePaintLayer = (ctx, width, height, layer) => {
const gradient = createSurfacePaintGradient(ctx, width, height, layer)
layer.stops.forEach(({ stop, color }) => gradient.addColorStop(stop, color))
ctx.globalCompositeOperation = layer.composite
ctx.fillStyle = gradient
ctx.fillRect(0, 0, width, height)
}
const getMapBgImageSrc = (mode = currentMode.value) => (
mode === 'local' ? MAP_BG_IMAGE_LOCAL : MAP_BG_IMAGE_WORLD
)
const mapWorldBgSrc = computed(() => getMapBgImageSrc(currentMode.value))
// path 符号需朝上绘制,ECharts 会沿路径切线自动旋转
const METEOR_EFFECT_PATH = 'path://M0.5,0 L0.56,0.14 L0.52,1 L0.48,1 L0.44,0.14 Z'
// 获取当前模式对应的地图文件路径
const getMapFilePath = (mode) => {
switch (mode) {
case 'local':
return './datas/513233.json'
default:
return './china.json'
}
}
// 获取当前模式对应的地图注册名
const getMapName = (mode) => {
switch (mode) {
case 'local':
return 'hongyuan'
default:
return 'china'
}
}
const getHubName = () => {
return tradingData?.centerCity?.name || mapSystemConfig?.mapHub?.name || DEFAULT_MAP_HUB.name
}
const buildGeoCoordMap = (systemConfig, fallbackGeoCoordMap = {}) => {
const hub = systemConfig?.mapHub || DEFAULT_MAP_HUB
return {
...fallbackGeoCoordMap,
...(systemConfig?.mapGeoCoordMap || {}),
[hub.name]: hub.coordinates
}
}
const loadTradingNetwork = async (systemConfig, fallbackPayload) => {
const hub = systemConfig?.mapHub || DEFAULT_MAP_HUB
const buildPayload = (tradingModes) => ({
centerCity: {
name: hub.name,
coordinates: hub.coordinates,
description: hub.description || fallbackPayload?.centerCity?.description || ''
},
geoCoordMap: buildGeoCoordMap(systemConfig, fallbackPayload?.geoCoordMap),
tradingModes,
flowLevels: systemConfig?.mapFlowLevels || fallbackPayload?.flowLevels || DEFAULT_MAP_FLOW_LEVELS
})
try {
const response = await fetch(MAP_TRADING_API)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const result = await response.json()
if (result.code !== 1 || !result.data?.tradingModes) {
throw new Error(result.message || '地图迁徙接口返回异常')
}
return buildPayload(result.data.tradingModes)
} catch (error) {
console.warn('加载地图迁徙数据失败,使用本地兜底数据:', error)
if (!fallbackPayload?.tradingModes) {
return null
}
return buildPayload(fallbackPayload.tradingModes)
}
}
// 加载地图数据和交易数据
const loadData = async (mapMode = 'china') => {
try {
const mapFilePath = getMapFilePath(mapMode)
const [mapResponse, systemConfig, fallbackResponse] = await Promise.all([
fetch(mapFilePath),
loadSystemConfig(),
fetch(FALLBACK_TRADING_JSON)
])
chinaMapData = await mapResponse.json()
mapSystemConfig = systemConfig
const fallbackPayload = await fallbackResponse.json()
tradingData = await loadTradingNetwork(systemConfig, fallbackPayload)
if (!tradingData) {
return false
}
// 注册地图
const mapName = getMapName(mapMode)
echarts.registerMap(mapName, chinaMapData)
unitedMapKey = ''
registerUnitedMap(mapMode)
mapSurfaceTexture = null
mapSurfaceTextureKey = ''
cachedOutlineKey = ''
cachedOutlinePaths = []
return true
} catch (error) {
console.error('数据加载失败:', error)
return false
}
}
// 获取流量等级颜色
const getFlowColor = (value) => {
const { flowLevels } = tradingData
if (value >= flowLevels.high.threshold) return flowLevels.high.color
if (value >= flowLevels.medium.threshold) return flowLevels.medium.color
return flowLevels.low.color
}
// 获取节点颜色(区分流出和流入)
const getNodeColor = (city) => {
const hubName = getHubName()
if (currentMode.value === 'outflow') {
return city === hubName ? '#FFD048' : '#6ecfff'
}
if (currentMode.value === 'inflow') {
return city === hubName ? '#67C23A' : '#5eb8ff'
}
return city === hubName ? '#FFD048' : '#6ecfff'
}
// 获取当前模式的数据
const getCurrentModeData = () => {
if (!tradingData) return { flows: [], title: '', description: '' }
return tradingData.tradingModes[currentMode.value]
}
// 生成散点数据
const generateScatterData = () => {
const modeData = getCurrentModeData()
const scatterData = []
const citySet = new Set()
// 收集所有涉及的城市
modeData.flows.forEach(flow => {
citySet.add(flow.from)
citySet.add(flow.to)
})
// 为每个城市生成散点数据
citySet.forEach(city => {
const coord = tradingData.geoCoordMap[city]
if (coord) {
const hubName = getHubName()
// 计算该城市的总流量
let totalFlow = 0
modeData.flows.forEach(flow => {
if (flow.from === city || flow.to === city) {
totalFlow += flow.value
}
})
scatterData.push({
name: city,
value: coord.concat([totalFlow]),
symbol: city === hubName ? 'diamond' : 'circle',
symbolSize: Math.max(6, Math.min(18, totalFlow / 80)),
itemStyle: {
color: getNodeColor(city),
borderColor: '#fff',
borderWidth: 1.5,
shadowBlur: 6,
shadowColor: getNodeColor(city)
}
})
}
})
return scatterData
}
// 生成流向线数据
const generateLinesData = () => {
const modeData = getCurrentModeData()
return modeData.flows
.map((flow) => {
const fromCoord = tradingData.geoCoordMap[flow.from]
const toCoord = tradingData.geoCoordMap[flow.to]
if (!fromCoord || !toCoord) {
return null
}
return {
fromName: flow.from,
toName: flow.to,
coords: [fromCoord, toCoord],
value: flow.value,
description: flow.description
}
})
.filter(Boolean)
}
// 生成波纹效果数据
const generateEffectScatterData = () => {
const modeData = getCurrentModeData()
const effectData = []
const cityFlows = {}
// 统计每个城市的流量
modeData.flows.forEach(flow => {
cityFlows[flow.from] = (cityFlows[flow.from] || 0) + flow.value
cityFlows[flow.to] = (cityFlows[flow.to] || 0) + flow.value
})
// 为流量大的城市添加波纹效果
const flowThreshold = tradingData?.flowLevels?.medium?.threshold || 40
const hubName = getHubName()
Object.entries(cityFlows).forEach(([city, totalFlow]) => {
if (totalFlow > flowThreshold) {
const coord = tradingData.geoCoordMap[city]
if (coord) {
effectData.push({
name: city,
value: coord.concat([totalFlow]),
symbol: city === hubName ? 'diamond' : 'circle',
symbolSize: Math.max(14, Math.min(26, totalFlow / 60)),
itemStyle: {
color: getNodeColor(city),
shadowBlur: 12,
shadowColor: getNodeColor(city)
}
})
}
}
})
return effectData
}
// 获取城市来源数据
const getCitySourceData = (cityName) => {
const modeData = getCurrentModeData()
const sources = []
modeData.flows.forEach(flow => {
if (flow.to === cityName) {
sources.push({
name: flow.from,
value: flow.value,
description: flow.description
})
}
})
return sources
}
// 获取城市销售数据
const getCitySalesData = (cityName) => {
const modeData = getCurrentModeData()
const sales = []
modeData.flows.forEach(flow => {
if (flow.from === cityName) {
sales.push({
name: flow.to,
value: flow.value,
description: flow.description
})
}
})
return sales
}
// 创建饼图配置
const createPieOption = (data, title) => {
return {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 25, 45, 0.95)',
borderColor: '#00d4ff',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 12
},
formatter: '{b}: {c}头 ({d}%)'
},
legend: {
orient: 'horizontal',
left: 'center',
bottom: '5%',
textStyle: {
color: '#8cc8ff',
fontSize: 9
},
itemWidth: 10,
itemHeight: 8,
itemGap: 8,
formatter: function (name) {
const item = data.find(d => d.name === name)
return item ? `${name}(${item.value})` : name
}
},
series: [{
type: 'pie',
radius: ['30%', '55%'],
center: ['50%', '40%'],
data: data.map(item => ({
name: item.name,
value: item.value,
itemStyle: {
borderColor: '#fff',
borderWidth: 1
}
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 212, 255, 0.5)'
}
},
label: {
show: false
},
labelLine: {
show: false
}
}],
color: ['#00d4ff', '#67c23a', '#e6a23c', '#ff6b6b', '#9c27b0', '#26c6da', '#ffc107', '#8bc34a']
}
}
// 显示浮动面板
const showFloatingPanels = (cityName) => {
const sources = getCitySourceData(cityName)
const sales = getCitySalesData(cityName)
if (sources.length > 0) {
sourceData.value = sources
showSourcePanel.value = true
nextTick(() => {
if (sourceChartRef.value && !sourceChartInstance) {
sourceChartInstance = echarts.init(sourceChartRef.value)
}
if (sourceChartInstance) {
sourceChartInstance.setOption(createPieOption(sources, '来源分布'))
}
})
}
if (sales.length > 0) {
salesData.value = sales
showSalesPanel.value = true
nextTick(() => {
if (salesChartRef.value && !salesChartInstance) {
salesChartInstance = echarts.init(salesChartRef.value)
}
if (salesChartInstance) {
salesChartInstance.setOption(createPieOption(sales, '销售分布'))
}
})
}
}
// 隐藏浮动面板
const hideFloatingPanels = () => {
showSourcePanel.value = false
showSalesPanel.value = false
sourceData.value = null
salesData.value = null
if (sourceChartInstance) {
sourceChartInstance.dispose()
sourceChartInstance = null
}
if (salesChartInstance) {
salesChartInstance.dispose()
salesChartInstance = null
}
}
// 生成交易明细数据
const generateDetailData = (cityName) => {
// 模拟交易明细数据
const baseData = [
{
quarantineNo: 'JY2024010001',
owner: '阿坝县扎西畜牧专业合作社',
origin: '阿坝县若尔盖草原',
destination: '成都市双流区',
vehiclePlate: '川A·12345',
yakCount: 120,
contactPerson: '扎西多吉',
contactPhone: '138****1234'
},
{
quarantineNo: 'JY2024010002',
owner: '红原县草原牧业有限公司',
origin: '红原县安曲镇',
destination: '重庆市渝北区',
vehiclePlate: '川A·23456',
yakCount: 95,
contactPerson: '次仁德勒',
contactPhone: '139****2345'
},
{
quarantineNo: 'JY2024010003',
owner: '若尔盖县牧民专业合作社',
origin: '若尔盖县花湖草原',
destination: '西安市雁塔区',
vehiclePlate: '川U·34567',
yakCount: 80,
contactPerson: '旺久多杰',
contactPhone: '136****3456'
},
{
quarantineNo: 'JY2024010004',
owner: '松潘县高原牧业合作社',
origin: '松潘县川主寺镇',
destination: '兰州市城关区',
vehiclePlate: '川A·45678',
yakCount: 65,
contactPerson: '桑杰嘉措',
contactPhone: '137****4567'
},
{
quarantineNo: 'JY2024010005',
owner: '马尔康市康巴牧业有限公司',
origin: '马尔康市梭磨乡',
destination: '昆明市西山区',
vehiclePlate: '川A·56789',
yakCount: 55,
contactPerson: '格桑旺堆',
contactPhone: '135****5678'
},
{
quarantineNo: 'JY2024010006',
owner: '壤塘县藏区牧业专业合作社',
origin: '壤塘县中壤塘镇',
destination: '贵阳市观山湖区',
vehiclePlate: '川A·67890',
yakCount: 45,
contactPerson: '洛桑扎西',
contactPhone: '134****6789'
}
]
// 根据不同城市返回相应的明细数据
if (cityName === getHubName()) {
return baseData
} else {
// 为其他城市生成相应的数据
return baseData.map((item, index) => ({
...item,
quarantineNo: `JY2024${String(Math.floor(Math.random() * 9000) + 1000).substr(0, 4)}${String(index + 1).padStart(3, '0')}`,
owner: `${cityName}${['畜牧专业合作社', '高原牧业有限公司', '牧民专业合作社', '草原牧业集团'][index % 4]}`,
origin: `${cityName}${['草原', '镇', '乡', '县'][index % 4]}`,
destination: `${cityName}${['经开区', '高新区', '市中区', '新城区'][index % 4]}`,
vehiclePlate: `${['川A', '川B', '川C', '川U'][index % 4]}·${Math.floor(Math.random() * 90000) + 10000}`,
contactPerson: ['扎西', '次仁', '旺久', '桑杰', '格桑', '洛桑'][index % 6] + ['多吉', '德勒', '多杰', '嘉措', '旺堆', '扎西'][Math.floor(Math.random() * 6)],
contactPhone: `${['138', '139', '136', '137', '135', '134'][index % 6]}****${Math.floor(Math.random() * 9000) + 1000}`
}))
}
}
// 显示交易明细面板
const showDetailData = (cityName) => {
selectedCity.value = cityName
detailData.value = generateDetailData(cityName)
showDetailPanel.value = true
// 隐藏其他面板
hideFloatingPanels()
}
// 关闭交易明细面板
const closeDetailPanel = () => {
showDetailPanel.value = false
detailData.value = null
selectedCity.value = ''
}
// 计算地图最佳视野范围
const calculateMapBounds = () => {
if (currentMode.value === 'local') {
return { center: [104, 35], zoom: 1.0 }
}
return {
center: [...MAP_ANCHOR_CENTER],
zoom: MAP_ANCHOR_ZOOM * MAP_ZOOM_FACTOR
}
}
const getFeatureCenter = (feature) => {
const cp = feature?.properties?.cp || feature?.properties?.center
if (Array.isArray(cp) && cp.length >= 2) {
return cp
}
if (Array.isArray(cp) && cp.length === 1) {
return [cp[0], 35]
}
return [104, 35]
}
const getUnitedMapName = (mode = currentMode.value) => `${getMapName(mode)}-united`
const buildUnitedGeoJson = (geojson) => {
if (!geojson?.features?.length) {
return geojson
}
const coordinates = []
geojson.features.forEach((feature) => {
const geom = feature.geometry
if (!geom) {
return
}
if (geom.type === 'Polygon') {
coordinates.push(geom.coordinates)
} else if (geom.type === 'MultiPolygon') {
geom.coordinates.forEach((polygon) => coordinates.push(polygon))
}
})
return {
type: 'FeatureCollection',
features: [{
type: 'Feature',
properties: { name: 'united' },
geometry: {
type: 'MultiPolygon',
coordinates
}
}]
}
}
const registerUnitedMap = (mapMode = currentMode.value) => {
const unitedName = getUnitedMapName(mapMode)
const key = `${unitedName}-${chinaMapData?.features?.length || 0}`
if (unitedMapKey !== key) {
echarts.registerMap(unitedName, buildUnitedGeoJson(chinaMapData))
unitedMapKey = key
mapSurfaceTexture = null
mapSurfaceTextureKey = ''
}
return unitedName
}
const getMapBgCrop = (image) => {
if (currentMode.value === 'local') {
if (image?.naturalWidth && image?.naturalHeight) {
return { x: 0, y: 0, width: image.naturalWidth, height: image.naturalHeight }
}
return MAP_BG_CROP_LOCAL
}
return MAP_BG_CROP_CHINA
}
const loadMapBgImage = (mode = currentMode.value) => {
const src = getMapBgImageSrc(mode)
if (mapBgImageCache.has(src)) {
return Promise.resolve(mapBgImageCache.get(src))
}
if (mapBgImageLoading.has(src)) {
return mapBgImageLoading.get(src)
}
const promise = new Promise((resolve, reject) => {
if (typeof Image === 'undefined') {
reject(new Error('Image is not available'))
return
}
const image = new Image()
image.decoding = 'async'
image.onload = () => {
mapBgImageCache.set(src, image)
mapBgImageLoading.delete(src)
resolve(image)
}
image.onerror = () => {
mapBgImageLoading.delete(src)
reject(new Error(`Failed to load ${src}`))
}
image.src = src
})
mapBgImageLoading.set(src, promise)
return promise
}
const paintMapSurfaceTexture = (ctx, width, height, image, crop) => {
const paint = getMapSurfacePaintConfig()
ctx.clearRect(0, 0, width, height)
// 1. 地形底图:保留山脉/河谷明暗,勿过度提亮
ctx.filter = `brightness(${paint.brightness}) contrast(${paint.contrast})`
ctx.drawImage(
image,
crop.x,
crop.y,
crop.width,
crop.height,
0,
0,
width,
height
)
ctx.filter = 'none'
// 1.5 先整体提亮暗部(避免 color 混合把地形压得太暗)
if (paint.lift?.enabled) {
ctx.globalCompositeOperation = paint.lift.composite || 'screen'
ctx.fillStyle = paint.lift.color
ctx.fillRect(0, 0, width, height)
}
// 2. color 定色相,保留地形明暗
applySurfacePaintLayer(ctx, width, height, paint.tint)
// 2.5 轻量 overlay 提亮饱和(避免单层 overlay 洗成一片亮蓝)
if (paint.satBoost?.enabled) {
applySurfacePaintLayer(ctx, width, height, paint.satBoost)
}
// 3. 东侧/边缘略压暗,增加左右纵深
if (paint.depth?.enabled) {
applySurfacePaintLayer(ctx, width, height, paint.depth)
}
// 4. 轻量 soft-light 提亮暗部
if (paint.softLight?.enabled) {
ctx.globalCompositeOperation = 'soft-light'
ctx.fillStyle = paint.softLight.color
ctx.fillRect(0, 0, width, height)
}
// 5. 中心柔和高光
if (paint.glow?.enabled) {
ctx.globalCompositeOperation = 'screen'
const glowGradient = createSurfacePaintGradient(ctx, width, height, {
mode: 'radial',
centerX: paint.glow.centerX,
centerY: paint.glow.centerY,
radius: paint.glow.radius
})
paint.glow.stops.forEach(({ stop, color }) => glowGradient.addColorStop(stop, color))
ctx.fillStyle = glowGradient
ctx.fillRect(0, 0, width, height)
}
ctx.globalCompositeOperation = 'source-over'
}
const createMapSurfaceTexture = (image) => {
const width = MAP_SURFACE_TEXTURE_WIDTH
const height = MAP_SURFACE_TEXTURE_HEIGHT
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
return null
}
paintMapSurfaceTexture(ctx, width, height, image, getMapBgCrop(image))
return canvas
}
const invalidateMapSurfaceTexture = () => {
mapSurfaceTexture = null
mapSurfaceTextureDataUrl = ''
mapSurfaceTextureKey = ''
}
const syncMapSurfaceTextureOutput = (canvas) => {
mapSurfaceTexture = canvas
mapSurfaceTextureDataUrl = canvas ? canvas.toDataURL('image/png') : ''
return mapSurfaceTexture
}
const ensureMapSurfaceTexture = async (force = false) => {
const key = `${currentMode.value}@v${MAP_SURFACE_PAINT_VERSION}`
if (!force && mapSurfaceTexture && mapSurfaceTextureKey === key) {
return mapSurfaceTexture
}
const image = await loadMapBgImage(key)
const canvas = createMapSurfaceTexture(image)
if (!canvas) {
return null
}
syncMapSurfaceTextureOutput(canvas)
mapSurfaceTextureKey = key
return mapSurfaceTexture
}
const getMapSurfaceAreaStyle = () => {
if (mapSurfaceTextureDataUrl) {
return {
areaColor: {
image: mapSurfaceTextureDataUrl,
repeat: 'no-repeat'
}
}
}
return { areaColor: MAP_SURFACE_FALLBACK }
}
const refreshMapSurfaceTexture = async () => {
if (!chartInstance) {
return
}
try {
invalidateMapSurfaceTexture()
await ensureMapSurfaceTexture(true)
const mapBounds = calculateMapBounds()
const mapName = getMapName(currentMode.value)
const unitedMapName = registerUnitedMap(currentMode.value)
chartInstance.setOption({
geo: buildGeoLayers(mapName, unitedMapName, mapBounds)
}, {
replaceMerge: ['geo']
})
} catch (error) {
console.warn('地图纹理生成失败:', error)
}
}
const pointKey = ([lng, lat]) => `${lng.toFixed(5)},${lat.toFixed(5)}`
const edgeKey = (a, b) => {
const ka = pointKey(a)
const kb = pointKey(b)
return ka < kb ? `${ka}|${kb}` : `${kb}|${ka}`
}
const forEachOuterRing = (geometry, callback) => {
if (!geometry) return
if (geometry.type === 'Polygon') {
if (geometry.coordinates[0]) {
callback(geometry.coordinates[0])
}
} else if (geometry.type === 'MultiPolygon') {
geometry.coordinates.forEach((polygon) => {
if (polygon[0]) {
callback(polygon[0])
}
})
}
}
const stitchBoundarySegments = (segments) => {
const adj = new Map()
segments.forEach(([a, b], idx) => {
const ka = pointKey(a)
const kb = pointKey(b)
if (!adj.has(ka)) adj.set(ka, [])
if (!adj.has(kb)) adj.set(kb, [])
adj.get(ka).push({ point: b, idx })
adj.get(kb).push({ point: a, idx })
})
const used = new Set()
const paths = []
segments.forEach(([a, b], idx) => {
if (used.has(idx)) {
return
}
used.add(idx)
const path = [a, b]
const extendTail = () => {
const tailKey = pointKey(path[path.length - 1])
const candidates = (adj.get(tailKey) || []).filter((item) => !used.has(item.idx))
if (!candidates.length) {
return false
}
const next = candidates[0]
used.add(next.idx)
path.push(next.point)
return true
}
const extendHead = () => {
const headKey = pointKey(path[0])
const candidates = (adj.get(headKey) || []).filter((item) => !used.has(item.idx))
if (!candidates.length) {
return false
}
const next = candidates[0]
used.add(next.idx)
path.unshift(next.point)
return true
}
while (extendTail()) { }
while (extendHead()) { }
paths.push(path)
})
return paths
}
const buildNationalOutlinePaths = (geojson) => {
if (!geojson?.features?.length) {
return []
}
const edgeCount = new Map()
const edgeList = []
geojson.features.forEach((feature) => {
forEachOuterRing(feature.geometry, (ring) => {
if (!Array.isArray(ring) || ring.length < 2) {
return
}
for (let i = 0; i < ring.length - 1; i += 1) {
const a = ring[i]
const b = ring[i + 1]
if (!a || !b || (a[0] === b[0] && a[1] === b[1])) {
continue
}
const key = edgeKey(a, b)
edgeCount.set(key, (edgeCount.get(key) || 0) + 1)
edgeList.push({ key, a, b })
}
})
})
const boundarySegments = []
edgeList.forEach(({ key, a, b }) => {
if (edgeCount.get(key) === 1) {
boundarySegments.push([a, b])
}
})
return stitchBoundarySegments(boundarySegments)
}
const getWireframePaths = () => {
const mapName = getMapName(currentMode.value)
const cacheKey = `${mapName}-${chinaMapData?.features?.length || 0}`
if (cacheKey === cachedOutlineKey) {
return cachedOutlinePaths
}
const minPoints = currentMode.value === 'local' ? 8 : 30
cachedOutlineKey = cacheKey
cachedOutlinePaths = buildNationalOutlinePaths(chinaMapData)
.filter((path) => path.length >= minPoints)
return cachedOutlinePaths
}
const getMapProjection = (mapBounds, latOffset = 0, lngOffset = 0) => {
const isLocal = currentMode.value === 'local'
if (isLocal) {
return {
aspectScale: MAP_ASPECT_SCALE,
layoutCenter: [
`${50 + lngOffset + MAP_GLOBAL_LAYOUT_X}%`,
`${50 + latOffset + MAP_GLOBAL_LAYOUT_Y}%`
],
layoutSize: MAP_LAYOUT_SIZE
}
}
const center = mapBounds.center || MAP_ANCHOR_CENTER
const zoom = mapBounds.zoom || MAP_ANCHOR_ZOOM
return {
aspectScale: MAP_ASPECT_SCALE,
zoom,
center: [
center[0] + lngOffset + MAP_GLOBAL_OFFSET_LNG,
center[1] + latOffset + MAP_GLOBAL_OFFSET_LAT
]
}
}
const buildShadowFillGeo = (unitedMapName, mapBounds, latOffset, lngOffset, areaColor, z) => ({
map: unitedMapName,
roam: false,
...getMapProjection(mapBounds, latOffset, lngOffset),
silent: true,
zlevel: 0,
z,
label: { show: false },
itemStyle: {
areaColor,
borderColor: 'transparent',
borderWidth: 0
},
emphasis: {
disabled: true
}
})
const getShadowOffsets = () => {
const isLocal = currentMode.value === 'local'
return {
far: isLocal ? MAP_SHADOW_OFFSET_FAR_LOCAL : MAP_SHADOW_OFFSET_FAR,
mid: isLocal ? MAP_SHADOW_OFFSET_MID_LOCAL : MAP_SHADOW_OFFSET_MID,
near: isLocal ? MAP_SHADOW_OFFSET_NEAR_LOCAL : MAP_SHADOW_OFFSET_NEAR,
lng: isLocal ? MAP_SHADOW_OFFSET_LNG_LOCAL : MAP_SHADOW_OFFSET_LNG
}
}
const buildGeoLayers = (mapName, unitedMapName, mapBounds) => {
const isLocal = currentMode.value === 'local'
const wireframeOffset = isLocal ? MAP_WIREFRAME_OFFSET_LOCAL : MAP_WIREFRAME_OFFSET
const wireframeLngOffset = isLocal ? MAP_WIREFRAME_OFFSET_LNG_LOCAL : MAP_WIREFRAME_OFFSET_LNG
const { far, mid, near, lng } = getShadowOffsets()
return [
buildShadowFillGeo(unitedMapName, mapBounds, far, lng, MAP_SHADOW_FILL_FAR, 1),
buildShadowFillGeo(unitedMapName, mapBounds, mid, lng, MAP_SHADOW_FILL_MID, 2),
buildShadowFillGeo(unitedMapName, mapBounds, near, lng, MAP_SHADOW_FILL_NEAR, 3),
{
map: unitedMapName,
roam: false,
...getMapProjection(mapBounds, 0),
silent: true,
zlevel: 1,
z: 2,
label: { show: false },
itemStyle: {
...getMapSurfaceAreaStyle(),
borderColor: 'transparent',
borderWidth: 0
},
emphasis: {
disabled: true
}
},
{
map: mapName,
roam: false,
...getMapProjection(mapBounds, 0),
silent: true,
zlevel: 1,
z: 1,
label: { show: false },
itemStyle: {
areaColor: 'rgba(0, 0, 0, 0)',
borderColor: 'transparent',
borderWidth: 0
},
emphasis: {
disabled: true
}
},
{
map: mapName,
roam: false,
...getMapProjection(mapBounds, wireframeOffset, wireframeLngOffset),
silent: true,
zlevel: 4,
z: 1,
label: { show: false },
itemStyle: {
areaColor: 'rgba(0, 0, 0, 0)',
borderColor: 'transparent',
borderWidth: 0
},
emphasis: {
disabled: true
}
}
]
}
const buildWireframeLineSeries = (name, zlevel, wireframeData, lineStyle) => ({
name,
type: 'lines',
coordinateSystem: 'geo',
geoIndex: WIREFRAME_GEO_INDEX,
zlevel,
polyline: true,
silent: true,
data: wireframeData,
lineStyle: {
cap: 'round',
join: 'round',
shadowOffsetX: 0,
shadowOffsetY: 0,
...lineStyle
}
})
// 初始化图表配置
const getChartOption = () => {
const modeData = getCurrentModeData()
const scatterData = generateScatterData()
const linesData = generateLinesData()
const effectScatterData = generateEffectScatterData()
const mapBounds = calculateMapBounds()
const mapName = getMapName(currentMode.value)
const unitedMapName = registerUnitedMap(currentMode.value)
const mapLayout = getMapProjection(mapBounds, 0)
const outlinePaths = getWireframePaths()
const wireframeData = outlinePaths.map((coords) => ({ coords }))
return {
backgroundColor: 'transparent', // 透明背景,与卡片一致
// title: {
// text: modeData.title,
// subtext: modeData.description,
// left: 'center',
// textStyle: {
// color: '#ffffff',
// fontSize: 20,
// fontWeight: 'bold'
// },
// subtextStyle: {
// color: '#8cc8ff',
// fontSize: 14
// }
// },
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(15, 25, 45, 0.95)',
borderColor: '#00d4ff',
borderWidth: 1,
textStyle: {
color: '#fff',
fontSize: 13
},
formatter: function (params) {
if (params.componentType === 'geo') {
return `<div style="padding: 8px;">
<strong style="color: #00d4ff;">${params.name}</strong><br/>
<span style="color: #8cc8ff;">省份/地区</span>
</div>`
} else if (params.seriesType === 'scatter') {
const value = params.value[2] || 0
const hubName = getHubName()
const nodeType = params.name === hubName ? '中心节点' :
(currentMode.value === 'outflow' ? '销售市场' : '供应来源')
return `<div style="padding: 8px;">
<strong style="color: #00d4ff;">${params.name}</strong><br/>
<span style="color: #67c23a;">总流量: ${value} 头</span><br/>
<span style="color: #8cc8ff;">节点类型: ${nodeType}</span>
</div>`
} else if (params.seriesType === 'lines') {
const lineData = linesData.find(line =>
line.fromName === params.data.fromName &&
line.toName === params.data.toName
)
return `<div style="padding: 8px;">
<strong style="color: #00d4ff;">${params.data.fromName} → ${params.data.toName}</strong><br/>
<span style="color: #67c23a;">流量: ${params.data.value} 头</span><br/>
<span style="color: #8cc8ff;">${lineData ? lineData.description : ''}</span>
</div>`
} else if (params.seriesType === 'effectScatter') {
const value = params.value[2] || 0
return `<div style="padding: 8px;">
<strong style="color: #00d4ff;">${params.name}</strong><br/>
<span style="color: #67c23a;">总流量: ${value} 头</span><br/>
<span style="color: #e6a23c;">重要交易节点</span>
</div>`
}
return params.name
}
},
geo: buildGeoLayers(mapName, unitedMapName, mapBounds),
series: [
{
name: 'mapFill',
type: 'map',
map: mapName,
roam: false,
...mapLayout,
zlevel: 2,
silent: true,
selectedMode: false,
itemStyle: {
areaColor: MAP_SURFACE_COLOR,
borderColor: MAP_INNER_BORDER,
borderWidth: currentMode.value === 'local'
? MAP_INNER_BORDER_WIDTH_LOCAL
: MAP_INNER_BORDER_WIDTH
},
label: {
show: true,
color: MAP_LABEL_COLOR,
fontSize: 12,
fontFamily: 'Microsoft YaHei, sans-serif',
fontWeight: 'bold',
textBorderColor: 'rgba(0, 0, 0, 0.5)',
textBorderWidth: 2
},
emphasis: {
disabled: true
}
},
...MAP_WIREFRAME_HALO_LAYERS.map((layer, index) => buildWireframeLineSeries(
`mapWireframeHalo${index}`,
4,
wireframeData,
{
color: layer.color,
width: layer.width,
opacity: 1
}
)),
buildWireframeLineSeries('mapWireframe', 5, wireframeData, {
color: MAP_WIREFRAME_COLOR,
width: MAP_WIREFRAME_CORE_WIDTH,
opacity: 0.93,
shadowBlur: MAP_WIREFRAME_CORE_SHADOW_BLUR,
shadowColor: MAP_WIREFRAME_CORE_SHADOW_COLOR
}),
{
name: '交易城市',
type: 'scatter',
coordinateSystem: 'geo',
geoIndex: GEO_DATA_INDEX,
zlevel: 6,
data: scatterData,
symbolSize: 8,
label: {
show: true,
position: 'top',
color: MAP_LABEL_COLOR,
fontSize: 11,
fontWeight: 'bold',
formatter: '{b}',
textBorderColor: 'rgba(0, 0, 0, 0.35)',
textBorderWidth: 2
},
emphasis: {
label: {
show: true,
fontSize: 13,
color: MAP_LABEL_COLOR
},
itemStyle: {
borderColor: '#00d4ff',
borderWidth: 3,
shadowBlur: 15
}
}
},
{
name: '流向',
type: 'lines',
coordinateSystem: 'geo',
geoIndex: GEO_DATA_INDEX,
zlevel: 6,
data: linesData,
lineStyle: {
color: '#a8f6ff',
width: 1.5,
opacity: 0.82,
curveness: 0.32,
shadowBlur: 10,
shadowColor: 'rgba(120, 240, 255, 0.65)'
},
emphasis: {
lineStyle: {
width: 2.5,
opacity: 1,
shadowBlur: 6
}
}
},
{
name: '流向流光',
type: 'lines',
coordinateSystem: 'geo',
geoIndex: GEO_DATA_INDEX,
zlevel: 8,
silent: true,
animation: false,
data: linesData,
effect: {
show: true,
constantSpeed: 55,
symbol: METEOR_EFFECT_PATH,
symbolSize: [5, 22],
trailLength: 0.16,
loop: true,
color: '#e8fbff'
},
lineStyle: {
width: 0,
opacity: 0,
curveness: 0.32
}
},
{
name: '重要节点',
type: 'effectScatter',
coordinateSystem: 'geo',
geoIndex: GEO_DATA_INDEX,
zlevel: 6,
data: effectScatterData,
symbolSize: function (val) {
return Math.max(14, Math.min(26, val[2] / 60))
},
showEffectOn: 'render',
rippleEffect: {
period: 3,
scale: 3,
brushType: 'stroke'
},
label: {
show: true,
position: 'top',
color: MAP_LABEL_COLOR,
fontSize: 12,
fontWeight: 'bold',
formatter: '{b}',
textBorderColor: 'rgba(0, 0, 0, 0.35)',
textBorderWidth: 2
},
itemStyle: {
shadowBlur: 15
},
emphasis: {
scale: true,
itemStyle: {
borderColor: '#00d4ff',
borderWidth: 3,
shadowBlur: 20
}
}
}
],
animationDuration: 2500,
animationEasing: 'cubicInOut'
}
}
// 重新加载地图数据
const reloadMapData = async (mapMode) => {
try {
const mapFilePath = getMapFilePath(mapMode)
const mapResponse = await fetch(mapFilePath)
chinaMapData = await mapResponse.json()
const mapName = getMapName(mapMode)
echarts.registerMap(mapName, chinaMapData)
unitedMapKey = ''
registerUnitedMap(mapMode)
mapSurfaceTexture = null
mapSurfaceTextureKey = ''
cachedOutlineKey = ''
cachedOutlinePaths = []
return true
} catch (error) {
console.error('地图数据重新加载失败:', error)
return false
}
}
const handleChartResize = () => {
chartInstance?.resize()
}
// 初始化图表
const initChart = async () => {
if (!chartRef.value) return
const dataLoaded = await loadData(currentMode.value)
if (!dataLoaded) return
chartInstance = echarts.init(chartRef.value)
chartInstance.setOption(getChartOption())
await refreshMapSurfaceTexture()
// 添加鼠标事件监听
chartInstance.on('mouseover', (params) => {
if (params.seriesType === 'scatter' || params.seriesType === 'effectScatter') {
showFloatingPanels(params.name)
}
})
chartInstance.on('mouseout', (params) => {
if (params.seriesType === 'scatter' || params.seriesType === 'effectScatter') {
hideFloatingPanels()
}
})
// 添加点击事件监听
chartInstance.on('click', (params) => {
if (params.seriesType === 'scatter' || params.seriesType === 'effectScatter') {
showDetailData(params.name)
}
})
window.addEventListener('resize', handleChartResize)
const mountPoint = chartRef.value.parentElement
if (mountPoint) {
resizeObserver = new ResizeObserver(handleChartResize)
resizeObserver.observe(mountPoint)
}
await nextTick()
requestAnimationFrame(() => {
handleChartResize()
setTimeout(handleChartResize, 160)
})
}
// 更新图表
const updateChart = async (needReloadMap = false) => {
if (chartInstance && tradingData) {
if (needReloadMap) {
const mapLoaded = await reloadMapData(currentMode.value)
if (!mapLoaded) return
}
chartInstance.setOption(getChartOption(), {
notMerge: false,
replaceMerge: ['geo', 'series']
})
await refreshMapSurfaceTexture()
await nextTick()
handleChartResize()
}
}
// 监听模式变化:跨地图切换时先隐藏,避免红原扶正与中国地图替换分两段播放
watch(currentMode, async (newMode, oldMode) => {
emit('mode-change', newMode)
if (oldMode === undefined) {
return
}
const needReloadMap = getMapName(oldMode) !== getMapName(newMode)
if (needReloadMap) {
isMapContentSwitching.value = true
}
await updateChart(needReloadMap)
await nextTick()
isMapContentSwitching.value = false
handleChartResize()
if (needReloadMap) {
requestAnimationFrame(() => handleChartResize())
}
}, { flush: 'pre', immediate: true })
onMounted(() => {
initChart()
})
onUnmounted(() => {
window.removeEventListener('resize', handleChartResize)
resizeObserver?.disconnect()
resizeObserver = null
if (chartInstance) {
chartInstance.dispose()
}
if (sourceChartInstance) {
sourceChartInstance.dispose()
}
if (salesChartInstance) {
salesChartInstance.dispose()
}
})
</script>
<style scoped>
.map-card {
position: relative;
width: 100%;
height: 100%;
min-height: 0;
overflow: hidden;
background: transparent;
}
.map-card :deep(.card-content) {
position: relative;
height: 100%;
min-height: 0;
padding: 0;
}
.map-controls {
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background: transparent;
border: none;
padding: 0;
box-shadow: none;
}
.control-buttons {
display: flex;
gap: 10px;
justify-content: center;
align-items: center;
flex-wrap: nowrap;
}
.mode-btn {
position: relative;
width: 235px;
height: 52px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.2s ease, filter 0.2s ease;
}
.mode-btn:hover {
transform: translateY(-1px);
filter: brightness(1.08);
}
.mode-btn.active {
filter: brightness(1.05);
}
.mode-btn-bg {
position: absolute;
inset: 0;
width: 235px;
height: 52px;
object-fit: fill;
pointer-events: none;
user-select: none;
}
.mode-btn-text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-family: 'Microsoft YaHei', sans-serif;
font-size: 16px;
font-weight: 600;
color: #d9f7ff;
letter-spacing: 0.5px;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
.mode-btn.active .mode-btn-text {
color: #fff7e8;
}
.map-stage {
position: absolute;
inset: 0;
overflow: hidden;
background: transparent;
}
.map-stage--local {
perspective: 900px;
perspective-origin: 50% 58%;
}
.map-tilt-layer {
position: absolute;
inset: 0;
transform-style: preserve-3d;
transform-origin: 50% 55%;
will-change: transform, opacity;
backface-visibility: hidden;
}
.map-tilt-layer--switching {
opacity: 0;
pointer-events: none;
}
.map-stage--local .map-tilt-layer {
transform: rotateX(33deg) translate3d(0, -9%, 22px) scale(0.88);
transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.map-stage--local .chart-container {
transform: none;
}
.map-tilt-layer .chart-container {
transform-style: preserve-3d;
}
.map-world-bg {
position: absolute;
width: var(--screen-width, 5120px);
height: var(--screen-height, 1440px);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
object-fit: fill;
pointer-events: none;
user-select: none;
z-index: 0;
}
.chart-container {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important;
min-width: 0;
min-height: 0;
z-index: 1;
transform: translate(-0.4%, 0.6%);
}
.map-legend {
position: absolute;
left: 14px;
bottom: 18px;
width: 183px;
height: 147px;
z-index: 100;
pointer-events: none;
user-select: none;
}
.map-legend-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
.map-legend-list {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
gap: 13px;
height: 100%;
margin: 0;
padding: 24px 18px 20px 26px;
list-style: none;
}
.map-legend-item {
display: flex;
align-items: center;
gap: 10px;
min-height: 18px;
}
.legend-icon {
flex-shrink: 0;
}
.legend-icon-line {
width: 22px;
height: 2px;
border-radius: 1px;
background: linear-gradient(90deg, rgba(126, 232, 255, 0.35), #7ee8ff 45%, rgba(126, 232, 255, 0.35));
box-shadow: 0 0 6px rgba(110, 232, 255, 0.75);
}
.legend-icon-city {
width: 9px;
height: 9px;
border-radius: 50%;
background: #ffd048;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 0 6px rgba(255, 208, 72, 0.65);
}
.legend-icon-node {
width: 9px;
height: 9px;
border-radius: 50%;
background: #6ecfff;
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 0 6px rgba(110, 207, 255, 0.65);
}
.legend-label {
font-family: 'Microsoft YaHei', sans-serif;
font-size: 20px;
font-weight: 400;
color: #eefaff;
line-height: 1;
letter-spacing: 0.5px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
.floating-panel {
position: absolute;
width: 280px;
height: 380px;
background: rgba(15, 25, 45, 0.95);
border: 1px solid #00d4ff;
border-radius: 8px;
backdrop-filter: blur(10px);
z-index: 1100;
box-shadow: 0 8px 24px rgba(0, 212, 255, 0.3);
transition: all 0.3s ease;
top: 80px;
}
.source-panel {
left: 20px;
}
.sales-panel {
right: 20px;
}
.floating-panel h3 {
color: #fff;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 16px 8px;
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
text-align: center;
}
.panel-chart {
width: 100%;
height: 340px;
padding: 0;
}
.detail-panel {
position: absolute;
left: 50%;
bottom: 20px;
transform: translateX(-50%);
width: 80%;
max-width: 1200px;
max-height: 400px;
background: rgba(15, 25, 45, 0.98);
border: 1px solid #00d4ff;
border-radius: 8px;
backdrop-filter: blur(15px);
z-index: 1200;
box-shadow: 0 12px 32px rgba(0, 212, 255, 0.4);
overflow: hidden;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
background: rgba(0, 212, 255, 0.1);
}
.detail-header h3 {
color: #fff;
font-size: 16px;
font-weight: bold;
margin: 0;
}
.close-btn {
background: none;
border: none;
color: #8cc8ff;
font-size: 24px;
font-weight: bold;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
}
.close-btn:hover {
background: rgba(0, 212, 255, 0.2);
color: #00d4ff;
transform: scale(1.1);
}
.detail-content {
max-height: 320px;
overflow-y: auto;
padding: 0;
}
.detail-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.detail-table th {
background: rgba(0, 212, 255, 0.15);
color: #00d4ff;
font-weight: bold;
padding: 12px 15px;
text-align: left;
border-bottom: 1px solid rgba(0, 212, 255, 0.3);
position: sticky;
top: 0;
z-index: 10;
}
.detail-table td {
color: #8cc8ff;
padding: 10px 15px;
border-bottom: 1px solid rgba(0, 212, 255, 0.1);
}
.detail-table tr:hover {
background: rgba(0, 212, 255, 0.05);
}
.detail-table tr:hover td {
color: #fff;
}
/* 滚动条样式 */
.detail-content::-webkit-scrollbar {
width: 6px;
}
.detail-content::-webkit-scrollbar-track {
background: rgba(0, 212, 255, 0.1);
border-radius: 3px;
}
.detail-content::-webkit-scrollbar-thumb {
background: rgba(0, 212, 255, 0.5);
border-radius: 3px;
}
.detail-content::-webkit-scrollbar-thumb:hover {
background: rgba(0, 212, 255, 0.7);
}
</style>