관리 메뉴

빵 입니다.

D3.js 지도 그리기(w/ 확대ㆍ축소 기능과 툴팁) 본문

프론트엔드/Vue

D3.js 지도 그리기(w/ 확대ㆍ축소 기능과 툴팁)

bread-gee 2025. 3. 11. 14:39
반응형

📌 서론

지도를 그리려고 합니다!

근데 이제 타입스크립트를 곁들인....

 

기존엔 Chart.js만 사용했고, Chart.js에서 제공하는 기본 차트만을 사용했습니다.

여러 지도 라이브러리가 있었는데, 무료이면서 메르카토르 도법으로 작업이 가능한 것을 찾았습니다.

(+커스텀을 위해 자료들이 많은 것도 한 몫함.)

그래서 고른 것이 D3.js !

더보기
더보기

D3.js는 오픈 소스 라이브러리이기 때문에, 개인 프로젝트, 상업적 프로젝트, 웹 애플리케이션 등에 제한 없이 사용할 수 있습니다.
D3.js 자체는 유료 버전이 없지만, D3.js를 기반으로 한 상용 라이브러리나 플러그인들은 유료로 제공될 수 있습니다.

👉🏻 직접 다 그리면 공짜^^~🥰

 

 

📌 사용 패키지

우선, 제가 사용한 패키지는 2가지입니다.
"d3": "^7.9.0"
"topojson-client": "^3.1.0"

D3만 설치하면 지도를 그릴 수 있을 줄 알았으나 경기도 오산이었습니다.
D3 자체엔 지도에 대한 정보가 없기 때문입니다!
D3는 지리적(지도) 데이터를 시각화할 수 있도록 돕는 역할을 합니다.

그렇다면 지리적 데이터는 어디서 가져올까요?

지리적 데이터는 World Atlas 저장소에서 제공하는 TopoJSON 파일을 가져옵니다.

지도 데이터를 데이터 패칭으로 가져올거라면,
위에 설치한 topojson-client 라이브러리는 무엇이고, 왜 사용할까요?

World Atlas는 TopoJSON 형식으로 된 다양한 지도 데이터를 제공하는 저장소입니다.
지도를 시각화하기 위해 D3를 사용하는데, D3는 GeoJSON을 사용합니다.
제공받은 지도 데이터(TopoJSON) ≠ D3가 사용하는 지도 데이터(GeoJSON)

맞습니다!
topojson-client는 제공받은 지도 데이터를 D3가 사용할 수 있는 데이터로 변환하기 위해 사용합니다.

이런 번거로운 과정없이 그냥 GeoJSON을 사용하면 안되나? 하는 의문이 들수도 있는데요.
TopoJSON은 GeoJSON에 비해 데이터 압축 효과가 뛰어나서 웹 기반 지도에서 로드 시간을 줄이고, 성능을 향상시키는 데 유리하기 때문에 선택했습니다.
+ 추후에 여러 개의 지역을 병합하는 커스텀을 할 수도 있을 것 같아서 함께 제공하는 라이브러리를 선택했습니다.

 

 

📌 작업 내용

작업 내용을 개략적으로 설명하자면...

1. d3로 지도 영역을 그린다.

2. JSON 기반의 지리 데이터(TopoJSON)를 d3에 입힌다(?!)

3. d3로 지도를 초기화(메르카토르 도법 기반)한다.

4. 기능(툴팁, 확대/축소)을 추가한다.

 

🧿 차트 생성

const createChart = () => {
  // 1. d3로 지도 영역을 그린다.
  const svg = d3
    .create('svg')
    .attr('id', 'svgRef')
    .attr('width', d3Width.value) //// Dom에 만들어 놓은 지도 영역의 크기를 svg에 할당
    .attr('height', d3Height.value)
    .attr('viewBox', [0, 0, d3Width.value, d3Height.value])

  //// 지도 내용을 그룹핑하고 상하좌우 여백을 주었습니다.
  const g = svg
    .append('g')
    .attr('id', 'groupWrap')
    .attr('width', d3Width.value)
    .attr('height', d3Height.value)
    .attr('transform', `translate(${padding.value}, ${padding.value})`)

  // 2. JSON 기반의 지리 데이터(TopoJSON)를 d3에 입힌다.
  d3.json('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json').then((data) => {
    const topology = data as Topology

    //// TopoJSON → GeoJSON 변환
    const countries = topojson.feature(
      topology,
      topology.objects.countries
    ) as GeoJSON.FeatureCollection

    //// 남극을 삭제한 지도 반환
    countries.features = countries.features.filter(
      (d: Feature) => d.properties?.name !== 'Antarctica'
    )

    // 3. d3로 지도를 초기화(메르카토르 도법 기반)한다.
    const projection = d3
      .geoMercator()
      .fitSize([d3Width.value - 20, d3Height.value - 20], countries) //// 상하좌우 여백 빼기
      .rotate([-10, 0])
      //// 알래스카 왼쪽에 러시아 일부가 같이 렌더링되는데, 지도상의 기준점을 회전시켜 러시아가 짤리지 않도록 설정
      //// 경도는 지도에서 동서 방향을 조정하고, 위도는 북남 방향을 조정한다.
    const pathGenerator = d3.geoPath().projection(projection)

    // 4. 기능 추가 > 툴팁 생성 (HTML div 요소)
    const tooltip = d3.select('body').append('div').attr('class', 'chart-tooltip-wrap')

    g.selectAll('path')
      .data(countries.features)
      .enter()
      .append('path')
      .attr('class', 'country')
      .attr('d', (d: Feature) => pathGenerator(d))
      .attr('stroke', '#fff')
      .attr('fill', (d: Feature, index: number) => {
        return randomColor(index)
      })

      //// 툴팁 바인딩
      .on('mouseover', (event: MouseEvent, d: Feature) => {
        tooltip
          .attr('class', 'chart-tooltip-wrap active')
          .html(
            `
            <div class="chart-tooltip-inner-wrap">
              <div class="chart-tooltip-title">${d.properties?.name}</div>
            </div>`
          )
          .append('div')
      })
      .on('mousemove', (event: MouseEvent) => {
        //// 마우스 이동 시 툴팁 위치 변경
        tooltip.style('top', event.pageY + 10 + 'px').style('left', event.pageX + 10 + 'px')
      })
      .on('mouseout', () => {
        tooltip.attr('class', 'chart-tooltip-wrap')
      })
  })

 

특이사항으로 저는 남극이 필요없어서 남극을 제외한 지도를 사용했습니다.

 

 

 

🧿 줌 기능 바인딩

툴팁은 국가 개체에 하나하나 바인딩해야 하기 때문에, path 자체에 바인딩했고,

줌 기능은 지도 전체에 해당되기 때문에 따로 바인딩합니다.

// 줌 > 데이터 init
let zoom: d3.ZoomBehavior<SVGSVGElement, unknown>
let svg: d3.Selection<SVGSVGElement, unknown, null, undefined>
let zoomGroup: d3.Selection<SVGGElement, unknown, null, undefined>
let chartScale = ref<number>(1)
const scalable = 1.2

// 줌 > 생성
const makeZoom = () => {
  svg = d3.select(d3Wrap.value).select('svg')
  zoomGroup = svg.select('g')

  zoom = d3
    .zoom<SVGSVGElement, unknown>()
    .on('zoom', (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
      if (event.sourceEvent?.type === 'wheel') {
        chartScale.value = event.transform.k
      }

      zoomGroup.attr('transform', event.transform.toString())
    })

  // map X, Y값 초기화(x: -10, y: -10) => 지도에 상하좌우 여백을 주었기 때문
  const initialTransform = d3.zoomIdentity.translate(padding.value, padding.value)
  svg.call(zoom.transform, initialTransform)
  svg.call(zoom)
}

// 줌 > 리셋
const resetZoom = () => {
  svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(0, padding.value))
}

// 줌 > 확대
const zoomIn = () => {
  chartScale.value *= scalable
  svg.transition().duration(500).call(zoom.scaleBy, scalable)
}

// 줌 > 축소
const zoomOut = () => {
  chartScale.value *= 1 / scalable

  svg
    .transition()
    .duration(500)
    .call(zoom.scaleBy, 1 / scalable)
}

 

마우스 휠을 이용해 확대/축소를 할 수 있고, 버튼 클릭을 이용해서 확대/축소를 할 수 있기 때문에 각 이벤트를 잘 연결해 주어야 합니다.

 

 

 

🧿 Vue3(HTML)

<template>
    <div class="map-wrap">
      <div class="map-scale-btns">
        <button @click="resetZoom">
          <FontAwesomeIcon
            icon="fa-solid fa-arrow-rotate-left"
            aria-label="화면 사이즈 초기화"
          />
        </button>
        <button @click="zoomIn">
          <FontAwesomeIcon
            icon="fa-solid fa-up-right-and-down-left-from-center"
            aria-label="화면 사이즈 확대"
          />
        </button>
        <button @click="zoomOut">
          <FontAwesomeIcon
            icon="fa-solid fa-down-left-and-up-right-to-center"
            aria-label="화면 사이즈 축소"
          />
        </button>
      </div>
      <!-- 지도가 렌더링되는 요소. CSS로 width, height 고정값 적용 -->
      <div class="map" ref="d3Wrap"></div>
    </div>
</template>

 

 

📌 전체 소스

import * as d3 from 'd3'
import * as topojson from 'topojson-client'
import type { Topology } from 'topojson-specification'
import type { Feature } from 'geojson'

// d3 캔버스 사이즈
const d3Wrap = ref<HTMLDivElement | null>(null)
const d3Width = ref(0)
const d3Height = ref(0)
const padding = ref(10) // 상하 여백

// 줌 > 데이터 init
let zoom: d3.ZoomBehavior<SVGSVGElement, unknown>
let svg: d3.Selection<SVGSVGElement, unknown, null, undefined>
let zoomGroup: d3.Selection<SVGGElement, unknown, null, undefined>
let chartScale = ref<number>(1)
const scalable = 1.2

// 줌 > 생성
const makeZoom = () => {
  svg = d3.select(d3Wrap.value).select('svg')
  zoomGroup = svg.select('g')

  zoom = d3
    .zoom<SVGSVGElement, unknown>()
    .on('zoom', (event: d3.D3ZoomEvent<SVGSVGElement, unknown>) => {
      if (event.sourceEvent?.type === 'wheel') {
        chartScale.value = event.transform.k
      }

      zoomGroup.attr('transform', event.transform.toString())
    })

  // map Y값 초기화(y: -10)
  const initialTransform = d3.zoomIdentity.translate(padding.value, padding.value)
  svg.call(zoom.transform, initialTransform)
  svg.call(zoom)
}

// 줌 > 리셋
const resetZoom = () => {
  svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(0, padding.value))
}

// 줌 > 확대
const zoomIn = () => {
  chartScale.value *= scalable
  svg.transition().duration(500).call(zoom.scaleBy, scalable)
}

// 줌 > 축소
const zoomOut = () => {
  chartScale.value *= 1 / scalable

  svg
    .transition()
    .duration(500)
    .call(zoom.scaleBy, 1 / scalable)
}

onMounted(() => {
  if (d3Wrap.value) {
    d3Width.value = Number(d3Wrap.value?.clientWidth)
    d3Height.value = Number(d3Wrap.value?.clientHeight)
  }
  // 지도 그리기
  nextTick(() => {
    const chart = createMap()
    if (chart) {
      d3Wrap.value?.appendChild(chart)

      makeZoom()
    }
  })
})

// 맵 생성
const createMap = () => {
  // 1. d3로 지도 영역을 그린다.
  const svg = d3
    .create('svg')
    .attr('id', 'svgRef')
    .attr('width', d3Width.value) //// Dom에 만들어 놓은 지도 영역의 크기를 svg에 할당
    .attr('height', d3Height.value)
    .attr('viewBox', [0, 0, d3Width.value, d3Height.value])

  //// 지도 내용을 그룹핑하고 상하좌우 여백을 주었습니다.
  const g = svg
    .append('g')
    .attr('id', 'groupWrap')
    .attr('width', d3Width.value)
    .attr('height', d3Height.value)
    .attr('transform', `translate(${padding.value}, ${padding.value})`)

  // 2. JSON 기반의 지리 데이터(TopoJSON)를 d3에 입힌다.
  d3.json('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json').then((data) => {
    const topology = data as Topology

    //// TopoJSON → GeoJSON 변환
    const countries = topojson.feature(
      topology,
      topology.objects.countries
    ) as GeoJSON.FeatureCollection

    //// 남극을 삭제한 지도 반환
    countries.features = countries.features.filter(
      (d: Feature) => d.properties?.name !== 'Antarctica'
    )

    // 3. d3로 지도를 초기화(메르카토르 도법 기반)한다.
    const projection = d3
      .geoMercator()
      .fitSize([d3Width.value - 20, d3Height.value - 20], countries) //// 상하좌우 여백 빼기
      .rotate([-10, 0])
      //// 알래스카 왼쪽에 러시아 일부가 같이 렌더링되는데, 지도상의 기준점을 회전시켜 러시아가 짤리지 않도록 설정
      //// 경도는 지도에서 동서 방향을 조정하고, 위도는 북남 방향을 조정한다.
    const pathGenerator = d3.geoPath().projection(projection)

    // 4. 기능 추가 > 툴팁 생성 (HTML div 요소)
    const tooltip = d3.select('body').append('div').attr('class', 'chart-tooltip-wrap')

    g.selectAll('path')
      .data(countries.features)
      .enter()
      .append('path')
      .attr('class', 'country')
      .attr('d', (d: Feature) => pathGenerator(d))
      .attr('stroke', '#fff')
      .attr('fill', (d: Feature, index: number) => {
        return randomColor(index)
      })

      //// 툴팁 바인딩
      .on('mouseover', (event: MouseEvent, d: Feature) => {
        tooltip
          .attr('class', 'chart-tooltip-wrap active')
          .html(
            `
            <div class="chart-tooltip-inner-wrap">
              <div class="chart-tooltip-title">${d.properties?.name}</div>
            </div>`
          )
          .append('div')
      })
      .on('mousemove', (event: MouseEvent) => {
        //// 마우스 이동 시 툴팁 위치 변경
        tooltip.style('top', event.pageY + 10 + 'px').style('left', event.pageX + 10 + 'px')
      })
      .on('mouseout', () => {
        tooltip.attr('class', 'chart-tooltip-wrap')
      })
  })

  return svg.node()
}

 

 

 

📌 결과물

D3 맵 구현 결과물

 

 

 

반응형

 

반응형