@ -3,47 +3,28 @@
<!-- 顶部切换按钮 -- >
< 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 = ""
/ >
< 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"
>
< 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"
>
< 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 v-if ="showDetailPanel && detailData" class="detail-panel" >
< div class = "detail-header" >
< h3 > { { selectedCity } } - 交易明细统计 < / h3 >
< button @click ="closeDetailPanel" class = "close-btn" > × < / button >
@ -77,14 +58,12 @@
< / table >
< / div >
< / div >
< div class = "map-stage" >
image . png < img
class = "map-world-bg"
src = "/images/世界地图背景.png"
alt = ""
/ >
< div ref = "chartRef" class = "chart-container" > < / div >
< div class = "map-stage" : class = "{ 'map-stage--local': currentMode === 'local' }" >
< div class = "map-tilt-layer" >
< img class = "map-world-bg" :src ="mapWorldBgSrc" alt = "" / >
< 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" >
@ -107,7 +86,7 @@
< / template >
< script setup >
import { ref , onMounted , onUnmounted , watch , nextTick } from 'vue'
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'
@ -115,6 +94,8 @@ import { loadSystemConfig, DEFAULT_MAP_HUB, DEFAULT_MAP_FLOW_LEVELS } from '../u
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 )
@ -129,8 +110,8 @@ let mapSystemConfig = null
let cachedOutlineKey = ''
let cachedOutlinePaths = [ ]
let unitedMapKey = ''
let mapBgImage = null
let mapBgImagePromise = null
const mapBgImageCache = new Map ( )
const mapBgImageLoading = new Map ( )
let mapSurfaceTexture = null
let mapSurfaceTextureKey = ''
@ -177,15 +158,25 @@ const MAP_WIREFRAME_OFFSET = -0.72
const MAP _WIREFRAME _OFFSET _LNG = 0.1
const MAP _WIREFRAME _OFFSET _LOCAL = - 3.5
const MAP _WIREFRAME _OFFSET _LNG _LOCAL = 0.6
const MAP _WIREFRAME _COLOR = '#f2fdff'
/ / 国 界 描 边 : 略 偏 青 蓝 ( 非 纯 白 ) , 光 晕 同 色 渐 弱
const MAP _WIREFRAME _COLOR = '#C8E8F4'
const MAP _WIREFRAME _HALO _LAYERS = [
{ width : 12 , color : 'rgba(148, 208, 228, 0.08)' } ,
{ width : 8.5 , color : 'rgba(162, 220, 238, 0.15)' } ,
{ width : 5.8 , color : 'rgba(176, 232, 246, 0.25)' }
]
const MAP _WIREFRAME _CORE _WIDTH = 3.1
const MAP _WIREFRAME _CORE _SHADOW _BLUR = 8
const MAP _WIREFRAME _CORE _SHADOW _COLOR = 'rgba(120, 198, 228, 0.24)'
const MAP _INNER _BORDER = 'rgba(4, 32, 52, 0.28)'
const MAP _INNER _BORDER _WIDTH = 0.45
const MAP _SHADOW _FILL _FAR = '#010204'
const MAP _SHADOW _FILL _MID = '#020408'
const MAP _SHADOW _FILL _NEAR = '#030a12'
const MAP _LABEL _COLOR = '#ffffff'
/ / 世 界 地 图 背 景 源 图 ( 4 3 1 8 × 1 0 7 8 ) 中 中 国 区 域 裁 剪 , 用 于 烘 焙 进 地 图 纹 理
const MAP _BG _IMAGE = '/images/世界地图背景.png'
/ / 世 界 地 图 / 红 原 模 式 底 图
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 }
@ -195,6 +186,12 @@ const MAP_SURFACE_TEXTURE_HEIGHT = 1152
const MAP _SURFACE _FALLBACK = 'rgb(92, 202, 206)'
const MAP _SURFACE _COLOR = 'rgba(0, 0, 0, 0)'
const getMapBgImageSrc = ( mode = currentMode . value ) => (
mode === 'local' ? MAP _BG _IMAGE _LOCAL : MAP _BG _IMAGE _WORLD
)
const mapWorldBgSrc = computed ( ( ) => getMapBgImageSrc ( currentMode . value ) )
/ / p a t h 符 号 需 朝 上 绘 制 , E C h a r t s 会 沿 路 径 切 线 自 动 旋 转
const METEOR _EFFECT _PATH = 'path://M0.5,0 L0.56,0.14 L0.52,1 L0.48,1 L0.44,0.14 Z'
@ -279,7 +276,7 @@ const loadData = async (mapMode = 'china') => {
if ( ! tradingData ) {
return false
}
/ / 注 册 地 图
const mapName = getMapName ( mapMode )
echarts . registerMap ( mapName , chinaMapData )
@ -289,7 +286,7 @@ const loadData = async (mapMode = 'china') => {
mapSurfaceTextureKey = ''
cachedOutlineKey = ''
cachedOutlinePaths = [ ]
return true
} catch ( error ) {
console . error ( '数据加载失败:' , error )
@ -328,13 +325,13 @@ 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 ]
@ -347,7 +344,7 @@ const generateScatterData = () => {
totalFlow += flow . value
}
} )
scatterData . push ( {
name : city ,
value : coord . concat ( [ totalFlow ] ) ,
@ -363,7 +360,7 @@ const generateScatterData = () => {
} )
}
} )
return scatterData
}
@ -393,13 +390,13 @@ 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 ( )
@ -421,7 +418,7 @@ const generateEffectScatterData = () => {
}
}
} )
return effectData
}
@ -429,7 +426,7 @@ const generateEffectScatterData = () => {
const getCitySourceData = ( cityName ) => {
const modeData = getCurrentModeData ( )
const sources = [ ]
modeData . flows . forEach ( flow => {
if ( flow . to === cityName ) {
sources . push ( {
@ -439,7 +436,7 @@ const getCitySourceData = (cityName) => {
} )
}
} )
return sources
}
@ -447,7 +444,7 @@ const getCitySourceData = (cityName) => {
const getCitySalesData = ( cityName ) => {
const modeData = getCurrentModeData ( )
const sales = [ ]
modeData . flows . forEach ( flow => {
if ( flow . from === cityName ) {
sales . push ( {
@ -457,7 +454,7 @@ const getCitySalesData = (cityName) => {
} )
}
} )
return sales
}
@ -487,7 +484,7 @@ const createPieOption = (data, title) => {
itemWidth : 10 ,
itemHeight : 8 ,
itemGap : 8 ,
formatter : function ( name ) {
formatter : function ( name ) {
const item = data . find ( d => d . name === name )
return item ? ` ${ name } ( ${ item . value } ) ` : name
}
@ -526,7 +523,7 @@ const createPieOption = (data, title) => {
const showFloatingPanels = ( cityName ) => {
const sources = getCitySourceData ( cityName )
const sales = getCitySalesData ( cityName )
if ( sources . length > 0 ) {
sourceData . value = sources
showSourcePanel . value = true
@ -539,7 +536,7 @@ const showFloatingPanels = (cityName) => {
}
} )
}
if ( sales . length > 0 ) {
salesData . value = sales
showSalesPanel . value = true
@ -560,7 +557,7 @@ const hideFloatingPanels = () => {
showSalesPanel . value = false
sourceData . value = null
salesData . value = null
if ( sourceChartInstance ) {
sourceChartInstance . dispose ( )
sourceChartInstance = null
@ -636,7 +633,7 @@ const generateDetailData = (cityName) => {
contactPhone : '134****6789'
}
]
/ / 根 据 不 同 城 市 返 回 相 应 的 明 细 数 据
if ( cityName === getHubName ( ) ) {
return baseData
@ -660,7 +657,7 @@ const showDetailData = (cityName) => {
selectedCity . value = cityName
detailData . value = generateDetailData ( cityName )
showDetailPanel . value = true
/ / 隐 藏 其 他 面 板
hideFloatingPanels ( )
}
@ -742,19 +739,26 @@ const registerUnitedMap = (mapMode = currentMode.value) => {
return unitedName
}
const getMapBgCrop = ( ) => (
currentMode . value === 'local' ? MAP _BG _CROP _LOCAL : MAP _BG _CROP _CHINA
)
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 = ( ) => {
if ( mapBgImage ) {
return Promise . resolve ( mapBgImage )
const loadMapBgImage = ( mode = currentMode . value ) => {
const src = getMapBgImageSrc ( mode )
if ( mapBgImageCache . has ( src ) ) {
return Promise . resolve ( mapBgImageCache . get ( src ) )
}
if ( mapBgImagePromise ) {
return mapBgImagePromise
if ( mapBgImageLoading . has ( src ) ) {
return mapBgImageLoading . get ( src )
}
mapBgImageP romise = new Promise ( ( resolve , reject ) => {
const p romise = new Promise ( ( resolve , reject ) => {
if ( typeof Image === 'undefined' ) {
reject ( new Error ( 'Image is not available' ) )
return
@ -763,17 +767,19 @@ const loadMapBgImage = () => {
const image = new Image ( )
image . decoding = 'async'
image . onload = ( ) => {
mapBgImage = image
mapBgImageCache . set ( src , image )
mapBgImageLoading . delete ( src )
resolve ( image )
}
image . onerror = ( ) => {
mapBgImagePromise = null
reject ( new Error ( ` Failed to load ${ MAP _BG _IMAGE } ` ) )
mapBgImageLoading . delete ( src )
reject ( new Error ( ` Failed to load ${ src } ` ) )
}
image . src = MAP _BG _IMAGE
image . src = src
} )
return mapBgImagePromise
mapBgImageLoading . set ( src , promise )
return promise
}
const paintMapSurfaceTexture = ( ctx , width , height , image , crop ) => {
@ -846,7 +852,7 @@ const createMapSurfaceTexture = (image) => {
return null
}
paintMapSurfaceTexture ( ctx , width , height , image , getMapBgCrop ( ) )
paintMapSurfaceTexture ( ctx , width , height , image , getMapBgCrop ( image ) )
return canvas
}
@ -856,7 +862,7 @@ const ensureMapSurfaceTexture = async () => {
return mapSurfaceTexture
}
const image = await loadMapBgImage ( )
const image = await loadMapBgImage ( key )
mapSurfaceTexture = createMapSurfaceTexture ( image )
mapSurfaceTextureKey = key
return mapSurfaceTexture
@ -964,8 +970,8 @@ const stitchBoundarySegments = (segments) => {
return true
}
while ( extendTail ( ) ) { }
while ( extendHead ( ) ) { }
while ( extendTail ( ) ) { }
while ( extendHead ( ) ) { }
paths . push ( path )
} )
@ -1139,6 +1145,24 @@ const buildGeoLayers = (mapName, unitedMapName, mapBounds) => {
]
}
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 ( )
@ -1151,7 +1175,7 @@ const getChartOption = () => {
const mapLayout = getMapProjection ( mapBounds , 0 )
const outlinePaths = getWireframePaths ( )
const wireframeData = outlinePaths . map ( ( coords ) => ( { coords } ) )
return {
backgroundColor : 'transparent' , / / 透 明 背 景 , 与 卡 片 一 致
/ / t i t l e : {
@ -1177,7 +1201,7 @@ const getChartOption = () => {
color : '#fff' ,
fontSize : 13
} ,
formatter : function ( params ) {
formatter : function ( params ) {
if ( params . componentType === 'geo' ) {
return ` <div style="padding: 8px;">
< strong style = "color: #00d4ff;" > $ { params . name } < / strong > < br / >
@ -1186,16 +1210,16 @@ const getChartOption = () => {
} else if ( params . seriesType === 'scatter' ) {
const value = params . value [ 2 ] || 0
const hubName = getHubName ( )
const nodeType = params . name === hubName ? '中心节点' :
( currentMode . value === 'outflow' ? '销售市场' : '供应来源' )
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 &&
const lineData = linesData . find ( line =>
line . fromName === params . data . fromName &&
line . toName === params . data . toName
)
return ` <div style="padding: 8px;">
@ -1243,25 +1267,23 @@ const getChartOption = () => {
disabled : true
}
} ,
{
name : 'mapWireframe' ,
type : 'lines' ,
coordinateSystem : 'geo' ,
geoIndex : WIREFRAME _GEO _INDEX ,
zlevel : 5 ,
polyline : true ,
silent : true ,
data : wireframeData ,
lineStyle : {
color : MAP _WIREFRAME _COLOR ,
width : 4.2 ,
opacity : 0.98 ,
cap : 'round' ,
join : 'round' ,
shadowBlur : 22 ,
shadowColor : 'rgba(130, 245, 255, 0.78)'
... 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' ,
@ -1347,7 +1369,7 @@ const getChartOption = () => {
geoIndex : GEO _DATA _INDEX ,
zlevel : 6 ,
data : effectScatterData ,
symbolSize : function ( val ) {
symbolSize : function ( val ) {
return Math . max ( 14 , Math . min ( 26 , val [ 2 ] / 60 ) )
} ,
showEffectOn : 'render' ,
@ -1390,7 +1412,7 @@ const reloadMapData = async (mapMode) => {
const mapFilePath = getMapFilePath ( mapMode )
const mapResponse = await fetch ( mapFilePath )
chinaMapData = await mapResponse . json ( )
const mapName = getMapName ( mapMode )
echarts . registerMap ( mapName , chinaMapData )
unitedMapKey = ''
@ -1399,7 +1421,7 @@ const reloadMapData = async (mapMode) => {
mapSurfaceTextureKey = ''
cachedOutlineKey = ''
cachedOutlinePaths = [ ]
return true
} catch ( error ) {
console . error ( '地图数据重新加载失败:' , error )
@ -1414,34 +1436,34 @@ const handleChartResize = () => {
/ / 初 始 化 图 表
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 ) {
@ -1463,7 +1485,7 @@ const updateChart = async (needReloadMap = false) => {
const mapLoaded = await reloadMapData ( currentMode . value )
if ( ! mapLoaded ) return
}
chartInstance . setOption ( getChartOption ( ) , {
notMerge : false ,
replaceMerge : [ 'geo' , 'series' ]
@ -1476,15 +1498,17 @@ const updateChart = async (needReloadMap = false) => {
/ / 监 听 模 式 变 化
watch ( currentMode , ( newMode , oldMode ) => {
emit ( 'mode-change' , newMode )
nextTick ( async ( ) => {
/ / 判 断 是 否 需 要 重 新 加 载 地 图 数 据
const oldMapName = getMapName ( oldMode )
const newMapName = getMapName ( newMode )
const needReloadMap = oldMapName !== newMapName
await updateChart ( needReloadMap )
setTimeout ( handleChartResize , 580 )
} )
} )
} , { immediate : true } )
onMounted ( ( ) => {
initChart ( )
@ -1602,6 +1626,38 @@ onUnmounted(() => {
background : transparent ;
}
. map - stage -- local {
perspective : 900 px ;
perspective - origin : 50 % 58 % ;
}
. map - tilt - layer {
position : absolute ;
inset : 0 ;
transform - style : preserve - 3 d ;
transform - origin : 50 % 55 % ;
transition : transform 0.6 s cubic - bezier ( 0.22 , 1 , 0.36 , 1 ) ;
will - change : transform ;
backface - visibility : hidden ;
}
. map - stage -- local . map - tilt - layer {
transform : rotateX ( 22 deg ) translate3d ( 0 , - 6 % , 22 px ) scale ( 0.92 ) ;
}
. map - stage -- local . chart - container {
transform : none ;
}
. map - tilt - layer . map - world - bg ,
. map - tilt - layer . chart - container {
transform - style : preserve - 3 d ;
}
. map - stage -- local . map - world - bg {
transform : translate3d ( - 50 % , - 50 % , 0 ) ;
}
. map - world - bg {
position : absolute ;
width : var ( -- screen - width , 5120 px ) ;
@ -1856,4 +1912,4 @@ onUnmounted(() => {
. detail - content : : - webkit - scrollbar - thumb : hover {
background : rgba ( 0 , 212 , 255 , 0.7 ) ;
}
< / style >
< / style >