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