[d3.js] d3를 이용하여 google map 에 따릉이 대여/반납 flow 표현

Castle_Junny·2023년 5월 28일
0

d3.js랑 친해지기

목록 보기
4/4
post-thumbnail

1. 서론

이전에는 서울지리데이터.geojsongoogle map 위에 overlay Layer 로 올리고 따릉이 대여소 위치를 표현하였다. 그리고 오늘은 따릉이를 어디서 빌려서, 어디에 반납하는지 그 흐름을 표 나타내 보려고 한다.

오늘의 작업을 위해서는 두가지 단계가 필요하다.

  1. 대여 / 반납 장소를 화면에 확대하여 표현 (* fitbound)
  2. 대여 / 반납 장소를 화살표로 방향 표현 (d3.js 이용)

2. 기본 개념

  • google map은 기본적으로 하나의 <div></div> 안에 지도 레이어를 채워 넣는 개념이기 때문에 단순히 새로운 div (레이어)position : absolute를 하게 되면 지도에 대한 동작이 실행이 안된다. 그렇기에 google에서 제공하는 overlayLayer object를 이용해서 내부적으로 레이어를 쌓아야 한다.

  • 대여/반납 좌표(위,경도)는 google map data layer를 이용하여 뿌린다. data layergeojson데이터를 뿌린다. (API 문서 링크)

  • fitbound 는 주어진 위, 경도 값을 이용하여 경계BOX를 생성하여 해당 BOX를 확대하는 것이다. 이 때 이 BOX의 초기 값을 잡기 위해서 extend를 해준다. (API 문서 링크)

  • d3로 생성한 svg 내에서는 좌표를 pixel로 변환을 해주어야 한다.

3. 대여/반납 장소 fitbound 하기

3.1 작업 순서

  1. 대여/반납 장소 위, 위경도 값을 이용하여 geojosn 객체 셍성
  2. google map의 data layer 를 이용하여 geojson 데이터 뿌리기
  3. fitbound 를 하기 위해 대여/반납 장소의 위 경도 값을 extend 해준 뒤 화면에 맞게 확대

3.2 geojson 생성

  • geojson 을 이용해서 데이터를 뿌려주는 이유는 실무를 진행할 때 geojson의 properties 라는 속성에 위,경도에 대한 정보를 담고, 꺼낼 수 있다는 장점이 있기 때문에 geojson으로 생성하여 진행하였다.

  • 여기서 data 는 따릉이 대여/반납 데이터이다. (1개 데이터 이용)


// geojson을 생성한다. 
// geojson의 구성에 맞게 데이터를 생성함.
    function createGeoJson(data) {
        const fromGeojson = {coordinates: [data.from_lng, data.from_lat], type: "Point"}
        const toGeojson = {coordinates: [data.to_lng, data.to_lat], type: "Point"}
        const initFeature = {type: "FeatureCollection", features: []};

        const createFeature = (data, geojson, type) => initFeature.features.push({
            type: "Feature",
            properties: {layerType: "rent", data: data, type},
            geometry: geojson
        });

        createFeature(data.from_nm, fromGeojson, "from");
        createFeature(data.to_nm, toGeojson, "to");

        return {feature: initFeature, loc: {from: [data.from_lat, data.from_lng], to: [data.to_lat, data.to_lng]}};
    }

3.3 fitBounds 하기

  • fitbound(data) 함수가 실행되면 geojson을 생성하고, map.data.addGeoJson(geojson객체) 를 해줌으로서 지도 위에 단순히 Marker만 뿌려준다.

  • map.data.setStyle를 하면 Marker를 custom 할 수 있다. 나는 svg 이미지를 이용하여 뿌린다.

  • 그리고 fitbound를 하기 위해 bound 객체를 생성해 주고 대여/반납 위 경도 값을 extend 해준 뒤 map.fitBounds() 를 해준다.


    function fitBound(data) {
        const {feature, loc} = createGeoJson(data);

      // 데이터 레이어로 Marker 뿌리기 
        map.data.addGeoJson(feature); 

      // Marker style Setting. 
        map.data.setStyle((feature) => {
            const getType = feature.getProperty("type");
            return {
                icon: {
                    url: 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(createSvg(getType)),
                    // anchor: new window.google.maps.Point(20, 56)
                },
                zIndex: 10,
                visible: true
            };

        });
      
      // fitBounds 하기 위해 경계 Box 사이즈 확장(등록?)
        const bound = new google.maps.LatLngBounds();
        bound.extend(new google.maps.LatLng(loc.from[0], loc.from[1]))
        bound.extend(new google.maps.LatLng(loc.to[0], loc.to[1]))

      // bounds를 fit 하게 맞추기 
        map.fitBounds(bound);
    }

4. 반납/대여 flow 뿌리기

아래에 진행되는 모든 작업은 fitBounds가 된 이후에 확장된 화면에서 진행된다.
그리고 삼각함수에서 사용되는 x,y 좌표 값은 위경도 값이 아닌 위경도를 화면의 pixel 값으로 변환한 다음에 진행해야만 한다. svg위에 그리는 것이디 때문에..!!

위경도 값을 변환하는 것은 code를 보면 쉽게 알 수 있다!


d3 에서 curve를 그리기 위해서는 세가지 좌표가 필요하다.

  1. 시작점 (대여 Point)
  2. 도착점 (반납 Point)
  3. 중간점 (그 중간..)

위 사진에서 보여지는 것 처럼 h의 경우 에는 사용자가 임의로 정의하면 되는 값이다.

사진 처럼 직선의 경우에는 center의 좌표를 쉽게 게산할 수 있지만, 2차원 평면에서 구하기 위해서는 삼각함수를 이용해야만 한다.

4.1 삼각함수로 필요한 값 유도

4.1.1 삼각비를 이용하여 필요한 값 유도

- curve 를 그리기 위해 필요한 값들

  1. 각도 (theta)
  2. x,y 거리
  3. 대각선(d) 거리
  4. 대각선(d) 중심 좌표

4.1.2 중심 값도 찾았고.. 그럼 curve의 중간 값은..?

대각선(d)의 중심 좌표를 알았으면 이제 curve를 그릴때 필요한 center값 아래 사진에서는 h 를 구해야한다. 이것 또한 삼각비를 이해하면 쉽게 유도해 낼 수 있다!!

x'y' 값을 위에서 구한 대각선(d) 의 중심 좌표 에 더해주면 된다 !

아래 사진에서는 - 를 해주고 있지만 사실 그렇게 크게 중요한 요소는 아닌거 같다. 자 대고 정확하게 그림을 그려 내는 게 아니라 curve 느낌만 주면 되니까..!

4.2 작업 순서

  1. d3로 지도 위에 svg생성할 수 있게 overlayLayer 객체 생성
  2. d3 로 svg 그릴 Canvas생성
  3. 대여(from), 반납(to)의 pixel 좌표 도출
  4. 위에서 유도한 식을 이용하여 필요한 값들 도출 및 Draw
  5. 뿌려주기

4.3 작성한 코드

사실 여기서 부터 코드를 어떻게 설명해야할지 몰라서 위에서 작업 순서를 작성하였다.
d3의 기본 기능은 나보다 더 깊은 이해를 하고 있다고 생각하고 코드를 설명해보자면...

  • 참고 1

  • 참고 2


// 1. overlay Layer 객체 생성!! 

        const overlay = new google.maps.OverlayView();
        let layer, projection, boundObj, bound_sw, bound_ne;


// 1-2 overlay Layer 객체가 생성되면 자동으로 실행되는 내장 함수
// overlayLayer에 flow 를 그릴 도화지를 펼치는 것
        overlay.onAdd = function () {
            layer = d3.select(this.getPanes().overlayLayer)
                .append("div")
                .attr("class", "stations")
                .attr("id", "arrowCanvas")
        };
// 1-2 overlay Layer 객체가 생성되면 자동으로 실행되는 내장 함수 
// 펼친 도화지에 이제 그림으르 그린다. 
        overlay.draw = function () {
          
          // 만약에 이전에 누가 쓰던 도화지 있으면 찢어버려!!
            if (d3.select('#arrowCanvas svg')) {
                d3.select('#arrowCanvas svg').remove();
            }

            projection = this.getProjection();

          // 위에 설명한 위,경도 값을 pixel 로 convert 해서 모서리 값 도출 ---- (참고. 1)
          // flow를 그릴 svg 사이즈 그려야지 
            boundObj = map.getBounds();
          
          //bound_sw = 화면의 왼쪽 아래 꼭지점 (south-west)
            bound_sw = projection.fromLatLngToDivPixel(boundObj.getSouthWest());
          
          //bound_ne = 화면의 오른 쪽 위 꼭지점 (north-east)
            bound_ne = projection.fromLatLngToDivPixel(boundObj.getNorthEast());


          // 제 도화지 이름과 사이즈는 이거에요! ----- (참고. 2)
            const canvas = layer.append("svg")
                .attr("id", 'arrowCanvasSvg')
                .style("left", bound_sw.x + 'px')
                .style("top", bound_ne.y + 'px')
                .style('width', bound_ne.x - bound_sw.x)
                .style('height', bound_sw.y - bound_ne.y)


            const arrowGroupCanvas = canvas.append("g").attr("id", "arrowGroup");

          
          /** 
           * transform(d)에서 위 경도 값을 pixel 값으로 convert 해 준다. 
           * 상황에 따라 다양한 작업이 이루어지지만 여기선 단순히 convert 용도 
          */
          
            let arrowCanvas = arrowGroupCanvas.selectAll('g')
                .data([data])
                .each(d => transform(d))
                .enter()
                .append('g')
                .each(d => transform(d));
          
          /** 
           * cala(d) 함수를 통해 위에서 도출한 curve를 그릴 때 필요한 [center 좌표] 를 도출한다. 
          */
          
            let lineArrow = arrowCanvas.append("path")
                .attr("d", d => {
                  
                    const {from, to, gap} = calc(d);
                  
                  
                    const lineCurve = d3.line().curve(d3.curveBasis);
                    return lineCurve([[from.x, from.y], [gap.x, gap.y], [to.x, to.y]]);
                }).style('stroke', '#498eff')
                .style('stroke-width', 3)
                .attr("fill-opacity", 0);
        };
  • calc function에서는 위에서 유도한 식을 그대로 사용하였기 때문에 이해하기에 어려움은 없을 거 같다. 다만 scaleValue 라는 녀석을 모를 수 있는데, 이 녀석은 사용자가 zoom level에 따라 대각선 (d) 길이 가 달라지게 된다. 그렇기에 x'y'의 값도 달라지게 된다. 이를 보정해주는 zoom Level에 따른 보정값 이라고 생각하면 된다.

          /** 
           * transform(d)에서 위 경도 값을 pixel 값으로 convert 해 준다. 
           * 상황에 따라 다양한 작업이 이루어지지만 여기선 단순히 convert 용도 
          */
          
        function transform(d) {

            const convertToPixel = (projection, lat, lng) => {
                const latLng = new google.maps.LatLng(lat, lng);
                return projection.fromLatLngToDivPixel(latLng);
            };

            const fr_pixel = convertToPixel(projection, d.from_lat, d.from_lng);
            const to_pixel = convertToPixel(projection, d.to_lat, d.to_lng);

            d.fr_pix = fr_pixel;
            d.to_pix = to_pixel;
        }

          
          /** 
           * cala(d) 함수를 통해 위에서 도출한 
           * curve를 그릴 때 필요한 [center 좌표] 를 도출한다. 
          */
          
      function calc(d) {
            const start_point = d.fr_pix;
            const end_point = d.to_pix;

            const from_point = {x: start_point.x - bound_sw.x, y: start_point.y - bound_ne.y};
            const to_point = {x: end_point.x - bound_sw.x, y: end_point.y - bound_ne.y}

            const getDistance = (x, y) => Math.sqrt(x ** 2 + y ** 2);
            const getDegree = (x, y) => (Math.atan2(y, x) * 180 / Math.PI);
            const toRadian = (degree) => degree * (Math.PI / 180);

            const distance_x = Math.abs(to_point.x - from_point.x);
            const distance_y = Math.abs(to_point.y - from_point.y);
            const distance_d = getDistance(distance_x, distance_y);

            const scaleValue = (distance_d / 600).toFixed(4);

            const angle = getDegree(distance_x, distance_y); // radian to degree
            const center = {x: (from_point.x + to_point.x) / 2, y: (from_point.y + to_point.y) / 2};

            const cyx = center.x + (150 * Math.cos(toRadian(angle - 90)) * scaleValue);
            const cyy = center.y + (150 * Math.sin(toRadian(angle - 90)) * scaleValue);


            return {
                gap: {x: cyx, y: cyy}
                , from: from_point
                , to: to_point,
            }
        }

5. 결과물

단순히 라인을 그리는 것 까지 진행을 하였고, 그 외에 animation 효과와 마지막 화살표 모양은 따로 설명없이 코드만 남겼다. (전체 코드)

6. 후기?

삼각함수를 정말 오랜만에 접하다보니 계속 어버버 했던거 같다.
개발이 어려운게 아니라 삼각함수를 통해 값을 유도해내는게 너무 오래 걸렸다..ㅠㅠ 공부좀 하자..
부장님 말씀으로는 개발자는 이정도 수학은 할 줄 알아야 된다며 어려워하는 나를 보고 측은한 눈빛을 보내셨지만 나는 해냈다..!! ㅋㅋ

아무튼 끝내고 나니 별거 아니고 확실히 머릿속에 각인되어 다음번에 비슷한 로직을 짜야할 상황이 생기면 누구보다 더 빨리 이해하고 진행할 수 있을 거 같단 자심감을 갖고 또 다음 개발을 하러.. 출발..!

0개의 댓글