shuilong

shuilong的博客

Custom Map

Background#

Recently, I received a project to create a webpage that displays an image map of a town. When the mouse hovers over each village, the corresponding village needs to be highlighted, and clicking on it should redirect to the detail page of that village.

WeChat Screenshot_20230923021604

Technical Research#

Echarts has a map component that supports map display, such as the USA Map. Therefore, we can use the image map as a base map and then display the map on top using Echarts to achieve the desired effect.

The problem is that displaying the map requires geo data. After searching online, I found that platforms like Alibaba Cloud's Data Visualization Platform minimally support city-level (district boundary) data, while geojson.cn provides county-level (street boundary) data but is made and charged on demand. However, we need town-level (village boundary) data. The site https://hxkj.vip/map/ mentioned that village boundaries can be customized, but after contacting them via QQ, there was no response, so I had to create the village-level geo data myself.

Creating Geo Data#

Image Map

Since the image map has marked the village boundaries with specific colored lines, we first identify the boundaries by recognizing the colors.

let img = new window.Image();
img.crossOrigin = `Anonymous`;
img.src = "./image-1.jpg";
img.onload = function () {
  canvas = document.getElementById("myCanvas");
  canvas.width = img.width;
  canvas.height = img.height;
  let ctx = canvas.getContext(
    "2d"
  );
  ctx.drawImage(img, 0, 0);

  const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
  data = imgData.data;

  for (let i = 0; i < canvas.width; ++i) {
    for (let j = 0; j < canvas.height; ++j) {
        let cols = canvas.width;
        let c = extractPixelColor(cols, j, i);

        const key = [c.red, c.green, c.blue].join('_');

        if (c.red >= 200 || c.red <= 180
              || c.green >= 10 
              || c.blue >= 249 || c.blue <= 200) {
            continue;
        }

        appendMark({
            left: i,
            top: j
        })
    }
  }
};

function appendMark(position) {
  const mark = document.createElement('div');
  mark.style.width = '6px';
  mark.style.height = '6px';
  mark.style.borderRadius = '50%';
  mark.style.position = 'absolute';
  mark.style.top = `${position.top}px`;
  mark.style.left = `${position.left}px`;
  mark.style.background = '#000';
  mark.classList.add('mark');

  mark.onclick = function() {
      this.classList.add('active');
      mark.dataset.index = index;
      index++;
  };

  const container = document.getElementById('myCanvasContainer');
  container.appendChild(mark);
}

const extractPixelColor = (cols, x, y) => {
  let pixel = cols * x + y;
  let position = pixel * 4;
  return {
    red: data[position],
    green: data[position + 1],
    blue: data[position + 2],
    alpha: data[position + 3],
  };
};

The above code uses getImageData to get the image data, then iterates through each pixel of the image to find the specific color of the boundary, generating a marker at that position on the webpage. As shown in the figure below.

1222

We select a village and mark it in a clockwise direction to obtain the boundary of that village.

2222

The purpose of this segment of code is to allow for some tolerance in color judgment.

if (c.red >= 200 || c.red <= 180
                 || c.green >= 10 
                 || c.blue >= 249 || c.blue <= 200) {
                continue;
            }
const data = [...document.querySelectorAll('.active')]
    .map((item) => { return {
        left: parseInt(item.style.left), 
        top: parseInt(item.style.top),
        index: parseInt(item.dataset.index)} 
    })
    .sort((a, b) => {
        return a.index - b.index;
    });

Then executing this piece of code allows us to obtain the data of all points that make up the boundary of a certain village, with each point's data containing left and top, representing the position of that point relative to the image map.

With this data, we need to calculate the latitude and longitude of each point. Open the Boundary Generator and navigate to the approximate location of the village to be drawn.

WeChat Image_20230922211616

Remember to switch the map to satellite mode for better identification of specific villages.

Use the rectangular marking function in the left toolbar to mark the location of the village.

222323

Click on the view GeoJSON on the right.

WeChat Image_20230922211945

WeChat Image_20230922212020

You can obtain the latitude and longitude of the four positions.

For example, we obtain the latitude and longitude of the upper left and lower right corners.

const containerCoordinates = [
    [
        121.16169327,
        37.50491774
      ],
    
      [
        121.22931442,
        37.43753236
      ]
    ]
const CANVAS_WIDTH = 2000;
const CANVAS_HEIGHT = 2666;
const convert = ([left, top]) => {
    const x = containerCoordinates[0][0] + (containerCoordinates[1][0] - containerCoordinates[0][0]) * (left / CANVAS_WIDTH);
    const y = containerCoordinates[0][1] - (containerCoordinates[0][1] - containerCoordinates[1][1]) * (top / CANVAS_HEIGHT);

    return [x, y];
}

data.push(data[0]);
const coordinates = data.map((item) => {
    return convert([item.left, item.top]);
});

By transforming the previous data in this way, we can obtain the latitude and longitude data for each point.

Essentially, this method has issues because latitude and longitude are relative to the Earth's sphere, and the position on a two-dimensional plane and latitude and longitude require projection, which is the Mercator Projection. Echarts has the convertFromPixel method that can convert two-dimensional plane positions into latitude and longitude.

const myChart = echarts.init(document.getElementById('main'));
myChart.setOption({
    geo: {
        left: 0,
        top: 0,
        right: 0,
        bottom: 0,
        boundingCoords: [
            [
                121.16169327,
                37.50491774
            ],
            [
                121.22931442,
                37.43753236
            ]
        ]
    }
});
const convert = ([left, top]) => {
    return myChart.convertFromPixel({geoIndex: 0}, [left, top]);
}
data.push(data[0]);
const coordinates = data.map((item) => {
    return convert([item.left, item.top]);
});

In fact, at first, I did not use the Mercator projection conversion but directly used the first method, and found that it was still quite similar, possibly because the area to be displayed is very small, so it is almost equivalent to a two-dimensional plane.

Map Display#

const data = {
    "type": "FeatureCollection",
    "features": [
        {
            "type": "Feature",
            "id": "01",
            "properties": { "name": "Ding Village" },
            "geometry": {
                "type": "Polygon",
                "coordinates": coordinates
            }
        }
    ]
}
echarts.registerMap('village', data);

const myChart = echarts.init(document.getElementById('main'));
myChart.setOption({
    geo: {
        map: 'village',
        itemStyle: {
            areaColor: 'transparent',
            borderColor:  '#c004f7',
            borderWidth: 0
        },
        label: {
          formatter({name}) {
            return '';
          }
        }
    }
});

Register the map data and instantiate the map using Echarts.

WeChat Image_20230922220814

However, there is an issue where the Echarts map does not completely overlap with the base map. The solution is to use layoutCenter and layoutSize to fine-tune it, defining the center of the map in the container using the layoutCenter property and defining the size of the map using layoutSize.

myChart.setOption({
  geo: {
    layoutCenter: ['49.5%', '51.5%'],
    layoutSize: 2200,
  }
});

WeChat Image_20230922222120

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.