본문으로 바로가기

728x90

vote21.map.zip
0.30MB

본 글은 21대 국회의원선거일 전에 의석수 계산기와 함께 쓰려고 하였으나 상세기능 구현 및 데이터 갱신작업 등으로 인하여 글로 쓰는 일이 늦어졌습니다. 구현된 의석수 계산기는 여기에서 확인이 가능하며 본 예제는 여기에서 확인이 가능합니다. 관련 소스코드는 본 글의 첨부파일로 등록하였습니다.

 

21대 국회의원선거 의석수계산기

이번 글에서는 Vue.js에서 D3.js를 사용하여 지도를 그리는 방법에 대해서 설명합니다.

 

아래의 이미지는 21대 국회의원 개표 결과를 지도를 통해 표시한 그림입니다. 각각의 지역구 및 당선된 정당을 색으로 표시하였습니다.

21대 국회의원선거 지역구 현황 지도

 

프로젝트 설정하기

d3 라이브러리 설치

아래의 명령을 수행하여 d3라이브러리를 vue.js 프로젝트에 추가합니다. 다른 추가적인 설정은 필요하지 않습니다.

npm install --save d3

 

GeoJSON 지도 정보 얻기

지도를 그리기 위해서는 GeoJSON 기반 데이터 파일을 먼저 가지고 있어야 합니다. GeoJSON은 지도정보를 그리기 위한 정보 파일로 아래와 같이 위키백과에서는 설명하고 있습니다.

 

GeoJSON(지오 제이슨)은 위치정보를 갖는 점을 기반으로 체계적으로 지형을 표현하기 위해 설계된 개방형 공개 표준 형식이다. 이것은 JSON인 자바스크립트 오브젝트 노테이션(Object Notation)을 사용하는 파일 포맷이다.

지리 좌표계의 점을 기반으로 Geocoding 된 지형지물(주소 및 위치), 라인 스트링(LineString - 거리, 고속도로 및 경계 등 정보를 담고 있는 문자열) 또는 폴리 라인, 다각형 (국가, 도시, 토지) 및 이러한 유형의 여러 부분으로 구성된 모음을 특징으로 한다. GeoJSON 기능은 물리적 세계의 엔티티만을 나타낼 필요는 없다. 예를 들어, 모바일 라우팅 및 내비게이션 애플리케이션은 GeoJSON을 사용하여 서비스 범위를 확장 기술할 수 있다. 또한 GPX가 특정 목적을 위한 경로 정보 공유 도구로 활용되는 것처럼 즉, 산악 등반이나 마운틴 바이크를 위한 루트 및 길안내 자료 등으로 사용할 수 있다.

GeoJSON 형식은 공식 표준 조직이 아니라 국제 인터넷 표준화 기구 산하 워킹그룹에 의해 작성되고 유지된다는 점에서 다른 GIS 표준과 다르지만[7]XML을 기반으로 한GPX와 함께 사실상 표준처럼 사용된다. 특징으로는 일반적인 구글맵이나 오픈 스트리트 맵 서비스가 [위도, 경도]의 표기법을 사용하나 GeoJSON은 WGS 84가 권장하는 [경도, 위도]의 표기법을 지원한다는 점이다. GeoJSON은 지형 공간 토폴로지를 인코딩하며 일반적으로 파일 크기가 더 작다.
출처 : 위키백과(https://ko.wikipedia.org/wiki/GeoJSON)

 

지도 데이터 및 지역구 정보는 아래의 링크들에서 다운로드하여 직접 사용하거나 가공하여 사용하였습니다. csv를 Node의 라이브러리를 추가하여 읽는 방법도 있겠으나 여기에서는 JSON으로 변경하여 사용하였습니다.

 

  1. 21대 국회의원 선거 지역구 지도 GeoJSON 정보 파일
    파일은 map.geo.json 파일로 저장하였습니다.
    출처 : https://github.com/OhmyNews/2020_21_elec_map/blob/master/2020_21_elec_253_simple.json
  2. 지역구 코드 및 지역구 이름
    지역구 코드는 GeoJSON에 포함되어 있어 별도의 파일로 저장하지 않았습니다.
    출처 : https://github.com/OhmyNews/2020_21_elec_map/blob/master/21_SGG.csv
  3. 21대 국회의원 선거 지역구 지역 정보
    내용을 map.area.json 파일로 저장하였습니다.
    출처 : http://www.ohmynews.com/NWS_Web/View/at_pg_w.aspx?CNTN_CD=A0002623791
  4. 21대 국회의원 당선자 정보
    아래의 출처에서 각 지역구 당선자 정보를 localseat.final.js 파일로 저장하였습니다.

    출처 : https://namu.wiki/w/%EC%A0%9C21%EB%8C%80%20%EA%B5%AD%ED%9A%8C%EC%9D%98%EC%9B%90%20%EC%84%A0%EA%B1%B0/

map.geo.json 파일 내용은 다음과 같은 형식입니다.

{"type":"FeatureCollection", "features": [
    {"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.66021917004392,35.876840670780126],......,[128.66021917004392,35.876840670780126]]]},"properties":{"SGG_Code":2270202,"SGG_1":"대구","SGG_2":"대구광역시 동구을","SGG_3":"대구 동구을"}},
    {"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[128.59071199576454,35.809768933700944],......,[128.59071199576454,35.809768933700944]]]},"properties":{"SGG_Code":2270101,"SGG_1":"대구","SGG_2":"대구광역시 중구남구","SGG_3":"대구 중구남구"}},
......이하생략......

map.area.json 파일 내용은 다음과 같은 내용입니다. 각 지역구의 해당 지역정보를 포함합니다.

{
    "2270202":["\u003cb>대구 동구을\u003c/b>","\u003cb>동구\u003c/b>\u003cbr>도평동, 불로·봉무동, 방촌동, 해안동, 안심1동, 안심2동, 안심3·4동, 공산동"],
    "2270101":["\u003cb>대구 중구남구\u003c/b>","\u003cb>남구\u003c/b>\u003cbr>이천동, 봉덕1동, 봉덕2동, 봉덕3동, 대명1동, 대명2동, 대명3동, 대명4동, 대명5동, 대명6동, 대명9동, 대명10동, 대명11동\u003cbr>\u003cb>중구\u003c/b>\u003cbr>동인동, 삼덕동, 성내1동, 성내2동, 성내3동, 대신동, 남산1동, 남산2동, 남산3동, 남산4동, 대봉1동, 대봉2동"],
......이하생략......

당선자 정보 파일내용은 아래와 같은 형식으로 편집하였습니다. elected 가 true인 후보가 최종 당선자입니다. 모든 후보자의 정보를 포함하려 하였으나 정보도 시간도 없는 관계로 출처에서 언급된 주요 후보의 득표율과 당선 여부만 입력하였습니다.

export default {
	// 서울 종로
	"2110101" : [
		{"no" : 1, "elected":true, "name" : "이낙연(李洛淵)", "party" : "theminjoo", "partyName" : "더불어민주당", "votes" : 0, "ratio" : 58.4},
		{"no" : 2, "name" : "황교안(黃敎安)", "party" : "unitedfuture", "partyName" : "미래통합당", "votes" : 0, "ratio" : 40.0},
		{"no" : 7, "name" : "한민호(韓民鎬)", "party" : "ourrepublican", "partyName" : "우리공화당", "votes" : 0, "ratio" : 0},
		{"no" : 8, "name" : "오인환(吳璘煥)", "party" : "minjung", "partyName" : "민중당", "votes" : 0, "ratio" : 0},
		{"no" : 9, "name" : "이정희(李貞嬉)", "party" : "etcparty", "partyName" : "가자!평화인권당", "votes" : 0, "ratio" : 0},
		{"no" : 10, "name" : "신동욱(申東旭)", "party" : "etcparty", "partyName" : "공화당", "votes" : 0, "ratio" : 0},
		{"no" : 11, "name" : "박준영(朴俊榮)", "party" : "etcparty", "partyName" : "국가혁명배당금당", "votes" : 0, "ratio" : 0},
		{"no" : 12, "name" : "백병찬(白秉贊)", "party" : "etcparty", "partyName" : "국민새정당", "votes" : 0, "ratio" : 0},
		{"no" : 13, "name" : "양세화(梁世和)", "party" : "etcparty", "partyName" : "기독자유통일당", "votes" : 0, "ratio" : 0},
		{"no" : 14, "name" : "박소현(朴炤炫)", "party" : "etcparty", "partyName" : "민중민주당", "votes" : 0, "ratio" : 0},
		{"no" : 15, "name" : "김형석(金亨錫)", "party" : "etcparty", "partyName" : "한나라당", "votes" : 0, "ratio" : 0},
		{"no" : 16, "name" : "김용덕(金龍德)", "party" : "noparty", "partyName" : "무소속", "votes" : 0, "ratio" : 0},
	],
......이하생략......

 

지도 그리기

<template> 노드와 <style> 노드를 다음과 같이 선언합니다.

아래의 코드에서 map-wrapper div는 다른 추가적인 정보를 출력하기 위해서 position을 relative로 선언하였습니다.

<template>
  <div id="map-wrapper" class="map-wrapper">
  </div>
</template>


<style lang="scss">
.map-wrapper {
  position:relative;
  text-align: center;

  .background {
    /* fill: #021019; */
    fill: transparent;
    pointer-events: all;
  }
  
  .map-layer {
    fill: #08304b;
    stroke: #021019;
    stroke-width: 1px;
  }
}
</style>

<script> 블록에 d3를 다음과 같이 선언합니다.

import * as d3 from 'd3'

지도 정보와 지역구 구역 정보를 읽어옵니다.

const MAP_GEOJSON = require('./map.geo.json');

Vue.js에서 mounted 이벤트 발생 시에 지도를 그리는 함수를 호출하도록 처리합니다.

export default {
  components: {
  },
  props: {
  },
  data() {
    return {
      province: undefined,
      currentProvince: undefined,
      mapArea: MAP_AREA,

      localSeatInfo : null,
    }
  },
  computed: {
  },
  created() {
  },
  mounted() {
    this.drawMap();
  },
  methods: {
    drawMap() {
    }
  }
  

drawMap 함수에서 지도에 필요한 변수들을 선언하고 지도를 그립니다.

    drawMap() {
      // 지도정보
      const geojson = MAP_GEOJSON;
      // 지도의 중심점 찾기
      const center = d3.geoCentroid(geojson);

      let centered = undefined;

      // 현재의 브라우저의 크기 계산
      const divWidth = document.getElementById("map-wrapper").clientWidth;
      const width = (divWidth < 1000) ? divWidth * 0.9 : 1000;
      const height = width * 1;

      // 지도를 그리기 위한 svg 생성
      const svg = d3
      // .select('.d3')
        .select('.map-wrapper')
        .append('svg')
        .attr('width', width)
        .attr('height', height);

      // 배경 그리기
      const background = svg.append('rect')
        .attr('class', 'background')
        .attr('width', width)
        .attr('height', height)

      // 지도가 그려지는 그래픽 노드(g) 생성
      const g = svg.append('g');
      // const effectLayer = g.append('g').classed('effect-layer', true);
      // 지도가 그려지는 그래픽 노드
      const mapLayer = g.append('g').classed('map-layer', true);
      // 아이콘이 그려지는 그래픽 노드
      const iconsLayer = g.append('g').classed('icons-layer', true);

      // 지도의 출력 방법을 선택(메로카토르)
      let projection = d3.geoMercator()
        .scale(1)
        .translate([0, 0]); 

      // svg 그림의 크기에 따라 출력될 지도의 크기를 다시 계산
      const path = d3.geoPath().projection(projection);
      const bounds = path.bounds(geojson);
      const widthScale = (bounds[1][0] - bounds[0][0]) / width; 
      const heightScale = (bounds[1][1] - bounds[0][1]) / height; 
      const scale = 0.95 / Math.max(widthScale, heightScale);
      const xoffset = width/2 - scale * (bounds[1][0] + bounds[0][0]) /2 + 0; 
      const yoffset = height/2 - scale * (bounds[1][1] + bounds[0][1])/2 + 0; 
      const offset = [xoffset, yoffset];
      projection.scale(scale).translate(offset);


      const color = d3.scaleLinear()
        .domain([1, 20])
        .clamp(true)
        .range(['#595959', '#595959']);

      // Get province name length
      function nameLength(d){
        const n = nameFn(d);
        return n ? n.length : 0;
      }

      // Get province name
      function nameFn(d){
        return d && d.properties ? d.properties.name : null;
      }
      
      // Get province color
      function fillFn(d){
        return color(nameLength(d));
      }

      // 지도 그리기
      mapLayer
        .selectAll('path')
        .data(geojson.features)
        .enter().append('path') 
        .attr('d', path)
        .attr('vector-effect', 'non-scaling-stroke')
        .style('fill', fillFn);
    }

아래는 위 코드로 그려진 지도 이미지입니다.

지도 출력 이미지

 

 

지도 배경 색상 설정하기

정당 목록 선언 및 당선자 정보를 다음과 같이 읽어와서 data()에 선언합니다. 정당의 색상은 각 정당의 대표 색상을 얻어왔으며 대표 색상이 명시되지 않은 경우 회색으로 처리하였습니다.

const LOCAL_SEAT_FINAL = require('./localseat.final').default;

export default {
  data() {
    return {
      partyList : {
        theminjoo : {name: "더불어민주당", no:1, localLock:false, ratioLock:true, color:"#004EA2"},
        unitedfuture : {name: "미래통합당", no:2, localLock:false, ratioLock:true, color:"#EF426F"},
        minsaengdang : {name: "민생당", no:3, localLock:false, ratioLock:false, color:"#0BA95F"},
        futurekorea : {name: "미래한국당", no:4, localLock:true, ratioLock:false, color:"#EF426F"},
        platformparty : {name: "더불어시민당", no:5, localLock:true, ratioLock:false, color:"#0088D2"},
        justice21 : {name: "정의당", no:6, localLock:false, ratioLock:false, color:"#FFCA05"},
        ourrepublican : {name: "우리공화당", no:7, localLock:false, ratioLock:false, color:"#009900"},
        minjung : {name: "민중당", no:8, localLock:false, ratioLock:false, color:"#F47920"},
        koreaeconomy : {name: "한국경제당", no:9, localLock:true, ratioLock:false, color:"#009900"},
        people21 : {name: "국민의당", no:10, localLock:true, ratioLock:false, color:"#EA5504"},
        proparknew : {name: "친박신당", no:11, localLock:false, ratioLock:false, color:"#BBBBBB"},
        openminjoo : {name: "열린민주당", no:12, localLock:true, ratioLock:false, color:"#004098"},
        etcparty : {name: "기타", localLock:false, ratioLock:false, color:"#BBBBBB"},
        noparty : {name: "무소속", localLock:false, ratioLock:true, color:"#AAAAAA"},
        undecided : {name: "무당층", localLock:true, ratioLock:false, color:"#CCCCCC"},
      },
      localSeatFinal : LOCAL_SEAT_FINAL,
    }
  },
  
......이하생략......

지역코드를 기반으로 당선자를 찾고 당선자의 정당 색상을 찾는 partyColor() 함수를 methods에 추가합니다.

    partyColor(code) {
      let color = null;
      const localSeatCode = "" + code;
      if(localSeatCode in this.localSeatFinal) {
        const localSeat = this.localSeatFinal[localSeatCode];
        let candidate = null;
        localSeat.some((item) => {
          if(item.elected === true) {
            candidate = item;
            return true;
          }
        });
        if(candidate) {
          const party = candidate.party;
          color = this.partyList[party].color;
        }
      }
      return color;
    },

drawMap 함수에서 색상을 칠하는 함수 fillFn을 다음과 같이 수정합니다.

아래 코드에서 vue인스턴스인 this를 _this 변수로 지정해야 d3내에서 참조가 가능합니다. d3내에서 this는 vue인스턴스가 아닌 d3인스턴스를 가리킵니다.

......상단생략......
      const _this = this;

      function fillFn(d){
        // console.log(d, nameLength(d));
        // console.log(d.properties);

        const pcolor = _this.partyColor(d.properties.SGG_Code);
        if(pcolor) {
          return pcolor;
        }

        return color(nameLength(d));
      }
......이하생략......

다음과 같이 당선자의 지역구의 색상이 표시됩니다.

지역구 정당 색상지정 이미지

 

마우스 이벤트 처리하기

지도에 마우스가 올라가거나 클릭하는 경우 지역 정보 및 당선자 정보를 출력하기 위한 방법을 설명하겠습니다.

지역구 및 당선자, 정당별 당선자 현황을 표시하기 위해 div를 추가합니다.

<template>
  <div id="map-wrapper" class="map-wrapper">
    <div v-if="province" class="province-title text-left">
      <h3 class="text-center">
        {{province.SGG_2}}
        <br />
        <h4>
          <template v-if="province.candidate">
            <strong>
            {{ partyList[province.candidate.party].name }}, {{ province.candidate.name }}
            </strong>
          </template>
        </h4>
      </h3>
      <div v-if="!currentProvince">
        <span v-html="findArea(province.SGG_Code)"></span>
      </div>
    </div>

    <div v-if="currentProvince" class="province-info">
      <h3 class="text-center">
        {{currentProvince.SGG_2}}
        <br />
        <h4>
          <template v-if="currentProvince.candidate">
            <strong>
            {{ partyList[currentProvince.candidate.party].name }}, {{ currentProvince.candidate.name }}
            </strong>
          </template>
        </h4>
      </h3>

      <ul class="text-left">
        <li>
          <span v-html="findArea(currentProvince.SGG_Code)"></span>
        </li>
        <!--
        <li>SGG_Code: {{currentProvince.SGG_Code}}</li>
        <li>SGG_1: {{currentProvince.SGG_1}}</li>
        <li>SGG_2: {{currentProvince.SGG_2}}</li>
        <li>SGG_2: {{currentProvince.SGG_3}}</li>
        -->
        <template v-if="currentProvince.final">
        <li> 
          <b-row>
            <b-col class="col-12 text-white">
              개표결과
            </b-col>
            <b-col class="col-12 text-white">
              <vote21-ratio-bar
                :party-list="partyList"
                :data="currentProvince.final"
              ></vote21-ratio-bar>
            </b-col>
          </b-row>
        </li>
        </template>
      </ul>
    </div>

    <div class="province-summary text-left" v-if="!currentProvince">
      <h4 class="text-center">정당별 현황</h4>
      <ul>
        <template v-for="(value, key) in localSeatData">
          <li :key="key">{{ partyList[key].name }} : {{value | digit }} 석</li>
        </template>
      </ul>
    </div>

  </div>
</template>


<style lang="scss">
.map-wrapper {
  position:relative;
  text-align: center;

  .province-title {
    position: absolute;
    top: 10px;
    left: 40px;
    width: 600px;
    color: white;
    z-index: 100;
    pointer-events: none;
    font-size: 0.9rem;
  }
  .province-info {
    // background: white;
    position: absolute;
    color: white;
    top: 50px;
    right: 60px;
    width: 600px;
    height: 500px;
    z-index: 101;
    font-size: 0.9rem;
    pointer-events: none;
  }
  .province-summary {
    // background: white;
    position: absolute;
    color: white;
    bottom: 10px;
    right: 10px;
    width: 300px;
    z-index: 103;
    pointer-events: none;
  }
  .province-summary > ul {
    list-style: none;
  }
  .background {
    /* fill: #021019; */
    fill: transparent;
    pointer-events: all;
  }
  .map-layer {
    fill: #08304b;
    stroke: #021019;
    stroke-width: 1px;
  }
}
</style>

지역구 정보를 읽어오고 vue의 data에 추가합니다.

const MAP_GEOJSON = require('./map.geo.json');
const MAP_AREA = require('./map.area.json');		// 지역구 지역정보
const LOCAL_SEAT_FINAL = require('./localseat.final').default;

......중간생략......
  data() {
    return {
      province: undefined,            // 마우스가 지역구 위에 있을 때 정보
      currentProvince: undefined,     // 지역구를 클릭했을 때 정보
      mapArea: MAP_AREA,              // 지역구 지역정보

      localSeatData : {},             // 정당별 당선자수 요약
      
......중간생략......

vue의 created 이벤트 시에 정당별 당선자수를 계산합니다.

......상단생략......
  created() {
    this.summarize();
  },
  mounted() {
    this.drawMap();
  },

  methods: {
    // 정당별 당선자수 요약
    summarize() {
      this.localSeatData = {};
      Object.keys(this.localSeatFinal).forEach(localSeatCode => {
        let candidate = null;
        const localSeat = this.localSeatFinal[localSeatCode];
        localSeat.some((item) => {
          if(item.elected === true) {
            candidate = item;
            return true;
          }
        });
        if(candidate) {
          if(candidate.party in this.localSeatData) {
            this.localSeatData[candidate.party]++;
          } else {
            this.localSeatData[candidate.party] = 1;
          }
        }
      });
      // console.log(this.localSeatData);
    },
......이하생략......

마우스 오버 및 클릭했을 때 정보 출력을 위한 지역구 정보를 설정하는 함수를 생성합니다. 추가적으로 지역구 코드에 해당하는 지역구 정보를 반환하는 함수와 지역구 당선자 정보를 찾는 함수도 추가합니다.

......상단생략......
  methods: {
  
    ......중간생략......
    
    // 지역구의 지역 정보 찾기
    findArea(code) {
      // console.log(code);
      if(code in this.mapArea) {
        return Array.isArray(this.mapArea[code]) && this.mapArea[code].length >= 2 ? this.mapArea[code][1] : "";
      }
      return "";
    },
    // 지역구의 당선자 찾기
    findCandidate(code) {
      let candidate = null;
      const localSeat = this.localSeatFinal[code];
      localSeat.some((item) => {
        if(item.elected === true) {
          candidate = item;
          return true;
        }
      });

      return candidate;
    },
    // 선택된 지역구
    selectProvince(province) {
      if(province) {
        // console.log(province);
        // console.log(this.findCandidate(province.SGG_Code));
        province.candidate = this.findCandidate(province.SGG_Code);
      }
      this.province = province;
    },

    // 지역구 정보 열기
    openInfo(province) {
      // console.log(province);
      if(province) {
        province.candidate = this.findCandidate(province.SGG_Code);
        province.final = this.localSeatFinal[province.SGG_Code];
      }
      this.currentProvince = province;
    },
    // 지역구 정보 닫기
    closeInfo() {
      this.currentProvince = undefined;
    },

......이하생략......

d3에 마우스 이벤트를 등록하고 각각의 이벤트 처리 핸들러를 아래와 같이 구현합니다.

......상단생략......

      function clicked(d) {
        var x, y, k;

        // Compute centroid of the selected path
        if (d && centered !== d) {
          var centroid = path.centroid(d);
          x = centroid[0];
          y = centroid[1];
          k = 4;
          centered = d;
          _this.openInfo(d.properties);
        } else {
          x = width / 2;
          y = height / 2;
          k = 1;
          centered = null;
          _this.closeInfo();
        }

        // Highlight the clicked province
        mapLayer.selectAll('path')
          .style('fill', function(d){
            return centered && d===centered ? '#D5708B' : fillFn(d);
        });

        // Zoom
        g.transition()
          .duration(750)
          .attr('transform', 'translate(' + width / 2 + ',' + height / 2 + ')scale(' + k + ')translate(' + -x + ',' + -y + ')');
      }

      function mouseover(d){
        // Highlight hovered province
        d3.select(this).style('fill', '#1483ce');
        // d3.select(this).style('fill', '#004EA2');
        if(d) {
          // console.log(d.properties);
          _this.selectProvince(d.properties);
        }
      }

      function mouseout(d){
        _this.selectProvince(undefined);
        // Reset province color
        mapLayer.selectAll('path')
          .style('fill', (d) => {
            return centered && d===centered ? '#D5708B' : fillFn(d);
          });
      }

......중간생략......

      // Add background
      background
        .on('click', clicked);

      // 지도 그리기
      mapLayer
        .selectAll('path')
        .data(geojson.features)
        .enter().append('path') 
        .attr('d', path)
        .attr('vector-effect', 'non-scaling-stroke')
        .style('fill', fillFn)
        .on('mouseover', mouseover)
        .on('mouseout', mouseout)
        .on('click', clicked);

......이하 생략......

지도의 확대 축소를 위해 줌 이벤트 핸들러를 drawMap() 함수의 맨 아래와 같이 추가합니다.

......상단생략......
      // 확대/축소 이벤트 처리
      const zoomed = () =>{
        mapLayer.attr('transform', d3.event.transform)
        iconsLayer.attr('transform', d3.event.transform)
      }
      const zoom = d3.zoom().scaleExtent([1, 8]).on('zoom', zoomed)
      svg.call(zoom)

......하단생략......

지금까지 작업한 지도 정보는 다음과 같이 출력됩니다.

지역구 정보 출력 이미지

지도에 아이콘 추가하기

지도에 아이콘을 추가하는 방법은 다음과 같습니다. 아이콘을 표시할 위도와 경도를 계산하여 svg아이콘을 그려 넣습니다.

......상단생략......
      // 아이콘이 그려지는 그래픽 노드
      const iconsLayer = g.append('g').classed('icons-layer', true);
      
......중간생략......

      const iconsInfo = [
        {
          "name":"서울",
          "lat" : "37.532600",
          "lon" : "126.904612"
        },
        {
          "name":"대전",
          "lat" : "36.3730178",
          "lon" : "127.2483736"
        }
      ];

      // 아이콘 그리기
      iconsLayer
        .selectAll('svg')
        .data(iconsInfo)
        .enter()  
        .append("svg:image")
        .attr("width", 30)
        .attr("height", 30)
        .attr('x',  d=> projection([d.lon, d.lat])[0])
        .attr('y',  d=> projection([d.lon, d.lat])[1])
        .attr('opacity', 0)
        .attr("xlink:href", require("./medal.svg")) 
        .on('click', d => {
          console.log(d)
        })
        .transition()
        .ease(d3.easeElastic)
        .duration(2000)
        .delay((d, i)=> i * 50)
        .attr('opacity', 1)
        .attr('y',  d=> projection([d.lon, d.lat])[1])
......하단생략......

아이콘이 지도에 추가된 이미지는 다음과 같습니다.

지도에 아이콘 표시하기

 

기타

후보자 지지율 막대 그리기

bootstrap vue의 progress를 사용하여 다음과 같이 간단하게 구현하였습니다.

<template>
  <b-progress class="mt-2 border-white" max="100" show-value v-if="data && data.length >= 0">
    <template v-for="(item, index) in data">
      <b-progress-bar
        :key="index + '-' + item.name"
        :value="item.ratio"
        :precision="2"
        :style="barStyle(item.party)"
      >
        {{item.name}}({{item.ratio}}%)
      </b-progress-bar>
    </template>
  </b-progress>
</template>
<script>

export default {
  props: {
    'partyList': {
      type: Object
    },
    'data': {
      type : [Array],
      default : []
    },
  },
  data() {
    return {
    }
  },
  mounted () {
  },
  methods: {
    barStyle(partyCode) {
      const ret = {};
      ret.backgroundColor = this.partyList[partyCode] ? this.partyList[partyCode].color : this.partyList["noparty"].color;
      ret.color ='#ffffff';
      return ret;
    }
  },
}
</script>
<style scoped>
.border-white {
  border: 1px solid #ffffff;
}
</style>

사용은 다음과 같이 사용할 수 있습니다.

<vote21-ratio-bar
  :party-list="partyList"
  :data="currentProvince.final"
></vote21-ratio-bar>


<script>
import * as d3 from 'd3'

const Vote21RatioBar = require('./Vote21.RatioBar').default;
export default {
  components: {
    "vote21-ratio-bar" : Vote21RatioBar,
  },
  props: {
  },
......이하생략......

 

참고 자료

 

 

728x90