웹 프로그래밍

[Yelpcamp 프로젝트] 클러스터 맵

미안하다 강림이 좀 늦었다 2023. 12. 9. 22:16

 

 

클러스터 맵 추가

모든 캠핑장을 보여주는 index 페이지에 클러스터 맵을 추가 해보자. mapbox를 사용한다.

https://docs.mapbox.com/mapbox-gl-js/example/cluster/

 

Create and style clusters | Mapbox GL JS

Use Mapbox GL JS' built-in functions to visualize points as clusters.

docs.mapbox.com

위 링크로 들어가서 필요한 코드를 붙여넣는다. 

 

이 코드는 이미 boilerplate에 포함되어있다.

<link href="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.0.1/mapbox-gl.js"></script>

 

clusterMap.js라는 파일을 새로 만들어서 스크립트 부분을 붙여 넣는다. 참고로 나는 맵 스타일을 light-v10 버전으로 바꿨다.

mapboxgl.accessToken = mapToken;
const map = new mapboxgl.Map({
    container: 'map',
    // Choose from Mapbox's core styles, or make your own style with Mapbox Studio
    style: 'mapbox://styles/mapbox/light-v10',
    center: [-103.5917, 40.6699],
    zoom: 3
});

map.on('load', () => {
    // Add a new source from our GeoJSON data and
    // set the 'cluster' option to true. GL-JS will
    // add the point_count property to your source data.
    map.addSource('earthquakes', {
        type: 'geojson',
        // Point to GeoJSON data. This example visualizes all M1.0+ earthquakes
        // from 12/22/15 to 1/21/16 as logged by USGS' Earthquake hazards program.
        data: 'https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson',
        cluster: true,
        clusterMaxZoom: 14, // Max zoom to cluster points on
        clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
    });

    map.addLayer({
        id: 'clusters',
        type: 'circle',
        source: 'earthquakes',
        filter: ['has', 'point_count'],
        paint: {
            // Use step expressions (https://docs.mapbox.com/style-spec/reference/expressions/#step)
            // with three steps to implement three types of circles:
            //   * Blue, 20px circles when point count is less than 100
            //   * Yellow, 30px circles when point count is between 100 and 750
            //   * Pink, 40px circles when point count is greater than or equal to 750
            'circle-color': [
                'step',
                ['get', 'point_count'],
                '#51bbd6',
                100,
                '#f1f075',
                750,
                '#f28cb1'
            ],
            'circle-radius': [
                'step',
                ['get', 'point_count'],
                20,
                100,
                30,
                750,
                40
            ]
        }
    });

    map.addLayer({
        id: 'cluster-count',
        type: 'symbol',
        source: 'earthquakes',
        filter: ['has', 'point_count'],
        layout: {
            'text-field': ['get', 'point_count_abbreviated'],
            'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
            'text-size': 12
        }
    });

    map.addLayer({
        id: 'unclustered-point',
        type: 'circle',
        source: 'earthquakes',
        filter: ['!', ['has', 'point_count']],
        paint: {
            'circle-color': '#11b4da',
            'circle-radius': 4,
            'circle-stroke-width': 1,
            'circle-stroke-color': '#fff'
        }
    });

    // inspect a cluster on click
    map.on('click', 'clusters', (e) => {
        const features = map.queryRenderedFeatures(e.point, {
            layers: ['clusters']
        });
        const clusterId = features[0].properties.cluster_id;
        map.getSource('earthquakes').getClusterExpansionZoom(
            clusterId,
            (err, zoom) => {
                if (err) return;

                map.easeTo({
                    center: features[0].geometry.coordinates,
                    zoom: zoom
                });
            }
        );
    });

    // When a click event occurs on a feature in
    // the unclustered-point layer, open a popup at
    // the location of the feature, with
    // description HTML from its properties.
    map.on('click', 'unclustered-point', (e) => {
        const coordinates = e.features[0].geometry.coordinates.slice();
        const mag = e.features[0].properties.mag;
        const tsunami =
            e.features[0].properties.tsunami === 1 ? 'yes' : 'no';

        // Ensure that if the map is zoomed out such that
        // multiple copies of the feature are visible, the
        // popup appears over the copy being pointed to.
        while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
            coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
        }

        new mapboxgl.Popup()
            .setLngLat(coordinates)
            .setHTML(
                `magnitude: ${mag}<br>Was there a tsunami?: ${tsunami}`
            )
            .addTo(map);
    });

    map.on('mouseenter', 'clusters', () => {
        map.getCanvas().style.cursor = 'pointer';
    });
    map.on('mouseleave', 'clusters', () => {
        map.getCanvas().style.cursor = '';
    });
});

 

clusterMap.js에서 mapbox 토큰에 접근할 수 있도록 index.ejs 페이지 최하단에 mapToken을 정의하고 clusterMap.js 스크립트를 포함시킨다.

<script>
	const mapToken = '<%-process.env.MAPBOX_TOKEN%>';
</script>
<script src="/javascript/clusterMap.js"></script>

 

이제 클러스터 맵을 표시하기 위한 기본적인 것들이 준비됐다. index.ejs 파일에서 클러스터 맵 표시를 원하는 위치에 아래 코드를 붙여넣는다. 스타일은 원하는 대로 지정하면 된다. 참고로 width랑 height 설정 안 하면 맵이 안 보인다.

<div id="map" style="margin-top: 10px; width: 100%; height: 500px;"></div>

 

 

캠핑장 위치 표시

index.ejs에는 모든 캠핑장을 배열로 가지고 있는 campgrounds 변수가 있다. clusterMap.js에서 campgrounds 변수에 접근할 수 있도록 아래와 같이 index.ejs 파일에 추가해 주자. 추가해도 동작은 안 하는데 차차 알아보자.

<script>
	const mapToken = '<%-process.env.MAPBOX_TOKEN%>';
	const campgrounds = <%- JSON.stringify(campgrounds) %>;
</script>

 

clusterMap.js에서 map.on 부분의 데이터를 보면 addSource 안에 data가 있는데 url이 있다. url을 복사해서 브라우저에서 실행해 보자.

features는 배열이고 그 안에 데이터가 매우 많은데 두 개만 보이도록 캡처했다. 이게 지도에 점을 찍어주는 정보임을 알 수 있다. 따라서 features를 키로 하고, index.ejs에서의 campgrounds 배열을 값으로 하여 clusterMap.js에서 접근할 수 있게 해야 한다. 그러니 아래처럼 코드를 고치자.

<script>
	const mapToken = '<%-process.env.MAPBOX_TOKEN%>';
	const campgrounds = {features: <%- JSON.stringify(campgrounds) %>};
</script>

 

clusterMap.js에서 map을 선언하는 부분 중 center: [127.5, 37]로 고친다. 맵이 처음 보일 때 중심을 대한민국의 경도와 위도로 설정한 것이다. zoom은 5로 설정했다. zoom이 크면 좁은 지역이 자세히 보인다. 그리고 addSource 안에 data를 campgrounds로 고친다. 'earthquakes'로 되어있는 모든 문자열도 'campgrounds'로 바꾼다. 안 바꿔줘도 작동하지 않는다거나 그러지는 않는다.

const map = new mapboxgl.Map({
    container: 'map',
    style: 'mapbox://styles/mapbox/light-v10',
    center: [127.5, 37],
    zoom: 5
});

map.on('load', () => {
    map.addSource('campgrounds', {
        type: 'geojson',
        data: campgrounds,
        cluster: true,
        clusterMaxZoom: 14, // Max zoom to cluster points on
        clusterRadius: 50 // Radius of each cluster when clustering points (defaults to 50)
    });

 

위치 클릭 팝업

위치를 클릭하면 캠핑장 이름과 주소를 보여주고, 캠핑장 이름을 클릭하면 상세 페이지로 이동시켜 주는 팝업창을 만들어보자.

아래 코드는 공식 페이지에서 복사한 코드 그대로이고, 이 코드는 하나하나 점 찍혀있는 캠핑장을 클릭했을 때 팝업을 띄워주는 역할을 하는 코드이다. 하지만 이대로 사용하면 원하는 결과가 나오지 않는다. 무슨 일인지 확인하기 위해 e.features[0]을 콘솔에 출력해 보자.

map.on('click', 'unclustered-point', (e) => {
	const coordinates = e.features[0].geometry.coordinates.slice();
	const mag = e.features[0].properties.mag;
	const tsunami =
		e.features[0].properties.tsunami === 1 ? 'yes' : 'no';

	// Ensure that if the map is zoomed out such that
	// multiple copies of the feature are visible, the
	// popup appears over the copy being pointed to.
	while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
		coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
	}

	new mapboxgl.Popup()
		.setLngLat(coordinates)
		.setHTML(
			`magnitude: ${mag}<br>Was there a tsunami?: ${tsunami}`
		)
		.addTo(map);
});

 

properties가 비어있는 것을 확인할 수 있다. 우리가 만든 캠핑장 스키마에는 properties 속성이 없기 때문이다. 위 코드를 보면 알겠지만 팝업에 띄울 메시지는 features[인덱스]의 properties에 접근해서 결정한다. features[인덱스]는 캠핑장을 의미한다. 따라서 캠핑장 스키마에 properties 속성을 추가해야 한다.

 

캠핑장 스키마 자체에 속성을 추가해도 되지만 나는 가상 속성을 추가할 것이다.

캠핑장 스키마가 정의되어 있는 campground.js에서 아래 코드를 추가한다.

CampgroundSchema.virtual('properties').get(function () {
    return { popUpHTML: 
        `<h5><a href="campgrounds/${this._id}">${this.title}</a></h5>
        <p>${this.location}</p>`
    };
})

clusterMap.js를 아래와 같이 수정한다.

map.on('click', 'unclustered-point', (e) => {
	const coordinates = e.features[0].geometry.coordinates.slice();
	const popupText = e.features[0].properties.popupText;
	while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
		coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
	}

	new mapboxgl.Popup()
		.setLngLat(coordinates)
		.setHTML(popupText)
		.addTo(map);
});

 

근데 실행하고 점을 클릭해 보면 undefined라고 뜰 것이다. 캠핑장을 콘솔에 출력해보자. 속성 목록에 가상 속성 properties가 없다. 그래서 undefined라고 뜨는 것이다. mongoose 문서를 보면 '기본적으로 mongoose는 문서를 JSON으로 변환할 때 가상 속성을 포함하지 않는다'라고 되어 있다. 따라서 가상 속성을 포함해 주는 옵션을 지정해야 한다.

캠핑장 스키마가 정의되어 있는 파일을 아래 코드처럼 수정한다.

const opts = { toJSON: { virtuals: true } };

...

const CampgroundSchema = new Schema({
    title: {
        type: String,
        required: true
    },
    ...
}, opts);

properties가 생겼다.
하이퍼링크도 정상적으로 작동한다.

 

clusterMap.js 파일에서 점의 색도 바꾸고 크기도 바꿀 수 있다. 나는 클러스터 되지 않은 점이 너무 작은 것 같아서 'circle-radius'를 7로 바꿨다. 원래는 4였다.

map.addLayer({
	id: 'unclustered-point',
	type: 'circle',
	source: 'campgrounds',
	filter: ['!', ['has', 'point_count']],
	paint: {
		'circle-color': '#11b4da',
		'circle-radius': 7,
		'circle-stroke-width': 1,
		'circle-stroke-color': '#fff'
	}
});