Leaflet와 Vue3로 구현하는 지도 상호작용 그리기 도구
GIS 프로젝트에서 사용자가 지도 위에 도형을 그려 특정 영역을 표시하고 싶은 경우가 많다. 기존的大型 지도 라이브러리는 무겁고 복잡한 반면, Leaflet은 가볍고 사용하기 쉬운 대안이다. Vue3의 조합식 API와 결합하면 모던한 방식으로 지도 그리기 기능을 구현할 수 있다. 이 글에서는 Vue3 프로젝트에 Leaflet을 통합하고, 사용자가 지도에서 원, 선분, 다각형을 그릴 수 있는 인터랙티브 도구를 만드는 과정을 다룬다.
구현할 기능은 다음과 같다:
- 지도 위에 원 그리기 (중심점과 반지름 지정)
- 연속적인 선분 그리기 (폴리라인)
- 폐쇄된 영역 그리기 (폴리곤)
- 그리기 중인 도형의 실시간 미리보기
- 여러 도형의 연속 그리기
이 모든 기능을 Vue3의 조합식 API를 활용하여 모듈화하고, Leaflet의 이벤트 시스템과 우아하게 통합하는 방법을 살펴보자.
1. 프로젝트 구성 및 기본 지도 설정
먼저 Vue3 프로젝트를 구성하고 Leaflet을 설치한 후, 기본 지도를 화면에 렌더링하는 것부터 시작한다.
1.1 Vue3 프로젝트 생성 및 의존성 설치
Vite를 사용하여 Vue3 프로젝트를 빠르게 생성한다. 터미널에서 다음 명령을 실행한다:
npm create vue@latest map-drawing-tool
프로젝트 생성 시 TypeScript, Vue Router, Pinia 등은 필요에 따라 선택한다. 여기서는 핵심 기능에 집중하기 위해 최소 구성으로 진행한다. 프로젝트 폴더로 이동하여 Leaflet을 설치한다:
cd map-drawing-tool
npm install leaflet
npm install -D @types/leaflet
Leaflet은 자바스크립트 라이브러리이므로 CSS 파일도 함께 로드해야 한다. 이후 컴포넌트에서 이를 처리한다.
1.2 기본 지도 컴포넌트 구현
MapCanvas.vue라는 컴포넌트를 만들어 지도를 초기화한다. Vue3의 ref와生命周期 후크를 활용하여 DOM 요소와 Leaflet 인스턴스를 관리한다.
<template>
<div ref="mapElement" class="map-canvas"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
const mapElement = ref<HTMLElement | null>(null);
let mapObj: L.Map | null = null;
const setupMap = () => {
if (!mapElement.value) return;
mapObj = L.map(mapElement.value).setView([37.5665, 126.978], 10); // 서울 중심
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(mapObj);
console.log('지도 초기화 완료');
};
onMounted(() => {
setupMap();
});
onUnmounted(() => {
if (mapObj) {
mapObj.remove();
mapObj = null;
}
});
</script>
<style scoped>
.map-canvas {
width: 100%;
height: 600px;
border: 1px solid #ddd;
border-radius: 8px;
}
</style>
컴포넌트가 언마운트될 때 지도 인스턴스를 정리하는 것은 중요한다. Leaflet은 DOM에 많은 이벤트 리스너를 등록하므로, 정리하지 않으면 SPA에서 페이지 전환 시 메모리 누수가 발생할 수 있다.
이제 부모 컴포넌트에서 MapCanvas를 사용하면 서울을 중심으로 하는 기본 지도가 화면에 표시된다. 이것이 모든 그리기 작업의 기반이 된다.
2. 그리기 로직: 이벤트 처리 및 상태 관리
그리기 기능의 핵심은 사용자의 마우스 이벤트를 포착하고, 이를 바탕으로 도형的状态를 동적으로 업데이트하는 것이다. 조합식 함수(Composable)를 만들어 그리기 로직을 분리하면, UI와 로직을 분리하고 재사용성을 높일 수 있다.
2.1 원 그리기: 중심점과 반지름의 동적 결정
원을 그릴 때는 중심점과 반지름이 필요하다. 작동 방식은 다음과 같다: 마우스다운 시 중심점을 설정하고, 마우스무브 시 반지름을 실시간으로 계산하면서 원의 미리보기를 표시한다. 마우스업 시 최종 반지름으로 원을 완성한다.
src/composables 폴더에 useMapDrawer.ts 파일을 생성한다.
// src/composables/useMapDrawer.ts
import { ref, type Ref } from 'vue';
import type L from 'leaflet';
// 그리기 모드 열거형
export enum SketchMode {
IDLE = 'idle',
CIRCLE = 'circle',
LINE = 'line',
AREA = 'area',
}
// 그리기 도구 상태 인터페이스
interface DrawStatus {
activeMode: Ref<SketchMode>;
drawing: Ref<boolean>;
previewLayer: Ref<L.Layer | null>;
completedLayers: Ref<L.Layer[]>;
}
export function useMapDrawer(mapRef: Ref<L.Map | null>) {
const currentMode = ref<SketchMode>(SketchMode.IDLE);
const inProgress = ref(false);
const previewLayer = ref<L.Layer | null>(null);
const finishedLayers = ref<L.Layer[]>([]);
let circleCenterLatLng: L.LatLng | null = null;
let linePoints: L.LatLng[] = [];
// 그리기 시작 핸들러
const startDrawing = (event: L.LeafletMouseEvent) => {
if (!mapRef.value || currentMode.value === SketchMode.IDLE) return;
inProgress.value = true;
const latlng = event.latlng;
if (currentMode.value === SketchMode.CIRCLE) {
circleCenterLatLng = latlng;
// 임시 원 레이어 생성 (반지름 0으로 시작)
const tempCircle = L.circle(latlng, { radius: 0 });
tempCircle.addTo(mapRef.value);
previewLayer.value = tempCircle;
}
};
// 그리기 중 이동 핸들러
const moveWhileDrawing = (event: L.LeafletMouseEvent) => {
if (!inProgress.value || !mapRef.value) return;
const latlng = event.latlng;
if (currentMode.value === SketchMode.CIRCLE && circleCenterLatLng && previewLayer.value) {
const radius = mapRef.value.distance(circleCenterLatLng, latlng);
(previewLayer.value as L.Circle).setRadius(radius);
}
};
// 그리기 완료 핸들러
const finishDrawing = () => {
if (!inProgress.value) return;
inProgress.value = false;
if (previewLayer.value && mapRef.value) {
finishedLayers.value.push(previewLayer.value);
previewLayer.value = null;
}
circleCenterLatLng = null;
linePoints = [];
};
// 모드 설정
const setMode = (mode: SketchMode) => {
currentMode.value = mode;
inProgress.value = false;
if (previewLayer.value && mapRef.value) {
mapRef.value.removeLayer(previewLayer.value);
previewLayer.value = null;
}
};
return {
currentMode,
inProgress,
previewLayer,
finishedLayers,
startDrawing,
moveWhileDrawing,
finishDrawing,
setMode,
};
}
이 조합식 함수는 그리기 상태를 추적하고, 마우스 이벤트에 따라 도형을 동적으로 업데이트한다. mapRef를 인자로 받아 Vue3의 ref와 Leaflet 인스턴스를 연결한다.
2.2 폴리라인 및 폴리곤 그리기
폴리라인(연속 선분)과 폴리гон(다각형)은 여러 개의 점을 순차적으로 수집해야 한다. 폴리라인은 마우스 더블클릭이나 우클릭으로 완성하고, 폴리곤은 시작점으로 돌아오면 자동으로 폐쇄 영역이 된다. 다만,单击과 더블클릭 이벤트 충돌 문제가 있어 이를 적절히 처리해야 한다.
폴리라인의 구현은 다음과 같다:
// 폴리라인 그리기 로직 추가
const handlePolylineDrawing = (event: L.LeafletMouseEvent) => {
if (currentMode.value !== SketchMode.LINE || !inProgress.value) return;
const latlng = event.latlng;
linePoints.push(latlng);
if (previewLayer.value) {
// 기존 선분에 새 점 추가
(previewLayer.value as L.Polyline).setLatLngs(linePoints);
} else {
// 첫 번째 점이면 새 선분 생성
const newLine = L.polyline(linePoints, { color: '#3388ff', weight: 3 });
newLine.addTo(mapRef.value!);
previewLayer.value = newLine;
}
};
폴리건의 경우, 시작점에 다시 도달하면 자동으로 닫힌 도형으로 인식하도록 처리한다:
// 폴리건 완성 조건 체크
const checkPolygonComplete = (newPoint: L.LatLng): boolean => {
if (linePoints.length < 3) return false;
const startPoint = linePoints[0];
const distance = mapRef.value?.distance(startPoint, newPoint) ?? 0;
return distance < 30; // 30미터 이내면 완성으로 간주
};