282
src/components/map/ChinaFreightMap.vue
Normal file
282
src/components/map/ChinaFreightMap.vue
Normal file
@@ -0,0 +1,282 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
|
||||
import { init, registerMap, use } from 'echarts/core'
|
||||
import { EffectScatterChart, LinesChart } from 'echarts/charts'
|
||||
import { GeoComponent, TooltipComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
import chinaGeoJson from 'china-map-data/china'
|
||||
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||
import type { GeoJSONSourceInput } from 'echarts/types/src/coord/geo/geoTypes.js'
|
||||
import type { MapScene } from '@/types/freight'
|
||||
import MapOperationsHud from './MapOperationsHud.vue'
|
||||
|
||||
use([GeoComponent, TooltipComponent, LinesChart, EffectScatterChart, CanvasRenderer])
|
||||
registerMap('network-freight-china', chinaGeoJson as GeoJSONSourceInput)
|
||||
|
||||
const props = defineProps<{ scene: MapScene }>()
|
||||
const emit = defineEmits<{ selectRoute: [routeId: string] }>()
|
||||
const mapEl = useTemplateRef<HTMLDivElement>('map')
|
||||
const chart = shallowRef<ECharts>()
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
|
||||
const cityCoordinates: Record<string, [number, number]> = {
|
||||
乌鲁木齐: [87.62, 43.82], 兰州: [103.83, 36.06], 成都: [104.07, 30.67],
|
||||
西安: [108.94, 34.34], 重庆: [106.55, 29.56], 昆明: [102.71, 25.04],
|
||||
北京: [116.41, 39.9], 天津: [117.2, 39.13], 济南: [117.12, 36.65],
|
||||
青岛: [120.38, 36.07], 郑州: [113.63, 34.75], 临沂: [118.36, 35.1],
|
||||
武汉: [114.31, 30.59], 南京: [118.8, 32.06], 上海: [121.47, 31.23],
|
||||
杭州: [120.16, 30.27], 长沙: [112.94, 28.23], 南昌: [115.86, 28.68],
|
||||
厦门: [118.09, 24.48], 广州: [113.27, 23.13], 深圳: [114.06, 22.55],
|
||||
南宁: [108.37, 22.82], 哈尔滨: [126.64, 45.76], 沈阳: [123.43, 41.8],
|
||||
鄂尔多斯: [109.78, 39.61], 榆林: [109.73, 38.29], 大同: [113.3, 40.08],
|
||||
神木: [110.5, 38.84], 乌海: [106.82, 39.67], 包头: [109.84, 40.66],
|
||||
晋城: [112.85, 35.49], 阳泉: [113.58, 37.86], 石家庄: [114.52, 38.05],
|
||||
唐山: [118.18, 39.63], 邯郸: [114.49, 36.62],
|
||||
贵阳: [106.63, 26.65], 六盘水: [104.83, 26.59], 毕节: [105.29, 27.3],
|
||||
曲靖: [103.8, 25.49], 昭通: [103.72, 27.34], 达州: [107.47, 31.21],
|
||||
}
|
||||
|
||||
const selectedRoute = computed(() =>
|
||||
props.scene.routes.find((route) => route.id === props.scene.selectedRouteId) ?? props.scene.routes[0],
|
||||
)
|
||||
|
||||
const auxiliaryFlows = [
|
||||
['鄂尔多斯', '上海'], ['榆林', '武汉'], ['大同', '南京'],
|
||||
['乌海', '济南'], ['六盘水', '广州'], ['毕节', '成都'],
|
||||
['曲靖', '南宁'], ['昭通', '重庆'],
|
||||
].map(([from, to], index) => ({
|
||||
id: `A${index + 1}`,
|
||||
name: `${from} → ${to} · 协同通道`,
|
||||
coords: [cityCoordinates[from], cityCoordinates[to]],
|
||||
lineStyle: {
|
||||
color: index % 3 === 0 ? '#ffc857' : '#1fcce8',
|
||||
width: index % 3 === 0 ? 1.25 : 0.9,
|
||||
opacity: index % 3 === 0 ? 0.62 : 0.38,
|
||||
curveness: 0.3 + index % 3 * 0.05,
|
||||
},
|
||||
}))
|
||||
|
||||
const option = computed<EChartsCoreOption>(() => {
|
||||
const activeRouteId = props.scene.selectedRouteId
|
||||
const routeData = props.scene.routes.map((route) => ({
|
||||
id: route.id,
|
||||
name: `${route.from.name} → ${route.to.name}`,
|
||||
coords: [cityCoordinates[route.from.name], cityCoordinates[route.to.name]],
|
||||
lineStyle: {
|
||||
color: route.id === activeRouteId ? '#ffc857' : route.riskCount > 2 ? '#ff415d' : '#27d8ff',
|
||||
width: route.id === activeRouteId ? 2.4 : 1.2,
|
||||
opacity: route.id === activeRouteId ? 1 : 0.66,
|
||||
curveness: 0.24,
|
||||
},
|
||||
}))
|
||||
|
||||
const involvedCities = new Map<string, number>()
|
||||
props.scene.routes.forEach((route) => {
|
||||
involvedCities.set(route.from.name, route.from.volume)
|
||||
involvedCities.set(route.to.name, route.to.volume)
|
||||
})
|
||||
auxiliaryFlows.forEach((flow) => {
|
||||
const [from, to] = flow.name.split(' · ')[0].split(' → ')
|
||||
if (!involvedCities.has(from)) involvedCities.set(from, 74)
|
||||
if (!involvedCities.has(to)) involvedCities.set(to, 68)
|
||||
})
|
||||
|
||||
return {
|
||||
animation: true,
|
||||
animationDurationUpdate: 500,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
backgroundColor: 'rgba(3,20,33,.94)',
|
||||
borderColor: 'rgba(25,217,255,.42)',
|
||||
textStyle: { color: '#d9f9ff', fontSize: 11 },
|
||||
},
|
||||
geo: [
|
||||
{
|
||||
map: 'network-freight-china',
|
||||
roam: false,
|
||||
layoutCenter: ['50%', '50%'],
|
||||
layoutSize: '100%',
|
||||
silent: true,
|
||||
z: 2,
|
||||
itemStyle: {
|
||||
areaColor: {
|
||||
type: 'linear',
|
||||
x: 0, y: 0, x2: 1, y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#0c5471' },
|
||||
{ offset: 0.52, color: '#08344f' },
|
||||
{ offset: 1, color: '#061b31' },
|
||||
],
|
||||
},
|
||||
borderColor: '#32d8ff',
|
||||
borderWidth: 1.1,
|
||||
shadowColor: 'rgba(24,206,255,.48)',
|
||||
shadowBlur: 12,
|
||||
},
|
||||
emphasis: {
|
||||
disabled: false,
|
||||
itemStyle: { areaColor: '#0d6684', borderColor: '#b8f8ff', borderWidth: 1.6 },
|
||||
label: { show: true, color: '#e9fdff', fontSize: 10 },
|
||||
},
|
||||
label: { show: false },
|
||||
},
|
||||
{
|
||||
map: 'network-freight-china',
|
||||
layoutCenter: ['50%', '52.3%'],
|
||||
layoutSize: '100%',
|
||||
silent: true,
|
||||
z: 1,
|
||||
itemStyle: {
|
||||
areaColor: '#020b16',
|
||||
borderColor: '#0d6a8b',
|
||||
borderWidth: 1.3,
|
||||
shadowColor: '#000',
|
||||
shadowBlur: 20,
|
||||
shadowOffsetY: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
type: 'lines',
|
||||
coordinateSystem: 'geo',
|
||||
geoIndex: 0,
|
||||
zlevel: 3,
|
||||
data: routeData,
|
||||
lineStyle: {
|
||||
color: '#0fc8ef',
|
||||
width: 5,
|
||||
opacity: 0.11,
|
||||
curveness: 0.24,
|
||||
shadowColor: '#19d9ff',
|
||||
shadowBlur: 12,
|
||||
},
|
||||
silent: true,
|
||||
},
|
||||
{
|
||||
type: 'lines',
|
||||
coordinateSystem: 'geo',
|
||||
geoIndex: 0,
|
||||
zlevel: 4,
|
||||
data: routeData,
|
||||
polyline: false,
|
||||
lineStyle: { curveness: 0.24 },
|
||||
symbol: ['none', 'arrow'],
|
||||
symbolSize: 6,
|
||||
effect: {
|
||||
show: true,
|
||||
period: 3.7,
|
||||
trailLength: 0.36,
|
||||
symbol: 'arrow',
|
||||
symbolSize: 7,
|
||||
color: '#d8fbff',
|
||||
},
|
||||
emphasis: { lineStyle: { width: 3, opacity: 1 } },
|
||||
},
|
||||
{
|
||||
type: 'lines',
|
||||
coordinateSystem: 'geo',
|
||||
geoIndex: 0,
|
||||
zlevel: 4,
|
||||
data: auxiliaryFlows,
|
||||
lineStyle: { curveness: 0.34, type: 'dashed' },
|
||||
effect: {
|
||||
show: true,
|
||||
period: 5.8,
|
||||
trailLength: 0.5,
|
||||
symbol: 'circle',
|
||||
symbolSize: 4,
|
||||
color: '#ffe4a3',
|
||||
},
|
||||
silent: true,
|
||||
},
|
||||
{
|
||||
type: 'effectScatter',
|
||||
coordinateSystem: 'geo',
|
||||
geoIndex: 0,
|
||||
zlevel: 5,
|
||||
symbolSize: (value: number[]) => Math.max(6, Math.min(13, value[2] / 18)),
|
||||
rippleEffect: { period: 3.6, scale: 3, brushType: 'stroke' },
|
||||
itemStyle: { color: '#8ff6ff', shadowColor: '#19d9ff', shadowBlur: 12 },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
formatter: '{b}',
|
||||
color: '#c9f8ff',
|
||||
fontSize: 9,
|
||||
textBorderColor: '#02101a',
|
||||
textBorderWidth: 3,
|
||||
},
|
||||
data: [...involvedCities].map(([name, volume]) => ({
|
||||
name,
|
||||
value: [...cityCoordinates[name], volume],
|
||||
})),
|
||||
},
|
||||
{
|
||||
type: 'effectScatter',
|
||||
coordinateSystem: 'geo',
|
||||
geoIndex: 0,
|
||||
zlevel: 6,
|
||||
symbolSize: 15,
|
||||
rippleEffect: { period: 2.8, scale: 4.2, brushType: 'stroke' },
|
||||
itemStyle: { color: '#ffc857', shadowColor: '#ffc857', shadowBlur: 16 },
|
||||
label: { show: false },
|
||||
data: ['鄂尔多斯', '榆林', '大同', '神木', '六盘水', '毕节', '曲靖', '昭通', '达州'].map(name => ({
|
||||
name: `${name}煤源`,
|
||||
value: [...cityCoordinates[name], 180],
|
||||
})),
|
||||
silent: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
function handleRouteClick(params: unknown) {
|
||||
const data = (params as { data?: { id?: string } | null }).data
|
||||
if (data?.id) emit('selectRoute', data.id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!mapEl.value) return
|
||||
chart.value = init(mapEl.value, undefined, { renderer: 'canvas' })
|
||||
chart.value.setOption(option.value)
|
||||
chart.value.on('click', handleRouteClick)
|
||||
resizeObserver = new ResizeObserver(() => chart.value?.resize())
|
||||
resizeObserver.observe(mapEl.value)
|
||||
})
|
||||
|
||||
watch(option, (value) => chart.value?.setOption(value, { notMerge: true }))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
chart.value?.off('click', handleRouteClick)
|
||||
chart.value?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="china-freight-map">
|
||||
<div ref="map" class="china-freight-map__canvas" />
|
||||
<div class="china-freight-map__grid" />
|
||||
<div class="china-freight-map__orbits" aria-hidden="true"><i /><i /><i /></div>
|
||||
<MapOperationsHud :scene="scene" @select-route="emit('selectRoute', $event)" />
|
||||
<div class="china-freight-map__caption">REAL-TIME NATIONAL FREIGHT NETWORK</div>
|
||||
<div class="route-focus">
|
||||
<span>重点通道 / {{ selectedRoute.id }}</span>
|
||||
<strong>{{ selectedRoute.from.name }} <i>→</i> {{ selectedRoute.to.name }}</strong>
|
||||
<div><b>{{ selectedRoute.orders.toLocaleString() }}</b> 单 <b>{{ selectedRoute.tonnage.toLocaleString() }}</b> 吨 <b>{{ selectedRoute.punctuality }}%</b> 准时</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.china-freight-map { position: relative; width: 100%; height: 100%; overflow: hidden; background: radial-gradient(ellipse at 50% 54%,rgba(7,91,126,.18),transparent 62%); }
|
||||
.china-freight-map::before { content:''; position:absolute; left:14%; right:14%; bottom:1%; height:18%; border:1px solid rgba(25,217,255,.2); border-radius:50%; box-shadow:0 0 30px rgba(25,217,255,.12),inset 0 0 25px rgba(22,104,255,.1); }
|
||||
.china-freight-map__canvas { position:absolute; inset:0; z-index:2; }
|
||||
.china-freight-map__grid { position:absolute; z-index:1; left:8%; right:8%; bottom:1%; height:36%; transform:perspective(220px) rotateX(66deg); transform-origin:bottom; background-image:linear-gradient(rgba(25,217,255,.1) 1px,transparent 1px),linear-gradient(90deg,rgba(25,217,255,.1) 1px,transparent 1px); background-size:25px 25px; mask-image:radial-gradient(ellipse,black,transparent 70%); }
|
||||
.china-freight-map__orbits{position:absolute;z-index:3;left:54%;top:38%;width:210px;height:120px;transform:translate(-50%,-50%);pointer-events:none}.china-freight-map__orbits i{position:absolute;inset:0;border:1px solid rgba(255,200,87,.23);border-radius:50%;animation:orbitPulse 4.5s ease-in-out infinite}.china-freight-map__orbits i:nth-child(2){inset:15px 28px;border-color:rgba(25,217,255,.28);animation-delay:-1.5s}.china-freight-map__orbits i:nth-child(3){inset:30px 56px;border-color:rgba(255,200,87,.35);animation-delay:-3s}
|
||||
.china-freight-map__caption { position:absolute; z-index:3; left:18px; bottom:12px; color:rgba(76,221,255,.42); font:600 10px Rajdhani; letter-spacing:4px; }
|
||||
.route-focus { position:absolute; z-index:4; right:15px; top:15px; width:195px; padding:10px 12px; border-left:2px solid var(--gold); background:linear-gradient(90deg,rgba(10,40,59,.94),rgba(4,18,31,.38)); pointer-events:none; }
|
||||
.route-focus span{color:#7daab9;font-size:9px;letter-spacing:1px}.route-focus strong{display:block;margin:4px 0 6px;color:#f4fbff;font-size:14px}.route-focus i{color:var(--gold);font-style:normal}.route-focus div{display:flex;gap:7px;color:#729baa;font-size:8px}.route-focus b{color:var(--gold);font:600 12px Rajdhani}
|
||||
@keyframes orbitPulse{0%,100%{opacity:.18;transform:scale(.88) rotate(-4deg)}50%{opacity:.72;transform:scale(1.08) rotate(4deg);box-shadow:0 0 18px rgba(25,217,255,.12)}}
|
||||
</style>
|
||||
Reference in New Issue
Block a user