echarts迷你航线图

启发

我之前从事军工行业曾突发奇想,用echarts的点、线、图标来构成兵力单元、军事防线、障碍物水雷舰队等,在echarts4.9.0的版本中还有平面地图,接受一定精度的经纬度,可以轻量地展示军事布设图。因为保密协议,相关截图和代码都是没有的。

但是受到启发,我在预览航线时,觉得加载三维地图引擎只用来预览航线样子有些大炮打蚊子,并且频繁销毁三维地图会给浏览器增加不必要的负担,于是想到了用echarts来表示。

因为我要用到一些echarts新版本特性,所以没有用4.9.0版本,也就是说没有底图,但这不影响使用,加载空底图,用css构成网格背景,效果依旧美观。并且我留有引入底图的口子,后续可以引入更加精准的矢量底图,比如高德地图、谷歌地图,也可以自行导入地图数据包,这个包可能要有几百MB,看个人取舍。

echarts的底图只有省市区的的边界线,没有山川河流,没有地名建筑,我是不推荐使用,除非你的点线跨越疆土很大,不然这个底图没有太大意义。

静态效果

动图效果

应用

  1. 调用方法
bash 复制代码
import { createVectorRoutePreviewChart } from '@/utils/special/vectorRoutePreviewChart'
import type { VectorRoutePreviewChartController } from '@/utils/special/vectorRoutePreviewChart'
  1. 准备 DOM ref 和 chart 实例:
bash 复制代码
const routePreviewChartRef = ref<HTMLElement | null>(null)
let routePreviewChart: VectorRoutePreviewChartController | null = null
  1. 初始化图表:
bash 复制代码
function ensureRoutePreviewChart() {
  const container = routePreviewChartRef.value
  if (!container) return null

  routePreviewChart ??= createVectorRoutePreviewChart(container)
  return routePreviewChart
}
  1. 渲染航线:
bash 复制代码
async function renderRoutePreviewChart() {
  await nextTick()

  const chart = ensureRoutePreviewChart()
  if (!chart) return

  chart.render(activePreviewPoints.value)
}
  1. 销毁图表:
bash 复制代码
function disposeRoutePreviewChart() {
  routePreviewChart?.dispose()
  routePreviewChart = null
}

onBeforeUnmount(() => {
  disposeRoutePreviewChart()
})
  1. 模板中放容器:
bash 复制代码
<div class="route-preview">
  <div ref="routePreviewChartRef" class="route-preview-chart" />
</div>

初始航线模板数据

bash 复制代码
const routeTemplate = {
  id: 'route-320033861557817344',
  name: '南湖试验场简单航线',
  routeType: '航点航线',
  points: 3,
  updatedAt: '2026-06-02 11:00:18',
  previewPoints: [
    {
      lng: 119.89659223103736,
      lat: 30.252321947821255,
      height: 15.84,
      pointType: 'takeoff',
      name: '起飞点',
    },
    {
      lng: 119.89560236273502,
      lat: 30.25291376537151,
      height: 118,
      pointType: 'waypoint',
      waypointIndex: 1,
    },
    {
      lng: 119.89488723567959,
      lat: 30.25191373808286,
      height: 118,
      pointType: 'waypoint',
      waypointIndex: 2,
    },
    {
      lng: 119.89105733593298,
      lat: 30.252680394464363,
      height: 118,
      pointType: 'waypoint',
      waypointIndex: 3,
    },
    {
      lng: 119.89040531585302,
      lat: 30.252034830036038,
      height: 15.61,
      pointType: 'landing',
      name: '降落点',
    },
  ],
}

封装代码

config.ts

bash 复制代码
export const CURRENT_CONFIG = {
  // amap
  vmapKey: 'your key',
  vmapSecurityJsCode: 'your security js code',
  vmapVersion: '2.0',
}

vectorRoutePreviewChart.ts

bash 复制代码
import * as echarts from 'echarts'

import type { ECharts, EChartsCoreOption } from 'echarts'

export type VectorRoutePreviewPoint = {
  lng: number
  lat: number
  height?: number
  pointType?: 'takeoff' | 'waypoint' | 'landing'
  waypointIndex?: number
  name?: string
}

export type VectorRoutePreviewStyleOptions = {
  lineColor?: string
  lineWidth?: number
  lineShadowColor?: string
  pointColor?: string
  waypointSize?: number
  startColor?: string
  endColor?: string
  endpointSize?: number
  startLabel?: string
  endLabel?: string
  showEndpointLabel?: boolean
}

export type VectorRoutePreviewChartController = {
  render: (
    points: readonly VectorRoutePreviewPoint[],
    options?: VectorRoutePreviewStyleOptions,
  ) => void
  resize: () => void
  dispose: () => void
  getChart: () => ECharts
}

const DEFAULT_LINE_COLOR = '#37d7ff'
const DEFAULT_LINE_SHADOW_COLOR = 'rgba(55, 215, 255, 0.55)'
const DEFAULT_POINT_COLOR = '#77e6ff'
const DEFAULT_START_COLOR = '#42f59b'
const DEFAULT_END_COLOR = '#ffce4a'

function getPointLabel(point: VectorRoutePreviewPoint, index: number, total: number) {
  if (point.pointType === 'takeoff') return '起飞点'
  if (point.pointType === 'landing') return '降落点'
  if (point.pointType === 'waypoint') return String(point.waypointIndex ?? index + 1)
  if (index === 0) return '起飞点'
  if (index === total - 1) return '降落点'
  return String(point.waypointIndex ?? index)
}

function getPointColor(point: VectorRoutePreviewPoint, index: number, total: number) {
  if (point.pointType === 'takeoff') return DEFAULT_START_COLOR
  if (point.pointType === 'landing') return DEFAULT_END_COLOR
  if (point.pointType === 'waypoint') return DEFAULT_POINT_COLOR
  if (index === 0) return DEFAULT_START_COLOR
  if (index === total - 1) return DEFAULT_END_COLOR
  return DEFAULT_POINT_COLOR
}

function getAxisRange(values: number[]) {
  if (!values.length) return { min: 0, max: 1 }
  const minValue = Math.min(...values)
  const maxValue = Math.max(...values)
  const span = Math.max(maxValue - minValue, 0.0001)
  const padding = Math.max(span * 0.16, 0.00008)

  return {
    min: minValue - padding,
    max: maxValue + padding,
  }
}

export function buildVectorRoutePreviewOption(
  points: readonly VectorRoutePreviewPoint[],
  options: VectorRoutePreviewStyleOptions = {},
): EChartsCoreOption {
  const {
    lineColor = DEFAULT_LINE_COLOR,
    lineWidth = 3,
    lineShadowColor = DEFAULT_LINE_SHADOW_COLOR,
    pointColor = DEFAULT_POINT_COLOR,
    waypointSize = 6,
    startColor = DEFAULT_START_COLOR,
    endColor = DEFAULT_END_COLOR,
    endpointSize = 11,
    startLabel = '起飞点',
    endLabel = '降落点',
    showEndpointLabel = true,
  } = options
  const lngRange = getAxisRange(points.map((point) => point.lng))
  const latRange = getAxisRange(points.map((point) => point.lat))
  const lineData = points.map((point) => [point.lng, point.lat])
  const pointData = points.map((point, index) => {
    const isTakeoff = point.pointType === 'takeoff' || (!point.pointType && index === 0)
    const isLanding =
      point.pointType === 'landing' || (!point.pointType && index === points.length - 1)
    const label = isTakeoff
      ? startLabel
      : isLanding
        ? endLabel
        : getPointLabel(point, index, points.length)

    return {
      name: label,
      value: [point.lng, point.lat],
      symbolSize: isTakeoff || isLanding ? endpointSize : waypointSize,
      label: {
        formatter: label,
      },
      itemStyle: {
        color: isTakeoff
          ? startColor
          : isLanding
            ? endColor
            : point.pointType === 'waypoint'
              ? pointColor
              : getPointColor(point, index, points.length),
      },
    }
  })
  const endpointEffectData = pointData.filter(
    (_, index) =>
      points[index]?.pointType === 'takeoff' ||
      points[index]?.pointType === 'landing' ||
      (!points[index]?.pointType && (index === 0 || index === points.length - 1)),
  )

  return {
    animation: true,
    backgroundColor: 'transparent',
    grid: {
      left: 12,
      right: 12,
      top: 12,
      bottom: 12,
      containLabel: false,
    },
    xAxis: {
      type: 'value',
      min: lngRange.min,
      max: lngRange.max,
      show: false,
    },
    yAxis: {
      type: 'value',
      min: latRange.min,
      max: latRange.max,
      show: false,
    },
    series: [
      {
        type: 'line',
        data: lineData,
        symbol: 'none',
        lineStyle: {
          color: lineColor,
          width: lineWidth,
          shadowBlur: 14,
          shadowColor: lineShadowColor,
        },
        z: 2,
      },
      {
        type: 'scatter',
        data: pointData,
        symbolSize: (value: unknown, params: { data?: { symbolSize?: number } }) =>
          params.data?.symbolSize ?? waypointSize,
        label: {
          show: showEndpointLabel,
          position: 'top',
          color: '#ffffff',
          fontSize: 10,
          fontWeight: 600,
          textBorderColor: 'rgba(0, 0, 0, 0.72)',
          textBorderWidth: 2,
        },
        z: 4,
      },
      {
        type: 'effectScatter',
        data: endpointEffectData,
        symbolSize: endpointSize + 3,
        rippleEffect: {
          brushType: 'stroke',
          scale: 3,
        },
        label: {
          show: false,
        },
        itemStyle: {
          opacity: 0.92,
        },
        z: 3,
      },
    ],
  }
}

export function createVectorRoutePreviewChart(
  container: HTMLElement,
  options: VectorRoutePreviewStyleOptions = {},
): VectorRoutePreviewChartController {
  const chart = echarts.getInstanceByDom(container) ?? echarts.init(container)
  let resizeObserver: ResizeObserver | null = null

  function render(
    points: readonly VectorRoutePreviewPoint[],
    nextOptions: VectorRoutePreviewStyleOptions = {},
  ) {
    chart.setOption(buildVectorRoutePreviewOption(points, { ...options, ...nextOptions }), {
      notMerge: true,
      lazyUpdate: false,
    })
    chart.resize()
  }

  function resize() {
    chart.resize()
  }

  function dispose() {
    resizeObserver?.disconnect()
    resizeObserver = null
    chart.dispose()
  }

  if (typeof ResizeObserver !== 'undefined') {
    resizeObserver = new ResizeObserver(resize)
    resizeObserver.observe(container)
  }

  return {
    render,
    resize,
    dispose,
    getChart: () => chart,
  }
}