基于OpenLayers实现离线地图

1 点赞
0 条评论
3601 次浏览
发布于 2022-07-12 11:38

在前端需求里实现地图相关功能时,我们常常可以使用百度地图、高德地图、谷歌地图、天地图等相关在线地图服务及api快速实现地图相关功能,但是对于有些客户只能使用内网不能提供外网的时候,以上地图服务就不能使用了,这个时候就需要用到离线地图服务。

地图的工作原理

一幅精确到街道级别的世界地图图片宽度为数以百万计的像素,由于这些数据太大了,从而导致无法一次下载并且在内存里也无法一次都hold住。实际上,Web地图由许多小的正方形的图片组成,这些小图片称作瓦片。瓦片的大小一般为256*256像素,这些瓦片一个挨一个并列放置以组成一张很大的看似无缝的地图,如果我们想看到更多的地图细节,如想了解国家轮廓级别的地图与街道级别地图的不同,可以使用不同的缩放级别达到目的。缩放级别越高,显示地图的物理尺寸和细节表现也会相应增加。

为了组织如此多的地图瓦片,Web地图使用了一个简单的坐标系统。每一个瓦片都有一个z坐标来表示其缩放级别,还有一个x坐标和一个y坐标用来表示该瓦片在当前缩放级别下的网格内的位置,如:z/x/y。


下载瓦片资源

下载 Offline Map Maker 软件并安装。
选择ArcGis类型的地图,选择缩放级别以及经纬度范围

图示为中国地图大致范围
选择保存位置点击start开始下载

基于vue2实现OpenLayers

OpenLayers的npm包名为: ol

安装

npm i ol -S
Map组件:
<template>
  <div class="ak-topology-map">
    <slot v-if="instance"/>
  </div>
</template>
<script type="text/javascript">
import Map from 'ol/Map';
import { transform, transformExtent } from 'ol/proj';

const EPSG_4326 = 'EPSG:4326';
const EPSG_3857 = 'EPSG:3857';

export default {
  name: 'AkMap',
  data() {
    return {
      // 地图实例
      instance: null,
    };
  },
  provide() {
    return {
      map: this,
    };
  },
  methods: {
    // 坐标转换
    transform(coordinate) {
      return transform(coordinate, EPSG_4326, EPSG_3857);
    },
    // 范围坐标转换
    transformExtent(coordinate) {
      return transformExtent(coordinate, EPSG_4326, EPSG_3857);
    },
    // 初始化地图
    async initMapHandle() {
      const { $nextTick, instance } = this;

      if (instance) return instance;

      try {
        await $nextTick();

        const ins = new Map({ target: this.$el });

        this.instance = ins;

        return ins;
      } catch (e) {
        return e;
      }
    },
    nextTickHandle(fn) {
      const { instance } = this;

      if (instance) {
        fn(instance);
      }
    },
    // 设置view
    setViewHandle(view) {
      this.nextTickHandle((ins) => ins.setView(view));
    },
    // 添加layer
    addLayerHandle(layer) {
      this.nextTickHandle((ins) => ins.addLayer(layer));
    },
    // 添加overlay
    addOverlayHandle(overlay) {
      this.nextTickHandle((ins) => ins.addOverlay(overlay));
    },
    // 移除layer
    removeLayerHandle(layer) {
      this.nextTickHandle((ins) => ins.removeLayer(layer));
    },
    // 移除overlay
    removeOverlayHandle(overlay) {
      this.nextTickHandle((ins) => ins.removeOverlay(overlay));
    },
  },
  mounted() {
    this.initMapHandle();
  },
  created() {
    const { setViewHandle, addLayerHandle, addOverlayHandle, removeLayerHandle, removeOverlayHandle } = this;

    this.$on('map.set.view', setViewHandle);
    this.$on('map.add.layer', addLayerHandle);
    this.$on('map.remove.layer', removeLayerHandle);
    this.$on('map.add.overlay', addOverlayHandle);
    this.$on('map.remove.overlay', removeOverlayHandle);
  },
};
</script>
<style lang="scss" type="text/scss" rel="stylesheet/scss">
@import "ol/ol.css";

.ak-topology-map {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  background-color: #02182d;

  canvas {
    outline: 0;
  }
}
</style>

Tile瓦片组件

<script type="text/javascript">
import Tile from 'ol/layer/WebGLTile';
import XYZ from 'ol/source/XYZ';

export default {
  name: 'AkMapTile',
  props: {
    url: {
      type: String,
      // eslint-disable-next-line
      default: `${__webpack_public_path__}static/map/tile/{z}/{x}/{y}.png`
    },
    size: {
      type: Number,
      default: 256,
    },
  },
  data() {
    return {
      instance: null,
    };
  },
  inject: ['map'],
  methods: {
    // 初始化瓦片
    initTileHandle() {
      const { instance, url, size, map } = this;
      if (instance) return;

      const ins = new Tile({
        source: new XYZ({
          url,
          tileSize: size,
        }),
      });

      map.$emit('map.add.layer', ins);

      this.instance = ins;
    },
  },
  created() {
    this.initTileHandle();
  },
  beforeDestroy() {
    const { map, instance } = this;

    map.$emit('map.remove.layer', instance);
  },
  render() {
    return null;
  },
};
</script>
<style lang="scss" type="text/scss" rel="stylesheet/scss">
</style>

View组件

<script type="text/javascript">
import View from 'ol/View';

export default {
  name: 'AkMapView',
  props: {
    center: {
      type: Array,
      default: () => ([116.482512, 39.94407]),
    },
    extent: {
      type: Array,
      default: () => ([70, 0, 140, 60]),
    },
    zoom: {
      type: Number,
      default: 5,
    },
    minZoom: {
      type: Number,
      default: 5,
    },
    maxZoom: {
      type: Number,
      default: 9,
    },
  },
  data() {
    return {
      instance: null,
    };
  },
  inject: ['map'],
  methods: {
    // 初始化view
    initViewHandle() {
      const { instance, center, extent, zoom, minZoom, maxZoom, map } = this;
      if (instance) return;

      const ins = new View({
        center: map.transform(center),
        zoom,
        maxZoom,
        minZoom,
        extent: map.transformExtent(extent),
      });

      map.$emit('map.set.view', ins);

      this.instance = ins;
    },
  },
  watch: {
    center(v) {
      const { instance, map } = this;
      if (!instance) return;

      instance.setCenter(map.transform(v));
    },
  },
  created() {
    this.initViewHandle();
  },
  render() {
    return null;
  },
};
</script>
<style lang="scss" type="text/scss" rel="stylesheet/scss">
</style>

Overlay覆盖物组件

<template>
  <div class="topology-map-overlay">
    <div
      class="map-overlay-handle"
      :class="handleClass"
    />
    <div class="map-overlay-info">
      <fieldset class="overlay-info-inner">
        <legend class="overlay-info-title" v-text="`总设备台数:${curData.total || 0}台`"/>
        <div class="overlay-info-table">
          <div class="overlay-info-row is-header">
            <div class="overlay-info-cell is-title is-number">编号</div>
            <div class="overlay-info-cell is-title is-ip">IP</div>
            <div class="overlay-info-cell is-title is-status">状态</div>
            <div class="overlay-info-cell is-title is-area">区域</div>
            <div class="overlay-info-cell is-title is-trust">负责人</div>
          </div>
          <Scrollbar class="overlay-info-scrollbar" wrapStyle="overflow-x: hidden;max-height: 170px;">
            <div class="overlay-info-row" v-for="(row, index) in tableList" :key="index">
              <div class="overlay-info-cell is-number" :title="row.number" v-text="row.number"/>
              <div class="overlay-info-cell is-ip" :title="row.ip" v-text="row.ip"/>
              <div
                class="overlay-info-cell is-status"
                :class="getStatusClass(row.status)"
                v-text="getStatusText(row.status)"
              />
              <div class="overlay-info-cell is-area" :title="row.area" v-text="row.area"/>
              <div class="overlay-info-cell is-trust" :title="row.trust" v-text="row.trust"/>
            </div>
          </Scrollbar>
        </div>
      </fieldset>
    </div>
  </div>
</template>
<script type="text/javascript">
import { Scrollbar } from 'element-ui';
import Overlay from 'ol/Overlay';

export default {
  name: 'AkMapOverlay',
  components: {
    Scrollbar,
  },
  props: {
    data: {
      type: Object,
      default: () => ({}),
    },
  },
  data() {
    return {
      instance: null,
    };
  },
  inject: ['map'],
  computed: {
    curData() {
      return this.data || {};
    },
    handleClass() {
      return this.getStatusClass(this.curData.status);
    },
    tableList() {
      const { list } = this.curData;

      return list || [];
    },
    totalText() {
      const { total } = this.curData;

      return `总设备台数:${total || 0}台`;
    },
  },
  methods: {
    getStatusText(status) {
      const statusVal = Number(status) || 0;

      if (statusVal === 0) return '正常运行';
      if (statusVal === 1) return '离线';
      if (statusVal === 2) return '高危预警';

      return null;
    },
    getStatusClass(status) {
      const statusVal = Number(status) || 0;

      return {
        'is-normal': statusVal === 0,
        'is-offline': statusVal === 1,
        'is-warning': statusVal === 2,
      };
    },
    async initOverlayHandle() {
      const { instance, $nextTick, map, curData } = this;
      if (instance) return;

      try {
        await $nextTick();

        const ins = new Overlay({
          position: map.transform([curData.lng, curData.lat]),
          element: this.$el,
        });

        map.$emit('map.add.overlay', ins);

        this.instance = ins;
      } catch (e) {
        return e;
      }
    },
  },
  created() {
    this.initOverlayHandle();
  },
  beforeDestroy() {
    const { map, instance } = this;

    map.$emit('map.remove.overlay', instance);
  },
};
</script>
<style lang="scss" type="text/scss" rel="stylesheet/scss">
.topology-map-overlay {
  position: absolute;
  z-index: 0;
  color: #fff;
  font-size: 20px;

  &:hover {
    z-index: 1;

    .map-overlay-info {
      display: block;
    }
  }

  .map-overlay-handle {
    position: relative;
    width: 56px;
    height: 56px;
    box-sizing: border-box;
    border: 2px solid #59ffda;
    border-radius: 50%;
    overflow: hidden;
    background: url("../images/terminal-icon.png") no-repeat center rgba(#000, 0.55);
    background-size: 50px auto;
    cursor: pointer;

    &.is-normal {
      border-color: #59ffda;
    }

    &.is-offline {
      border-color: #c9c9c9;

      &::before {
        position: absolute;
        width: 100%;
        height: 100%;
        background: url("../images/forbid-icon.png") no-repeat 22px 22px rgba(#000, 0.55);
        background-size: 22px auto;
        content: "";
      }
    }

    &.is-warning {
      border-color: #e02020;
    }
  }

  .map-overlay-info {
    display: none;
    position: absolute;
    top: 56px;
    left: 50%;
    z-index: 999;
    cursor: default;
    transform: translateX(-50%);
  }

  .overlay-info-scrollbar {
    padding-bottom: 17px;
  }

  .overlay-info-inner {
    border: 1px solid rgba(0, 255, 255, 0.3);
    border-radius: 6px;
    background: rgba(14, 24, 114, 0.8);
  }

  .overlay-info-table {
    margin: 10px;
  }

  .overlay-info-title {
    margin-left: 20px;
    padding: 0 50px 0 18px;
    border-radius: 4px;
    line-height: 32px;
    color: #cbdcf4;
    font-size: 14px;
    font-weight: 700;
    white-space: nowrap;
    background: linear-gradient(90deg, #0051bf, rgba(0, 5, 63, 0.03));
  }

  .overlay-info-row {
    display: flex;
    position: relative;
    width: 510px;
    height: 34px;
    box-sizing: border-box;
    padding: 0 14px;
    align-items: center;

    &::before {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 1px;
      background-color: #3fa9f5;
      content: "";
      transform: scaleY(0.5);
    }

    &.is-header {
      background: rgba(31, 42, 107, 0.6);
    }
  }

  .overlay-info-cell {
    flex-shrink: 0;
    margin: 0 10px;
    overflow: hidden;
    color: #cbdcf4;
    font-size: 14px;
    text-overflow: ellipsis;
    white-space: nowrap;

    &.is-title {
      font-weight: 700;
    }

    &.is-number {
      width: 60px;
    }

    &.is-ip {
      width: 100px;
    }

    &.is-status {
      width: 64px;

      &.is-normal {
        color: #59ffda;
      }

      &.is-offline {
        color: #c9c9c9;
      }

      &.is-warning {
        color: #e02020;
      }
    }

    &.is-area {
      width: 100px;
    }

    &.is-trust {
      width: 60px;
    }
  }
}
</style>

使用

<template>
  <AkMap >
    <AkMapView :center="center"/>
    <AkMapTile />
    <AkMapOverlay v-for="(row, index) in list" :data="row" :key="index"/>
  </AkMap>
</template>
<script type="text/javascript">
import AkMap from './components/Map';
import AkMapView from './components/View';
import AkMapTile from './components/Tile';
import AkMapOverlay from './components/Overlay';

export default {
  name: 'AkOfflineMap',
  components: {
    AkMap,
    AkMapView,
    AkMapTile,
    AkMapOverlay,
  },
  data() {
    return {
      center: [116.482512, 39.94407],
      list: [],
    };
  },
};
</script>
<style lang="scss" type="text/scss" rel="stylesheet/scss">
</style>

效果图

说明

说明这里主要使用中国地图5-9缩放级别,如果需要其他地图数据下载相对应的资源即可

OpenLayers 文档 https://openlayers.org

版权所属:开发日记
转载时必须以链接形式注明原始出处及本声明。
"赞助我们,我们才能做的更多&更好"
赞助支持
还没有评论
写下你的评论...