일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- repaint
- pm2 버전 충돌
- vue composable 함수
- 인터넷 거버넌스
- d3 지도 타입스크립트
- $fetch
- 참조형 props의 default
- git
- pm2 업데이트 에러
- Learning React
- reflow
- d3 지도 확대/축소
- vue 컴포저블 함수
- d3 지도
- 화살표 함수 {}
- in-memory pm2 is out-of-date
- ToDo
- ecmascript modules(esm)
- vuedraggable
- d3 지도 툴팁
- 화살표 함수 중괄호
- vue draggable 차트 안나옴
- commonjs와 ecmascript modules(esm)
- nuxt universal rendering
- component is already mounted please use $fetch instead.
- firebase id 자동
- 참조형 default
- vue3 drag and drop
- cloud firestore id auto increment
- 함수형 프로그래밍
- Today
- Total
빵 입니다.
D3.js 지도 그리기(w/ 확대ㆍ축소 기능과 툴팁) 본문
📌 서론
지도를 그리려고 합니다!
근데 이제 타입스크립트를 곁들인....
기존엔 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()
}
📌 결과물
'프론트엔드 > Vue' 카테고리의 다른 글
Drag and Drop 해보자(w/ 라이브러리) (0) | 2025.03.24 |
---|---|
Composable 함수 (0) | 2025.03.21 |
참조형 props의 default 값 설정하기 (0) | 2025.01.16 |
Chart.js 툴팁 레이블 색상 변경, 레전드 레이블 색상 변경 (0) | 2024.07.31 |
Chart.js 축 font-size 조절 (0) | 2024.07.30 |