🗺️ Leaflet — 지도 시각화 종합

마커 · 클러스터 · 정량 원형 · 히트맵 · 코로플레스 · 폴리라인 · 코드 그대로 복사 → 데이터만 교체

⓪ 시작하기
① 기초 마커
② 마커 클러스터
③ 원형 마커 (정량)
④ 히트맵
⑤ 코로플레스
⑥ 폴리라인 (경로)
⑦ 타일 전환·레이어 컨트롤
⓪ Leaflet 4단계 워크플로우 — 모든 지도의 공통 패턴
Leaflet 지도는 항상 같은 4단계입니다. 마커든 히트맵이든 코로플레스든, 4단계 다음에 무엇을 추가하느냐만 달라집니다.
  1. ① CDN 로드 — Leaflet CSS + JS (HTML <head>에 한 번)
  2. ② 컨테이너 만들기 — <div id="map" style="height:440px"> ← 높이 필수!
  3. ③ 데이터 준비 — 위/경도 배열, 또는 fetch()로 GeoJSON·CSV 로드
  4. ④ 지도 생성 + 레이어 추가 — L.map() → L.tileLayer() → L.marker()/L.heatLayer()/...
🧰 Leaflet 핵심 객체 5가지
L.map(id) · 지도 컨테이너 생성. 처음 한 번.
L.tileLayer(url) · 베이스맵(OSM·CartoDB·위성). 지도 배경.
L.marker([lat, lng]) · 핀 마커 한 개.
L.circleMarker([lat, lng], { radius }) · 정량 표현용 원형 마커.
L.geoJSON(geo) · GeoJSON으로 폴리곤(행정구역) 그리기.
💡 높이 깜빡 잊지 마세요. Leaflet은 컨테이너 div의 height가 0이면 보이지 않습니다. CSS로 명시 필수.
<!-- ① CDN: Leaflet CSS + JS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

<!-- ② 컨테이너 (높이 필수!) -->
<div id="map" style="height:440px"></div>

<script>
// ③ 데이터 준비 — A안: 직접 작성
const places = [
    { lat: 37.5665, lng: 126.978, name: '서울시청', count: 45 },
    { lat: 37.5172, lng: 127.0473, name: '강남구청', count: 78 }
];

// ③ 데이터 준비 — B안: 외부 JSON에서 fetch (실무 패턴)
// fetch('places.json')
//     .then(r => r.json())
//     .then(places => drawMap(places));

// ④ 지도 생성 + 레이어
const map = L.map('map').setView([37.5665, 126.978], 11);  // 중심좌표 + 줌(1~18)
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
    { attribution: '© OpenStreetMap' }).addTo(map);
places.forEach(p => {
    L.marker([p.lat, p.lng])
     .bindPopup(`<b>${p.name}</b><br>사고 ${p.count}건`)
     .addTo(map);
});
</script>
💡 학생 미션: 위 코드의 places 배열만 자기 데이터로 교체하면 끝. 위/경도(lat, lng)와 표시할 이름·값만 맞추면 어떤 지점이든 그릴 수 있습니다.
① 기초 마커 — 지도 + 마커 + 팝업
📚 지도 시각화의 출발점
🎯 목적 · 데이터에 위도/경도가 있으면 가장 먼저 떠올릴 것 — "지도에 점 찍기".
✨ 특징 · Leaflet은 가장 가벼운 무료 라이브러리. 3단계(지도→타일→마커)로 끝.
🚦 사례
  • 특정 사망사고 5건의 위치 → "어느 교차로?"
  • 학교 주변 어린이보호구역 위반 지점
⚠️ 주의 · 점이 100개 넘으면 가독성 폭락 → 클러스터(②) 또는 히트맵(④).
📝 자기 데이터로: places 배열의 lat/lng/name/count만 교체. 길이만큼 마커가 자동 생성됨.
// ② <div id="map1" style="height:440px"></div>

// ③ 데이터 준비 ─── 자기 데이터로 교체
const places = [
    { lat: 37.5665, lng: 126.978,  name: '서울시청',   count: 45 },
    { lat: 37.5172, lng: 127.0473, name: '강남구청',   count: 78 },
    { lat: 37.5796, lng: 126.977,  name: '광화문',     count: 32 },
    { lat: 37.5443, lng: 127.0557, name: '잠실역',     count: 61 },
    { lat: 37.5326, lng: 126.9901, name: '한남대교',   count: 53 }
];

// ④ 지도 생성 (3단계)
const map = L.map('map1').setView([37.5665, 126.978], 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
    { attribution: '© OpenStreetMap' }).addTo(map);

places.forEach(p => {
    L.marker([p.lat, p.lng])
     .bindPopup(`<b>${p.name}</b><br>사고 ${p.count}건`)
     .addTo(map);
});
⚠️ <div id="map">에 반드시 height가 지정돼 있어야 합니다.
② 마커 클러스터 — 다수 데이터를 자동 그룹화
📚 마커 클러스터링이란?
🎯 목적 · 점이 수백~수천 개면 화면이 까매짐 → 가까운 점들을 "숫자가 적힌 동그라미"로 묶음. 줌인하면 자동 분리.
✨ 특징 · 데이터 양과 무관하게 화면 깔끔. 색·필터로 카테고리별 토글.
🚦 사례
  • 한 해 서울 사고 30,000건을 그대로 점찍기
  • 유형별 색상 + 체크박스 토글
⚠️ 주의 · 클러스터는 밀도를 보여주지만 총량은 안 보임 → 총량은 코로플레스(⑤).
📝 핵심: L.markerClusterGroup()로 그룹 만들고 마커들을 그 그룹에 add → 마지막에 map에 add. 카테고리별로 그룹을 분리하면 토글 가능.
// ① CDN 추가:
// <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
// <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
// <script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
// ② <div id="map2" style="height:440px"></div>

// ③ 데이터 준비 ─── 자기 데이터로 교체
const data = [
    { lat: 37.517, lng: 127.047, type: '차대차' },
    { lat: 37.544, lng: 127.055, type: '차대사람' },
    { lat: 37.498, lng: 127.027, type: '차량단독' }
    // ... 수백~수천 건도 OK
];
const typeColors = { 차대차:'#3b82f6', 차대사람:'#ef4444', 차량단독:'#f59e0b' };

// ④ 지도 + 클러스터 (카테고리별로 그룹 분리 → 토글 가능)
const map = L.map('map2').setView([37.55, 127.01], 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

const clusters = {};
['차대차', '차대사람', '차량단독'].forEach(t => {
    clusters[t] = L.markerClusterGroup();           // 카테고리당 그룹 1개
});
data.forEach(d => {
    L.circleMarker([d.lat, d.lng], {
        radius: 6, fillColor: typeColors[d.type],
        color: '#fff', weight: 1, fillOpacity: 0.85
    }).bindPopup(d.type).addTo(clusters[d.type]);
});
Object.values(clusters).forEach(c => map.addLayer(c));

// 체크박스로 토글
// document.getElementById('cb-차대차').onchange = e =>
//     e.target.checked ? map.addLayer(clusters['차대차']) : map.removeLayer(clusters['차대차']);
차대차
차대사람
차량단독
③ 원형 마커 (Proportional Symbol) — 반지름으로 정량 표현
📚 정량 원형 마커란?
🎯 목적 · 위치 + "얼마나" 동시에. 원의 크기 = 값.
✨ 특징 · 위치-양 결합 시각화의 표준.
🚦 사례
  • 주요 교차로 7곳의 사고건수: 강남역(850) > 잠실(680) > 서울역(520)
  • 지하철역별 보행 사고 빈도
⚠️ 주의 · 면적이 값에 비례해야 정확 → radius = √(값) / 상수. 직접 r=값으로 하면 큰 값이 4배·9배 과장.
📝 핵심: radius: Math.sqrt(d.count) / 1.6 같이 √ 변환. 분모(1.6)는 데이터 범위에 맞춰 조정.
// ② <div id="map3" style="height:440px"></div>

// ③ 데이터 준비 ─── 위치 + 값
const data = [
    { name:'강남역',   lat:37.4979, lng:127.0276, count:850 },
    { name:'서울역',   lat:37.5547, lng:126.9706, count:520 },
    { name:'잠실역',   lat:37.5133, lng:127.1000, count:680 },
    { name:'홍대입구', lat:37.5572, lng:126.9244, count:430 },
    { name:'사당역',   lat:37.4769, lng:126.9817, count:380 }
];

// ④ 지도 + 원형 마커 (반지름은 √값)
const map = L.map('map3').setView([37.55, 127.0], 11);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

data.forEach(d => {
    L.circleMarker([d.lat, d.lng], {
        radius: Math.sqrt(d.count) / 1.6,            // ← √ 변환 핵심
        fillColor: '#ef4444',
        color: 'white', weight: 2, fillOpacity: 0.65
    }).bindPopup(`<b>${d.name}</b><br>사고 ${d.count}건`).addTo(map);
});
④ 히트맵 — 사고 밀집 지역 시각화
📚 히트맵이란?
🎯 목적 · 점이 너무 많을 때 "어디에 얼마나 모여 있는가"를 색 농도로. 핫스팟이 즉시 보임.
✨ 특징 · 개별 점은 안 보이지만 전체 밀도 분포가 그림처럼 드러남.
🚦 사례
  • "서울 어디가 사고 핫스팟?" — 강남·잠실이 빨갛게
  • 야간/주말 한정 → 시간대별 위험구역 차이
⚠️ 주의 · 정확 수치 비교 불가. 보조로 마커·툴팁 함께.
📝 데이터 형식: [[위도, 경도, 강도(0~1)], ...] 3원소 배열들의 배열. 강도는 생략 가능.
// ① CDN: leaflet.heat 추가
// <script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
// ② <div id="map4" style="height:440px"></div>

// ③ 데이터 준비 ─── [위도, 경도, 강도] 3원소 배열
const heatPoints = [
    [37.517, 127.047, 0.8],
    [37.518, 127.046, 0.6],
    [37.544, 127.055, 0.9]
    // ... 수천 개도 OK. 강도(3번째 값) 생략 시 1로 처리
];

// ④ 지도 + 히트맵 레이어
const map = L.map('map4').setView([37.55, 127.01], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

L.heatLayer(heatPoints, {
    radius: 22,                                // 한 점이 차지하는 반경
    blur:   15,                                // 흐림 정도
    maxZoom: 14,
    gradient: { 0.2: 'blue', 0.5: 'lime', 0.8: 'yellow', 1: 'red' }
}).addTo(map);
22 15
⑤ 코로플레스(Choropleth) — 행정구역 색상 단계구분
📚 코로플레스란?
🎯 목적 · 행정구역(시도/시군구/동) 단위 통계를 구역 색 농도로. "어느 지역이 가장 위험한가" 즉시 인식.
✨ 특징 · GeoJSON으로 경계 그리고, properties.name으로 데이터 매칭. hover 강조 + 클릭 팝업.
🚦 사례
  • 17개 시도별 사고건수: 경기·서울이 짙은 파랑
  • 인구 10만명당 사망률 → "위험도" 비교
⚠️ 주의 · 행정구역 면적이 크면 시각적 과대평가 → 인구 가중 지표 권장.
📝 2개 데이터 필요: ① GeoJSON(경계선) ② 통계값 객체({시도명: 값}). 둘을 fetch로 가져와 결합.
// ② <div id="map5" style="height:440px"></div>

// ③ 데이터 준비 ─── 통계 객체 (시도명 → 값)
const regionData = {
    '서울':34215, '경기':58432, '부산':15621, '대구':9843,
    '인천':14523, '광주':7234,  '대전':8123,  '울산':5234
    // ... 자기 데이터로
};

// 색상 함수 (값 → 색)
function getColor(v) {
    return v > 50000 ? '#084594' : v > 30000 ? '#2171b5' :
           v > 15000 ? '#4292c6' : v > 10000 ? '#6baed6' :
           v >  5000 ? '#9ecae1' : '#deebf7';
}

// ④ 지도 + GeoJSON (외부 파일 fetch)
const map = L.map('map5').setView([36.3, 127.8], 7);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

fetch('https://cdn.jsdelivr.net/gh/southkorea/southkorea-maps@master/kostat/2018/json/skorea-provinces-2018-geo.json')
    .then(r => r.json())
    .then(geo => {
        L.geoJSON(geo, {
            style: f => ({                            // 각 행정구역의 스타일
                fillColor:   getColor(regionData[f.properties.name] || 0),
                fillOpacity: 0.75,
                color: 'white', weight: 1
            }),
            onEachFeature: (f, lyr) => {              // 각 구역에 이벤트 부여
                const name = f.properties.name;
                const v = regionData[name] || 0;
                lyr.bindPopup(`<b>${name}</b><br>사고 ${v.toLocaleString()}건`);
                lyr.on('mouseover', () => lyr.setStyle({ fillOpacity:0.95, weight:2.5 }));
                lyr.on('mouseout',  () => lyr.setStyle({ fillOpacity:0.75, weight:1 }));
            }
        }).addTo(map);
    });
💡 실제 시도 GeoJSON(cdn.jsdelivr.net/gh/southkorea/southkorea-maps)을 사용. 인터넷 차단 시 자동 사각형 폴백.
⑥ 폴리라인 / 폴리곤 / 원 — 경로와 영역
📚 선·면 시각화란?
🎯 목적 · 점이 아닌 "길/구역/반경". 폴리라인=선, 폴리곤=닫힌 면, 원=반경.
✨ 특징 · GPS 트랙·관심영역(AOI)·반경원 표현 가능.
🚦 사례
  • 운전자 사고 직전 GPS 경로 (폴리라인)
  • 어린이 보호구역 다각형 영역 + 위반 마커
  • 학교 반경 300m 원 → 안전 점검 대상 추출
⚠️ 주의 · 폴리곤은 좌표를 닫힌 순서로. 너무 복잡하면 simplify.
📝 3가지 도구: L.polyline(좌표 배열), L.polygon(닫힌 좌표 배열), L.circle(중심+반경 미터).
// ② <div id="map6" style="height:440px"></div>

// ③ 데이터 준비
const route = [
    [37.5665, 126.978], [37.560, 127.000], [37.553, 127.018],
    [37.546, 127.040], [37.540, 127.060], [37.535, 127.082]
];
const aoi   = [[37.555,126.97],[37.555,127.00],[37.540,127.00],[37.540,126.97]];
const circleCenter = [37.510, 127.05], radiusMeter = 1500;

// ④ 지도 + 3가지 도형
const map = L.map('map6').setView([37.535, 127.0], 12);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);

// 폴리라인 (경로)
L.polyline(route, { color: '#ef4444', weight: 5, opacity: 0.85 })
 .addTo(map).bindPopup('시뮬레이션 경로');

// 폴리곤 (닫힌 영역)
L.polygon(aoi, { color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.25 })
 .addTo(map).bindPopup('관심 영역');

// 원 (반경, 단위: 미터)
L.circle(circleCenter, { radius: radiusMeter, color: '#10b981', fillOpacity: 0.18 })
 .addTo(map).bindPopup('반경 1.5km 원');

// 출발/도착 마커
L.marker(route[0]).addTo(map).bindPopup('출발');
L.marker(route.at(-1)).addTo(map).bindPopup('도착');
⑦ 베이스 타일 전환 + 레이어 컨트롤
📚 레이어 컨트롤이란?
🎯 목적 · 독자가 지도 스타일(밝은/어두운/위성)과 표시 레이어(마커·히트맵)를 토글.
✨ 특징 · 우상단 컨트롤에서 베이스맵은 라디오, 오버레이는 체크박스. 한 줄로 끝.
🚦 사례
  • 야간 사고 → 어두운 베이스맵
  • "마커만/히트맵만/둘 다" 자유 토글
⚠️ 주의 · 베이스맵 사용 약관 확인. 무료는 OSM·CartoDB.
📝 핵심: L.control.layers(베이스, 오버레이).addTo(map) 한 줄. 객체 키가 UI 라벨.
// ② <div id="map7" style="height:440px"></div>

// ③ 베이스맵 + 오버레이 레이어 정의
const osm   = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png');
const dark  = L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png');
const light = L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png');

// ④ 지도 + 컨트롤
const map = L.map('map7').setView([37.55, 127.0], 11);
osm.addTo(map);                                       // 처음 보일 베이스맵

const markerLayer = L.layerGroup([
    L.marker([37.5665, 126.978]).bindPopup('서울시청'),
    L.marker([37.5172, 127.0473]).bindPopup('강남구청')
]);
markerLayer.addTo(map);

L.control.layers(
    { 'OSM (기본)': osm, 'Dark': dark, 'Light': light },   // 베이스 (라디오)
    { '주요 교차로 마커': markerLayer }                       // 오버레이 (체크)
).addTo(map);