diff --git a/package-lock.json b/package-lock.json
index 53a4f62..de327f8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,6 +12,8 @@
"axios": "^1.5.0",
"echarts": "^5.4.3",
"echarts-gl": "^2.1.0",
+ "flv.js": "^1.6.2",
+ "hls.js": "^1.6.16",
"lunar-javascript": "^1.7.3",
"vue": "^3.3.4",
"vue-router": "^4.5.1"
@@ -1098,6 +1100,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/es6-promise": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
+ "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
+ "license": "MIT"
+ },
"node_modules/esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
@@ -1153,6 +1161,16 @@
"node": ">=8"
}
},
+ "node_modules/flv.js": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/flv.js/-/flv.js-1.6.2.tgz",
+ "integrity": "sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "es6-promise": "^4.2.8",
+ "webworkify-webpack": "^2.1.5"
+ }
+ },
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
@@ -1291,6 +1309,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/hls.js": {
+ "version": "1.6.16",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
+ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
+ "license": "Apache-2.0"
+ },
"node_modules/immutable": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
@@ -1622,6 +1646,12 @@
"vue": "^3.2.0"
}
},
+ "node_modules/webworkify-webpack": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz",
+ "integrity": "sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw==",
+ "license": "MIT"
+ },
"node_modules/zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
@@ -2215,6 +2245,11 @@
"hasown": "^2.0.2"
}
},
+ "es6-promise": {
+ "version": "4.2.8",
+ "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
+ "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="
+ },
"esbuild": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz",
@@ -2260,6 +2295,15 @@
"to-regex-range": "^5.0.1"
}
},
+ "flv.js": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/flv.js/-/flv.js-1.6.2.tgz",
+ "integrity": "sha512-xre4gUbX1MPtgQRKj2pxJENp/RnaHaxYvy3YToVVCrSmAWUu85b9mug6pTXF6zakUjNP2lFWZ1rkSX7gxhB/2A==",
+ "requires": {
+ "es6-promise": "^4.2.8",
+ "webworkify-webpack": "^2.1.5"
+ }
+ },
"follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
@@ -2341,6 +2385,11 @@
"function-bind": "^1.1.2"
}
},
+ "hls.js": {
+ "version": "1.6.16",
+ "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
+ "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA=="
+ },
"immutable": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
@@ -2531,6 +2580,11 @@
"@vue/devtools-api": "^6.6.4"
}
},
+ "webworkify-webpack": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/webworkify-webpack/-/webworkify-webpack-2.1.5.tgz",
+ "integrity": "sha512-2akF8FIyUvbiBBdD+RoHpoTbHMQF2HwjcxfDvgztAX5YwbZNyrtfUMgvfgFVsgDhDPVTlkbb5vyasqDHfIDPQw=="
+ },
"zrender": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz",
diff --git a/package.json b/package.json
index 260ce0a..82e8c2c 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,8 @@
"axios": "^1.5.0",
"echarts": "^5.4.3",
"echarts-gl": "^2.1.0",
+ "flv.js": "^1.6.2",
+ "hls.js": "^1.6.16",
"lunar-javascript": "^1.7.3",
"vue": "^3.3.4",
"vue-router": "^4.5.1"
diff --git a/public/datas/market-cameras.json b/public/datas/market-cameras.json
new file mode 100644
index 0000000..560c193
--- /dev/null
+++ b/public/datas/market-cameras.json
@@ -0,0 +1,54 @@
+{
+ "pageSize": 2,
+ "autoPlayInterval": 10000,
+ "cameras": [
+ {
+ "id": 1,
+ "name": "交易大厅主区",
+ "resolution": "1920x1080",
+ "preview": "/images/monitor/交易大厅主区.jpg",
+ "streamUrl": "",
+ "status": "online"
+ },
+ {
+ "id": 2,
+ "name": "牦牛展示区",
+ "resolution": "1920x1080",
+ "preview": "/images/monitor/牦牛展示区.jpg",
+ "streamUrl": "",
+ "status": "online"
+ },
+ {
+ "id": 3,
+ "name": "停车场入口",
+ "resolution": "1280x720",
+ "preview": "/images/monitor/停车场入口.jpg",
+ "streamUrl": "",
+ "status": "online"
+ },
+ {
+ "id": 4,
+ "name": "安全出口",
+ "resolution": "1280x720",
+ "preview": "/images/monitor/安全出口.jpg",
+ "streamUrl": "",
+ "status": "offline"
+ },
+ {
+ "id": 5,
+ "name": "办公区域",
+ "resolution": "1920x1080",
+ "preview": "/images/monitor/办公区域.jpg",
+ "streamUrl": "",
+ "status": "error"
+ },
+ {
+ "id": 6,
+ "name": "仓储区域",
+ "resolution": "1280x720",
+ "preview": "/images/monitor/仓储区域.jpg",
+ "streamUrl": "",
+ "status": "online"
+ }
+ ]
+}
diff --git a/public/images/图例bg.png b/public/images/图例bg.png
new file mode 100644
index 0000000..557d7c4
Binary files /dev/null and b/public/images/图例bg.png differ
diff --git a/public/images/大气压强.png b/public/images/大气压强.png
new file mode 100644
index 0000000..ec05a3e
Binary files /dev/null and b/public/images/大气压强.png differ
diff --git a/public/images/已交易.png b/public/images/已交易.png
new file mode 100644
index 0000000..636c7ce
Binary files /dev/null and b/public/images/已交易.png differ
diff --git a/public/images/待交易.png b/public/images/待交易.png
new file mode 100644
index 0000000..12de3e1
Binary files /dev/null and b/public/images/待交易.png differ
diff --git a/public/images/总数量.png b/public/images/总数量.png
new file mode 100644
index 0000000..4b5e43c
Binary files /dev/null and b/public/images/总数量.png differ
diff --git a/public/images/成交订单.png b/public/images/成交订单.png
new file mode 100644
index 0000000..603d877
Binary files /dev/null and b/public/images/成交订单.png differ
diff --git a/public/images/按钮.png b/public/images/按钮.png
new file mode 100644
index 0000000..cefedbd
Binary files /dev/null and b/public/images/按钮.png differ
diff --git a/public/images/按钮选中.png b/public/images/按钮选中.png
new file mode 100644
index 0000000..8790d28
Binary files /dev/null and b/public/images/按钮选中.png differ
diff --git a/public/images/温度.png b/public/images/温度.png
new file mode 100644
index 0000000..dd1ff03
Binary files /dev/null and b/public/images/温度.png differ
diff --git a/public/images/湿度.png b/public/images/湿度.png
new file mode 100644
index 0000000..127c785
Binary files /dev/null and b/public/images/湿度.png differ
diff --git a/public/images/空气质量.png b/public/images/空气质量.png
new file mode 100644
index 0000000..59e9701
Binary files /dev/null and b/public/images/空气质量.png differ
diff --git a/public/images/紫外线.png b/public/images/紫外线.png
new file mode 100644
index 0000000..ceb5492
Binary files /dev/null and b/public/images/紫外线.png differ
diff --git a/public/images/降雨量.png b/public/images/降雨量.png
new file mode 100644
index 0000000..e33aadd
Binary files /dev/null and b/public/images/降雨量.png differ
diff --git a/public/images/风力.png b/public/images/风力.png
new file mode 100644
index 0000000..9e14718
Binary files /dev/null and b/public/images/风力.png differ
diff --git a/public/images/风向.png b/public/images/风向.png
new file mode 100644
index 0000000..1c5b8fc
Binary files /dev/null and b/public/images/风向.png differ
diff --git a/src/App.vue b/src/App.vue
index 6fe5ec7..00b1d91 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -45,7 +45,12 @@
-
+
@@ -119,10 +124,25 @@ const systemConfig = ref({
})
const isLayoutReady = ref(false) // 控制页面渲染时机
const isSupplyExpanded = ref(false) // 控制供应信息组件是否展开
+const supplyDetailId = ref('')
// 处理供应信息组件展开/收缩
-const handleSupplyExpand = (expanded) => {
- isSupplyExpanded.value = expanded
+const handleSupplyExpand = (payload) => {
+ if (typeof payload === 'boolean') {
+ isSupplyExpanded.value = payload
+ if (!payload) {
+ supplyDetailId.value = ''
+ }
+ return
+ }
+ isSupplyExpanded.value = !!payload.expanded
+ if (payload.detailId) {
+ supplyDetailId.value = payload.detailId
+ }
+}
+
+const clearSupplyDetailId = () => {
+ supplyDetailId.value = ''
}
onMounted(async () => {
diff --git a/src/components/ChinaMap.vue b/src/components/ChinaMap.vue
index fbc18f3..73a0936 100644
--- a/src/components/ChinaMap.vue
+++ b/src/components/ChinaMap.vue
@@ -1,16 +1,22 @@
-
-
+
+
@@ -72,7 +78,26 @@
-
+
+
+
+
![]()
+
+ -
+
+ 流向
+
+ -
+
+ 交易城市
+
+ -
+
+ 重要节点
+
+
+
+
@@ -80,6 +105,10 @@
import { ref, 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 chartRef = ref(null)
const sourceChartRef = ref(null)
@@ -90,6 +119,9 @@ let sourceChartInstance = null
let salesChartInstance = null
let chinaMapData = null
let tradingData = null
+let mapSystemConfig = null
+let cachedOutlineKey = ''
+let cachedOutlinePaths = []
// 浮动面板状态
const showSourcePanel = ref(false)
@@ -108,6 +140,47 @@ const modes = [
{ key: 'local', label: '红原出栏分布图' }
]
+const BTN_NORMAL = '/images/按钮.png'
+const BTN_ACTIVE = '/images/按钮选中.png'
+const LEGEND_BG = '/images/图例bg.png'
+const GEO_DATA_INDEX = 2
+const WIREFRAME_GEO_INDEX = 3
+const MAP_ASPECT_SCALE = 0.82
+const MAP_ZOOM_FACTOR = 0.97
+const MAP_LAYOUT_SIZE = '84%'
+const MAP_ANCHOR_CENTER = [104.8, 37.2]
+const MAP_ANCHOR_ZOOM = 1.05
+const MAP_GLOBAL_OFFSET_LNG = 1.45
+const MAP_GLOBAL_OFFSET_LAT = 0.9
+const MAP_GLOBAL_LAYOUT_X = -2.8
+const MAP_GLOBAL_LAYOUT_Y = 3.6
+const MAP_SHADOW_OFFSET_FAR = 1.25
+const MAP_SHADOW_OFFSET_NEAR = 0.75
+const MAP_SHADOW_OFFSET_LNG = -0.45
+const MAP_SHADOW_OFFSET_FAR_LOCAL = 5
+const MAP_SHADOW_OFFSET_NEAR_LOCAL = 3
+const MAP_SHADOW_OFFSET_LNG_LOCAL = -2
+const MAP_WIREFRAME_OFFSET = -0.65
+const MAP_WIREFRAME_OFFSET_LNG = 0.12
+const MAP_WIREFRAME_OFFSET_LOCAL = -3.5
+const MAP_WIREFRAME_OFFSET_LNG_LOCAL = 0.6
+const MAP_FILL_WEST = [45, 181, 181]
+const MAP_FILL_EAST = [12, 123, 176]
+const MAP_GRADIENT_MIN_LNG = 73
+const MAP_GRADIENT_MAX_LNG = 135
+const MAP_GRADIENT_MIN_LAT = 18
+const MAP_GRADIENT_MAX_LAT = 54
+const MAP_WIREFRAME_COLOR = '#f8feff'
+const MAP_INNER_BORDER = 'rgba(6, 38, 62, 0.62)'
+const MAP_INNER_BORDER_WIDTH = 0.8
+const MAP_SHADOW_FAR = '#000000'
+const MAP_SHADOW_NEAR = '#000000'
+const MAP_LABEL_COLOR = '#ffffff'
+const MAP_FILL_OPACITY = 0.88
+const MAP_TEXTURE_SIZE = 128
+
+const mapTextureCache = new Map()
+
// 获取当前模式对应的地图文件路径
const getMapFilePath = (mode) => {
switch (mode) {
@@ -128,21 +201,74 @@ const getMapName = (mode) => {
}
}
+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 = await fetch(mapFilePath)
+ const [mapResponse, systemConfig, fallbackResponse] = await Promise.all([
+ fetch(mapFilePath),
+ loadSystemConfig(),
+ fetch(FALLBACK_TRADING_JSON)
+ ])
chinaMapData = await mapResponse.json()
-
- // 加载牦牛交易数据
- const tradingResponse = await fetch('./yak-trading-data.json')
- tradingData = await tradingResponse.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)
+ cachedOutlineKey = ''
+ cachedOutlinePaths = []
+ mapTextureCache.clear()
return true
} catch (error) {
@@ -160,17 +286,15 @@ const getFlowColor = (value) => {
}
// 获取节点颜色(区分流出和流入)
-const getNodeColor = (city, modeData) => {
+const getNodeColor = (city) => {
+ const hubName = getHubName()
if (currentMode.value === 'outflow') {
- // 流出模式:红原县为红色(流出源),其他为蓝色(流入目标)
- return city === '红原县' ? '#FF6B6B' : '#00D4FF'
- } else if (currentMode.value === 'inflow') {
- // 流入模式:红原县为绿色(流入目标),其他为橙色(流出源)
- return city === '红原县' ? '#67C23A' : '#E6A23C'
- } else {
- // 本地模式:红原县为紫色,其他为青色
- return city === '红原县' ? '#9C27B0' : '#26C6DA'
+ return city === hubName ? '#FFD048' : '#6ecfff'
+ }
+ if (currentMode.value === 'inflow') {
+ return city === hubName ? '#67C23A' : '#5eb8ff'
}
+ return city === hubName ? '#FFD048' : '#6ecfff'
}
// 获取当前模式的数据
@@ -195,6 +319,7 @@ const generateScatterData = () => {
citySet.forEach(city => {
const coord = tradingData.geoCoordMap[city]
if (coord) {
+ const hubName = getHubName()
// 计算该城市的总流量
let totalFlow = 0
modeData.flows.forEach(flow => {
@@ -206,13 +331,14 @@ const generateScatterData = () => {
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, modeData),
+ color: getNodeColor(city),
borderColor: '#fff',
borderWidth: 1.5,
shadowBlur: 6,
- shadowColor: getNodeColor(city, modeData)
+ shadowColor: getNodeColor(city)
}
})
}
@@ -224,16 +350,22 @@ const generateScatterData = () => {
// 生成流向线数据
const generateLinesData = () => {
const modeData = getCurrentModeData()
- return modeData.flows.map(flow => ({
- fromName: flow.from,
- toName: flow.to,
- coords: [
- tradingData.geoCoordMap[flow.from],
- tradingData.geoCoordMap[flow.to]
- ],
- value: flow.value,
- description: flow.description
- }))
+ 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)
}
// 生成波纹效果数据
@@ -249,18 +381,21 @@ const generateEffectScatterData = () => {
})
// 为流量大的城市添加波纹效果
+ const flowThreshold = tradingData?.flowLevels?.medium?.threshold || 40
+ const hubName = getHubName()
Object.entries(cityFlows).forEach(([city, totalFlow]) => {
- if (totalFlow > 400) { // 流量阈值
+ 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, modeData),
+ color: getNodeColor(city),
shadowBlur: 12,
- shadowColor: getNodeColor(city, modeData)
+ shadowColor: getNodeColor(city)
}
})
}
@@ -483,7 +618,7 @@ const generateDetailData = (cityName) => {
]
// 根据不同城市返回相应的明细数据
- if (cityName === '红原县') {
+ if (cityName === getHubName()) {
return baseData
} else {
// 为其他城市生成相应的数据
@@ -521,70 +656,386 @@ const closeDetailPanel = () => {
// 计算地图最佳视野范围
const calculateMapBounds = () => {
- // 对于红原出栏分布图模式,让ECharts自动适应GeoJSON边界,不需要手动计算
if (currentMode.value === 'local') {
- console.log('红原出栏分布图模式:使用ECharts自动缩放')
- return { center: [104, 35], zoom: 1.0 } // 返回默认值,实际不会使用
+ return { center: [104, 35], zoom: 1.0 }
}
-
- // 其他模式使用原有的基于交易数据的计算方式
- const modeData = getCurrentModeData()
- const cities = new Set()
-
- // 收集当前模式下所有涉及的城市
- modeData.flows.forEach(flow => {
- cities.add(flow.from)
- cities.add(flow.to)
+
+ return {
+ center: [...MAP_ANCHOR_CENTER],
+ zoom: MAP_ANCHOR_ZOOM * MAP_ZOOM_FACTOR
+ }
+}
+
+const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
+
+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 getRegionAreaColor = (lng, lat, minLng, maxLng, minLat, maxLat) => {
+ const { r, g, b } = blendDesignColor(lng, lat, minLng, maxLng, minLat, maxLat)
+ const cacheKey = `${r},${g},${b},${MAP_FILL_OPACITY}`
+
+ if (!mapTextureCache.has(cacheKey)) {
+ mapTextureCache.set(cacheKey, createRegionTextureCanvas(r, g, b, MAP_FILL_OPACITY))
+ }
+
+ return {
+ image: mapTextureCache.get(cacheKey),
+ repeat: 'repeat'
+ }
+}
+
+const createRegionTextureCanvas = (r, g, b, alpha) => {
+ if (typeof document === 'undefined') {
+ return null
+ }
+
+ const size = MAP_TEXTURE_SIZE
+ const canvas = document.createElement('canvas')
+ canvas.width = size
+ canvas.height = size
+ const ctx = canvas.getContext('2d')
+ if (!ctx) {
+ return null
+ }
+
+ ctx.fillStyle = `rgba(${r}, ${g}, ${b}, ${alpha})`
+ ctx.fillRect(0, 0, size, size)
+
+ for (let i = 0; i < 420; i += 1) {
+ const x = Math.random() * size
+ const y = Math.random() * size
+ const radius = Math.random() * 1.6 + 0.3
+ ctx.fillStyle = `rgba(255, 255, 255, ${Math.random() * 0.1 + 0.02})`
+ ctx.beginPath()
+ ctx.arc(x, y, radius, 0, Math.PI * 2)
+ ctx.fill()
+ }
+
+ for (let i = 0; i < 260; i += 1) {
+ const x = Math.random() * size
+ const y = Math.random() * size
+ const radius = Math.random() * 2.4 + 0.6
+ ctx.fillStyle = `rgba(8, 45, 72, ${Math.random() * 0.08 + 0.02})`
+ ctx.beginPath()
+ ctx.arc(x, y, radius, 0, Math.PI * 2)
+ ctx.fill()
+ }
+
+ for (let i = 0; i < 6; i += 1) {
+ const x = Math.random() * size
+ const y = Math.random() * size
+ const radius = Math.random() * 28 + 18
+ const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius)
+ gradient.addColorStop(0, `rgba(255, 255, 255, ${Math.random() * 0.06 + 0.02})`)
+ gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
+ ctx.fillStyle = gradient
+ ctx.beginPath()
+ ctx.arc(x, y, radius, 0, Math.PI * 2)
+ ctx.fill()
+ }
+
+ return canvas
+}
+
+const blendDesignColor = (lng, lat, minLng, maxLng, minLat, maxLat) => {
+ const lngRatio = clamp((lng - minLng) / (maxLng - minLng), 0, 1)
+ const latRatio = clamp((lat - minLat) / (maxLat - minLat), 0, 1)
+
+ let r = MAP_FILL_WEST[0] + (MAP_FILL_EAST[0] - MAP_FILL_WEST[0]) * lngRatio
+ let g = MAP_FILL_WEST[1] + (MAP_FILL_EAST[1] - MAP_FILL_WEST[1]) * lngRatio
+ let b = MAP_FILL_WEST[2] + (MAP_FILL_EAST[2] - MAP_FILL_WEST[2]) * lngRatio
+
+ const northBoost = (0.62 - latRatio) * 16
+ const southDepth = (latRatio - 0.38) * 10
+ r = clamp(r + northBoost - southDepth * 0.35, 0, 255)
+ g = clamp(g + northBoost * 0.95 - southDepth * 0.25, 0, 255)
+ b = clamp(b + northBoost * 0.55 - southDepth * 0.15, 0, 255)
+
+ return {
+ r: Math.round(r),
+ g: Math.round(g),
+ b: Math.round(b)
+ }
+}
+
+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 coordinates = Array.from(cities)
- .map(city => tradingData.geoCoordMap[city])
- .filter(coord => coord && coord.length === 2)
-
- if (coordinates.length === 0) {
- return { center: [104, 35], zoom: 0.9 }
+
+ 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 lngs = coordinates.map(coord => coord[0])
- const lats = coordinates.map(coord => coord[1])
-
- const minLng = Math.min(...lngs)
- const maxLng = Math.max(...lngs)
- const minLat = Math.min(...lats)
- const maxLat = Math.max(...lats)
-
- // 计算中心点
- const centerLng = (minLng + maxLng) / 2
- const centerLat = (minLat + maxLat) / 2
-
- // 计算跨度
- const lngSpan = maxLng - minLng
- const latSpan = maxLat - minLat
- const maxSpan = Math.max(lngSpan, latSpan)
-
- // 根据跨度计算缩放级别,添加适当的边距
- let zoom = 0.9
- if (maxSpan > 0) {
- // 基础缩放计算,加上20%的边距
- const baseZoom = Math.min(20 / (maxSpan * 1.2), 3.5)
- zoom = Math.max(1.2, Math.min(baseZoom, 3.0))
+
+ 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
}
-
- // 针对不同模式进行微调
- if (currentMode.value === 'outflow' || currentMode.value === 'inflow') {
- zoom = Math.min(zoom * 1.1, 2.8) // 销售和供应模式稍微放大一些
- } else {
- zoom = Math.min(zoom * 1.2, 3.0) // 本地模式更大放大
+
+ const minPoints = currentMode.value === 'local' ? 8 : 30
+ cachedOutlineKey = cacheKey
+ cachedOutlinePaths = buildNationalOutlinePaths(chinaMapData)
+ .filter((path) => path.length >= minPoints)
+
+ return cachedOutlinePaths
+}
+
+const buildRegionFillData = () => {
+ if (!chinaMapData?.features?.length) {
+ return []
}
-
+
+ let minLng = MAP_GRADIENT_MIN_LNG
+ let maxLng = MAP_GRADIENT_MAX_LNG
+ let minLat = MAP_GRADIENT_MIN_LAT
+ let maxLat = MAP_GRADIENT_MAX_LAT
+
+ if (currentMode.value === 'local') {
+ const centers = chinaMapData.features.map(getFeatureCenter)
+ const lngs = centers.map((center) => center[0])
+ const lats = centers.map((center) => center[1])
+ minLng = Math.min(...lngs)
+ maxLng = Math.max(...lngs)
+ minLat = Math.min(...lats)
+ maxLat = Math.max(...lats)
+ if (maxLng - minLng < 0.001) {
+ maxLng = minLng + 1
+ }
+ if (maxLat - minLat < 0.001) {
+ maxLat = minLat + 1
+ }
+ }
+
+ return chinaMapData.features.map((feature) => {
+ const name = feature.properties?.name
+ if (!name) {
+ return null
+ }
+ const [lng, lat] = getFeatureCenter(feature)
+ return {
+ name,
+ itemStyle: {
+ areaColor: getRegionAreaColor(lng, lat, minLng, maxLng, minLat, maxLat),
+ borderColor: MAP_INNER_BORDER,
+ borderWidth: MAP_INNER_BORDER_WIDTH
+ }
+ }
+ }).filter(Boolean)
+}
+
+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 {
- center: [centerLng, centerLat],
- zoom: zoom
+ aspectScale: MAP_ASPECT_SCALE,
+ zoom,
+ center: [
+ center[0] + lngOffset + MAP_GLOBAL_OFFSET_LNG,
+ center[1] + latOffset + MAP_GLOBAL_OFFSET_LAT
+ ]
}
}
+const buildGeoLayers = (mapName, mapBounds) => {
+ const isLocal = currentMode.value === 'local'
+ const shadowFarOffset = isLocal ? MAP_SHADOW_OFFSET_FAR_LOCAL : MAP_SHADOW_OFFSET_FAR
+ const shadowNearOffset = isLocal ? MAP_SHADOW_OFFSET_NEAR_LOCAL : MAP_SHADOW_OFFSET_NEAR
+ const shadowLngOffset = isLocal ? MAP_SHADOW_OFFSET_LNG_LOCAL : MAP_SHADOW_OFFSET_LNG
+ const wireframeOffset = isLocal ? MAP_WIREFRAME_OFFSET_LOCAL : MAP_WIREFRAME_OFFSET
+ const wireframeLngOffset = isLocal ? MAP_WIREFRAME_OFFSET_LNG_LOCAL : MAP_WIREFRAME_OFFSET_LNG
+
+ return [
+ {
+ map: mapName,
+ roam: false,
+ ...getMapProjection(mapBounds, shadowFarOffset, shadowLngOffset),
+ silent: true,
+ zlevel: 0,
+ z: 1,
+ label: { show: false },
+ itemStyle: {
+ areaColor: MAP_SHADOW_FAR,
+ borderColor: MAP_SHADOW_FAR,
+ borderWidth: 0
+ }
+ },
+ {
+ map: mapName,
+ roam: false,
+ ...getMapProjection(mapBounds, shadowNearOffset, shadowLngOffset),
+ silent: true,
+ zlevel: 0,
+ z: 2,
+ label: { show: false },
+ itemStyle: {
+ areaColor: MAP_SHADOW_NEAR,
+ borderColor: MAP_SHADOW_NEAR,
+ borderWidth: 0
+ }
+ },
+ {
+ 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 getChartOption = () => {
const modeData = getCurrentModeData()
@@ -592,6 +1043,10 @@ const getChartOption = () => {
const linesData = generateLinesData()
const effectScatterData = generateEffectScatterData()
const mapBounds = calculateMapBounds()
+ const mapName = getMapName(currentMode.value)
+ const mapLayout = getMapProjection(mapBounds, 0)
+ const outlinePaths = getWireframePaths()
+ const wireframeData = outlinePaths.map((coords) => ({ coords }))
return {
backgroundColor: 'transparent', // 透明背景,与卡片一致
@@ -626,7 +1081,8 @@ const getChartOption = () => {
`
} else if (params.seriesType === 'scatter') {
const value = params.value[2] || 0
- const nodeType = params.name === '红原县' ? '中心节点' :
+ const hubName = getHubName()
+ const nodeType = params.name === hubName ? '中心节点' :
(currentMode.value === 'outflow' ? '销售市场' : '供应来源')
return `
${params.name}
@@ -654,80 +1110,77 @@ const getChartOption = () => {
return params.name
}
},
- legend: {
- orient: 'vertical',
- left: 'left',
- top: 'bottom',
- data: ['交易城市', '流向线', '重要节点'],
- textStyle: {
- color: '#8cc8ff',
- fontSize: 12
- },
- itemStyle: {
- borderColor: '#00d4ff'
- }
- },
- geo: {
- map: getMapName(currentMode.value),
- roam: true,
- // 只有在非local模式时才手动设置zoom和center,让ECharts自动适应GeoJSON范围
- ...(currentMode.value !== 'local' && {
- zoom: mapBounds.zoom,
- center: mapBounds.center
- }),
- label: {
- show: true,
- color: '#ffffff',
- fontSize: 11,
- fontWeight: 'bold',
- shadowBlur: 3,
- shadowColor: '#000'
- },
- emphasis: {
+ geo: buildGeoLayers(mapName, mapBounds),
+ series: [
+ {
+ name: 'mapFill',
+ type: 'map',
+ map: mapName,
+ roam: false,
+ ...mapLayout,
+ zlevel: 2,
+ silent: true,
+ selectedMode: false,
+ data: buildRegionFillData(),
+ itemStyle: {
+ borderColor: MAP_INNER_BORDER,
+ borderWidth: MAP_INNER_BORDER_WIDTH
+ },
label: {
show: true,
- color: '#00d4ff',
- fontSize: 13,
- fontWeight: 'bold'
+ color: MAP_LABEL_COLOR,
+ fontSize: 11,
+ fontFamily: 'Microsoft YaHei, sans-serif',
+ fontWeight: 'bold',
+ textBorderColor: 'rgba(0, 0, 0, 0.45)',
+ textBorderWidth: 2
},
- itemStyle: {
- areaColor: '#1e3a5f',
- borderColor: '#00d4ff',
- borderWidth: 2,
- shadowBlur: 10,
- shadowColor: '#00d4ff'
+ emphasis: {
+ 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: 3.2,
+ opacity: 0.96,
+ cap: 'round',
+ join: 'round',
+ shadowBlur: 16,
+ shadowColor: 'rgba(120, 230, 255, 0.6)'
}
},
- itemStyle: {
- areaColor: '#0f1b2e', // 深蓝色地图
- borderColor: '#2c5282',
- borderWidth: 1,
- shadowBlur: 5,
- shadowColor: 'rgba(0, 212, 255, 0.3)'
- }
- },
- series: [
{
name: '交易城市',
type: 'scatter',
coordinateSystem: 'geo',
+ geoIndex: GEO_DATA_INDEX,
+ zlevel: 6,
data: scatterData,
symbolSize: 8,
label: {
show: true,
position: 'top',
- color: '#ffffff',
+ color: MAP_LABEL_COLOR,
fontSize: 11,
fontWeight: 'bold',
formatter: '{b}',
- shadowBlur: 2,
- shadowColor: '#000'
+ textBorderColor: 'rgba(0, 0, 0, 0.35)',
+ textBorderWidth: 2
},
emphasis: {
label: {
show: true,
fontSize: 13,
- color: '#00d4ff'
+ color: MAP_LABEL_COLOR
},
itemStyle: {
borderColor: '#00d4ff',
@@ -737,28 +1190,30 @@ const getChartOption = () => {
}
},
{
- name: '流向线',
+ name: '流向',
type: 'lines',
coordinateSystem: 'geo',
+ geoIndex: GEO_DATA_INDEX,
+ zlevel: 6,
data: linesData,
large: true,
effect: {
show: true,
- constantSpeed: 40,
+ constantSpeed: 45,
symbol: 'arrow',
- symbolSize: 12,
- trailLength: 0.15,
- color: '#ffffff',
- shadowBlur: 6,
- shadowColor: '#00d4ff'
+ symbolSize: 10,
+ trailLength: 0.2,
+ color: '#e8f7ff',
+ shadowBlur: 8,
+ shadowColor: 'rgba(110, 207, 255, 0.8)'
},
lineStyle: {
- color: '#00d4ff',
- width: 1.2,
- opacity: 0.7,
- curveness: 0.3,
- shadowBlur: 3,
- shadowColor: '#00d4ff'
+ color: '#7ee8ff',
+ width: 1.5,
+ opacity: 0.85,
+ curveness: 0.28,
+ shadowBlur: 8,
+ shadowColor: 'rgba(110, 232, 255, 0.55)'
},
emphasis: {
lineStyle: {
@@ -772,6 +1227,8 @@ const getChartOption = () => {
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))
@@ -785,12 +1242,12 @@ const getChartOption = () => {
label: {
show: true,
position: 'top',
- color: '#ffffff',
+ color: MAP_LABEL_COLOR,
fontSize: 12,
fontWeight: 'bold',
formatter: '{b}',
- shadowBlur: 3,
- shadowColor: '#000'
+ textBorderColor: 'rgba(0, 0, 0, 0.35)',
+ textBorderWidth: 2
},
itemStyle: {
shadowBlur: 15
@@ -819,6 +1276,9 @@ const reloadMapData = async (mapMode) => {
const mapName = getMapName(mapMode)
echarts.registerMap(mapName, chinaMapData)
+ cachedOutlineKey = ''
+ cachedOutlinePaths = []
+ mapTextureCache.clear()
return true
} catch (error) {
@@ -872,11 +1332,8 @@ const updateChart = async (needReloadMap = false) => {
}
chartInstance.setOption(getChartOption(), {
- replaceMerge: ['series', 'geo'],
- transition: {
- duration: 1200,
- easing: 'cubicInOut'
- }
+ notMerge: false,
+ replaceMerge: ['geo', 'series']
})
}
}
@@ -911,64 +1368,184 @@ onUnmounted(() => {
\ No newline at end of file
+
diff --git a/src/components/MarketRealtimeMonitor.vue b/src/components/MarketRealtimeMonitor.vue
index 692c3bc..45c9970 100644
--- a/src/components/MarketRealtimeMonitor.vue
+++ b/src/components/MarketRealtimeMonitor.vue
@@ -1,75 +1,124 @@
-