JS OpenLayers (feat.vue.js)

강정우·2023년 10월 12일
0

JavaScript

목록 보기
44/53
post-thumbnail

openlayer + vanilla JS를 사용하는 이유

  • 우선 앞서 포스팅한 vue3-openlayers 라이브러리를 사용하지 않는이유는
    첫번째. 개구리다. 뭐가 많이 없고(제공하는 메서드들이 시원치 않다.) 이번에 완료한 드론 프로젝트에서 복잡한 요구사항들을 완성시키기엔 매우매우 턱없는 라이브러리였다.
    두번째. 원래 라이브러리를 좋아하진 않는다. 최대한 가볍고 네이티브하게 짜서 빠르게 돌리는 것이 목표이기 때문이다.

html tag 작성

  • 가장 쉬운 부분이다. 그냥
<div id="vMap"/>

이렇게만 적어주면 된다. 왜냐하면 어차피 openlayer new Map 생성자 메서드에 target으로 참조할 것이기 때문이다.

import

import { fromLonLat, get as getProjection, toLonLat, transform } from "ol/proj";
import { Map, View, Feature, Overlay } from "ol";
import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer";
import { XYZ, TileWMS, Vector as VectorSource, Cluster } from "ol/source";
import { defaults as defaultControls, FullScreen } from "ol/control";
import { DragRotateAndZoom, defaults as defaultInteractions } from "ol/interaction";
import { LineString, Point, Polygon } from "ol/geom";
import { Circle as CircleStyle, Fill, Icon, Stroke, Style,  Text } from "ol/style";

JS 작성(feat.vue.js)

1. map 만들기

onMounted(async () => {
  // 로딩 state
  isLoading.value = true;
  progressTask.value = "맵 생성중...";
  
  // new Map의 생성자 함수의 target 속성으로 위에서 작성한 html 태그에 얹기 
  vMap.value = new Map({
    // 참고로 zoom, rotate 아이콘은 default가 너무 못생겨서 삭제하고 따로 만듦
    controls: defaultControls({ zoom: false, rotate: false }),
    // 사용자가 핸드폰으로 사용할 때 사용하는 그래그로 회전, 줌 기능
    interactions: defaultInteractions().extend([new DragRotateAndZoom()]),
    // layer는 기본값으로 초기화 하고 나중에 따로 넣어줄꺼임
    layers: [],
    // 여기가 html id 값으로 실제 map은 얹는 곳.
    target: "vMap",
    // map의 카메라 역할을 하는 view 부분.
    view: new View({
      // 우리가 통상 사용한는 projecttion은 EPSG 4326 인데 옵션인데 
      // 안 들어먹어서 그냥 따로 getProjection, lonLAt등 다른 메서드로 EPSG 4326으로 다시 바꿔줌.
      projection: getProjection("EPSG:3857"),
      center: fromLonLat(
        [126.9779228388393, 37.56643948208262],
        getProjection("EPSG:3857")
      ),
      zoom: zoomLv.value,
      maxZoom: 50,
      minZoom: 8,
    }),
  });
  geoModifyLayer.value = new TileLayer({
    // 타일을 [여기](https://www.vworld.kr/dev/v4dv_wmsguide2_s001.do) 들어가면 있음
    source: new TileWMS({
      url: "http://api.vworld.kr/req/wms",
      params: {
        SERVICE: "WMS",
        REQUEST: "GetMap",
        VERSION: "1.3.0",
        LAYERS: "lp_pa_cbnd_bonbun,lp_pa_cbnd_bubun",
        STYLES: "lp_pa_cbnd_bonbun_line,lp_pa_cbnd_bubun_line",
        FORMAT: "image/png",
        KEY: {본인 키 넣으면 됨.},
        DOMAIN: {개발 혹은 프로덕션 주소 넣으면 됨.},
      },
    }),
  });
  baseLayer.value = new TileLayer({
    source: new XYZ({
      // 참고로 zyx에 $를 빼먹은게 아니라 그냥 이렇게 사용하라고 나와있다.
      url: `http://api.vworld.kr/req/wmts/1.0.0/${key}/Base/{z}/{y}/{x}.png`,
    }),
  });
  hybridLayer.value = new TileLayer({
    source: new XYZ({
      url: `http://api.vworld.kr/req/wmts/1.0.0/${key}/Hybrid/{z}/{y}/{x}.png`,
    }),
  });
  satelliteLayer.value = new TileLayer({
    source: new XYZ({
      url: `http://api.vworld.kr/req/wmts/1.0.0/${key}/Satellite/{z}/{y}/{x}.jpeg`,
    }),
  });
  
  // 위에서 생성한 layer들을 setLayers 메서드를 통해 사용함.
  vMap.value.setLayers([baseLayer.value, geoModifyLayer.value]);

  // 사용자가 드래그나 마우스로 지도를 옮겼을 때 이벤트 발생
  vMap.value.on("moveend", async (event) => {
    zoomLv.value = event.map.getView().getZoom();
    isCluster.value = zoomLv.value < 15;
    const center = event.map.getView().getCenter();
    const centerLonLat = toLonLat(center, getProjection("EPSG:3857"));
    const position = {
      lat: centerLonLat[1],
      lon: centerLonLat[0],
    };
    // 클릭이나 터치 할 때마다 req를 날리면 과부하가 걸리기 때문에 1초 간격을 두어 실행하도록 함.
    if (moveEndTimeout.value) {
      clearTimeout(moveEndTimeout.value);
    }
    moveEndTimeout.value = setTimeout(async () => {
      try {
        await 어떠한로직 예를들어 현재위치의 날씨정보가져오기
      } catch (e) {
        console.log("날씨 정보를 가져오는 중 문제가 생겼습니다.");
      }
    }, 1000);
  });

  // 클릭을 했을 때 forEachFeatureAtPixel 함수로 클릭한 부위의 feature들을 모두 가져오고 해당 feature에 특징을 잡아
  // 해당 feature에 로직을 실행하도록 작성함.
  vMap.value.on("click", function (evt) {
    vMap.value.forEachFeatureAtPixel(
      evt.pixel,
      function (feature, layer) {
        let values = feature.getProperties();
        if (values.name) {
          const overlayContent = `<돔트리>`;
          let container = document.createElement("div");
          document.body.appendChild(container);
          var coordinate = evt.coordinate;
          container.innerHTML = overlayContent;
          var overlay = new Overlay({
            element: container,
          });
          overlay.setPosition(values.startPoint);
          vMap.value.addOverlay(overlay);
        }
        if (values.projectIdx) {
          vMap.value.removeOverlay(missionPolygonOverlay.value);
          const overlayContent = `<돔트리>`;
          let container = document.createElement("div");
          document.body.appendChild(container);
          container.innerHTML = overlayContent;
          missionPolygonOverlay.value = new Overlay({
            element: container,
            positioning: "bottom-center",
          });
          missionPolygonOverlay.value.setPosition([values.lon, values.lat]);
          vMap.value.addOverlay(missionPolygonOverlay.value);
          document.getElementById("closeIcon").addEventListener("click", () => {
            vMap.value.removeOverlay(missionPolygonOverlay.value);
          });
        }
      },
      {
        hitTolerance: 2,
        // layerFilter: function (layer) {
        //   return layer === baseVector;
        // },
      }
    );
    if (!!clusterObj) {
      clusterObj.value.getFeatures(evt.pixel).then((clickedFeatures) => {
        if (clickedFeatures.length) {
          const features = clickedFeatures[0].get("features");
          if (features.length > 1) {
            const extent = boundingExtent(
              features.map((r) => r.getGeometry().getCoordinates())
            );
            vMap.value
              .getView()
              .fit(extent, { duration: 1000, padding: [50, 50, 50, 50] });
          }
        }
      });
    }
  });
  vMap.value.render();
  isLoading.value = false;
});
  • 설명을 각각의 주석을 대체하겠다.

  • 자세히 살펴보아야 할 점은 map 이 view, tile, layer, feature로 구성되어있다는 점.

  • 지도의 움직임은 클릭과 터치를 포괄하는 'moveend' 이벤트를 사용해야한다는 점.

  • click으로 feature를 가져와서 특징을 잡아 각 로직을 다르게 실행시켜준다는 점.

  • moveend 마다 req를 하면 서버가 과부하 걸리니까 setTimeout 메서드를 사용해 과부하를 클라이언트 단에서 줄인다는 점.

  • 마지막으로 모든 데이터는 layer 위에 표현된다는 점(그림이해).

2. layer 설정하고 넣고 빼기

  • 위에서 설명했지만 따로 또 뺀이유는 바로 과부하를 줄이기 위함이다. 무슨 뜻이냐

  • 지도를 사용하다보면 zoom level이 매우 높을 땐(전국지도) feature들이 매우 많아서 뭐가 뭔지 분간이 안 간다. 그럴때 클러스터링을 해준다.

이런거

  • 이때 기존의 feature들을 제거하고 cluster를 생성해야하는데 feature을 일일이 제거하고 다시 일일이 생성하면 매우 오래걸린다.

  • 그래서 미리 featureArray나 layerArray를 생성하여 관리를 하고 그걸 for문 돌려서 layer에 넣고 빼기만 하면 되는 형식으로 사용하는 것이 가장 빠르다

// 지도상에 보이는 모든 마커(feature), layer(경로), cluster 등 모두 삭제
const deleteAll = () => {
  flyingDoneMarkerList.value.forEach((element) => {
    vMap.value.removeOverlay(element);
  });
  flyingMarkerList.value.forEach((element) => {
    vMap.value.removeOverlay(element);
  });
  droneStore.removeTempMarker();
  vMap.value.removeLayer(clusterObj.value);
  vMap.value.removeLayer(pathClusterObj.value);
  flyingMarkerList.value = [];
  flyingDoneMarkerList.value = [];
  clusterList.value = [];
  pathClusterList.value = [];
};

// 인공위성 지도와 일반 지도 스위치
const changeMap = () => {
  isSatellite.value = !isSatellite.value;
  if (isSatellite.value) {
    vMap.value.setLayers([satelliteLayer.value, hybridLayer.value]);
    pathArray.value.forEach((data) => {
      vMap.value.addLayer(data);
    });
    missionPolygonList.value.forEach((data) => {
      vMap.value.addLayer(data);
    });
    return;
  }
  vMap.value.setLayers([baseLayer.value, geoModifyLayer.value]);
  pathArray.value.forEach((data) => {
    vMap.value.addLayer(data);
  });
  missionPolygonList.value.forEach((data) => {
    vMap.value.addLayer(data);
  });
};

3. 클러스터 만들기

  • 그럼 앞서 언급한 클러스터링을 만들어보자.
const mkCluster = () => {
  const features = Array.from(flyingDroneData.value.values());
  for (let i = 0; i < features.length; ++i) {
    // fromLonLat 함수로 ESPG:4326 => EPSG:3857 으로 변경
    const coordinates = fromLonLat(
      [features[i].longitude, features[i].latitude],
      getProjection("EPSG:3857")
    );
    // 각 index마다 feature 객체를 생성하여 삽입
    features[i] = new Feature(new Point(coordinates));
  }
  // clustering의 벡터 데이터 소스 생성
  const source = new VectorSource({
    features: features,
  });
  // 클러스터 객체 생성 distance, minDistance로 클러스터 원의 크기 조절
  const clusterSource = new Cluster({
    distance: parseInt("60", 10),
    minDistance: parseInt("30", 10),
    source: source,
  });
  // 위에서 작성한 클러스터 객체데이터를 기반으로 클러스터 layer 생성 
  const styleCache = {};
  clusterObj.value = new VectorLayer({
    source: clusterSource,
    style: function (feature) {
      const size = feature.get("features").length;
      let style = styleCache[size];
      if (!style) {
        style = new Style({
          image: new CircleStyle({
            radius: 12,
            stroke: new Stroke({
              color: "#fff",
            }),
            fill: new Fill({
              color: "#3399CC",
            }),
          }),
          text: new Text({
            text: size.toString(),
            fill: new Fill({
              color: "#fff",
            }),
          }),
        });
        styleCache[size] = style;
      }
      return style;
    },
  });
  // 앞서 언급한 cluster 재생성을 방지하기위해 clusterList에 값을 저장
  clusterList.value.push(clusterObj.value);
  vMap.value.addLayer(clusterObj.value);
};

4. feature 만들기

  • 사용자가 주로 보는 마커(feature) 만들기

  • 참고로 아래 코드는 경로 데이터에 이런식으로 시작과 끝단 데이터를 가져와 마커를 그려주는 코드이다.

const addDronePath = () => {
  // 레이어의 바탕이 될 벡터 레이어 생성
  const baseVector = new VectorLayer({
    source: new VectorSource({
      features: [],
    }),
    style: {
      "stroke-color": "rgba(255, 51, 51, 2)",
      "stroke-width": 4,
    },
  });
  // 드론의 위치(점)들이 들어갈 배열 생성
  const featureLayer = [];
  for (const dronePath of dronePathData.value) {
    const feature = new Feature({
      geometry: new LineString(dronePath[1].pathAry),
    });
    feature.setProperties({
      name: dronePath[1].droneName,
      ownerIdx: dronePath[1].ownerIdx,
      startPoint: dronePath[1].pathAry[0],
    });
    featureLayer.push(feature);
    // 마커의 처음과 끝 아이콘(스타일) 생성
    const endMarkerStyle = new Style({
      image: new Icon({
        anchor: [0.5, 0],
        anchorOrigin: "bottom-right",
        anchorXUnits: "fraction",
        anchorYUnits: "pixels",
        src: "/assets/marker_end.png",
      }),
    });
    const startMarkerStyle = new Style({
      image: new Icon({
        anchor: [0.5, 0],
        anchorOrigin: "bottom-right",
        anchorXUnits: "fraction",
        anchorYUnits: "pixels",
        src: "/assets/marker_start.png",
      }),
    });
    // 경로의 시작점과 끝점 생성
    const startMarkerPoint = new Point(dronePath[1].pathAry[0]);
    const endMarkerPoint = new Point(
      dronePath[1].pathAry[dronePath[1].pathAry.length - 1]
    );
    // 끝점의 마커(feature) 생성
    const endMarkerFeature = new Feature({
      geometry: endMarkerPoint,
    });
    const startMarkerFeature = new Feature({
      geometry: startMarkerPoint,
    });
    // 스타일 적용
    startMarkerFeature.setStyle(startMarkerStyle);
    endMarkerFeature.setStyle(endMarkerStyle);
    featureLayer.push(startMarkerFeature);
    featureLayer.push(endMarkerFeature);
  }
  pathSource.value = featureLayer;
  baseVector.getSource()?.addFeatures(featureLayer);
  pathArray.value.push(baseVector);
  vMap.value.addLayer(baseVector);
};

5. 클릭이벤트에 해당 feature 정보 표출하기

  • 클러스터링 이후 해당 드론 아이콘을 클릭하면 드론1 이라고 정보를 표시하는 div를 만드는 로직을 알아보자.
const mkFlyingDoneDroneIcon = () => {
  // 미리 가져온 드론 데이터를 쫙 돌면서
    for (const drone of flyingDoneDroneData.value) {
      const className = " ";
      // DB에 드론 이름이 들어간게 있고 없는 것도 있어서;; 오류처리
      const name = drone[1].droneName ?? "";
      let overlayContent = "";
      if (drone[1].droneName) {
        overlayContent = `<a style="z-index:999">
          <img src="/assets/marker_ing.png"/>
          <div class="line"/>
          <div class="label">
          ${name}
          </div>
          </a>`;
      } else {
        overlayContent = `<a class= ${className} style="z-index:999">
          <div class="point"/>
          </div>
          </a>`;
      }
      // 여긴 그냥 JS
      let container = document.createElement("div");
      document.body.appendChild(container);
      container.innerHTML = overlayContent;
      // 새로 만든 div el을 overlay에 얹어서
      const overlay = new Overlay({
        element: container,
      });
      // 표시해준다. Overalyer 생성자 함수에 대한건 공홈에 나와있다.
      overlay.setPosition(drone[1].position[0]);
      vMap.value.addOverlay(overlay);
      flyingDoneMarkerList.value.push(overlay);
    }
};

이상으로 학습곡선이 조금 높은 OpenLayers에 대해 알아보았다.
하지만 다른 더 편리한 지도 오픈소스가 있다는 것을 알게되었다.
그래도 학습곡선이 높은 이 오픈소스를 공부하며 굉장히 많이 성장했고
매우 소중한 기회였다.

24.02.22 필수파라미터 추가

오랜만에 들어갔더니 필수 파라미터들이 추가되었다.

복붙이 간절한 미래의 나를 위하여...

geoModifyLayer.value = new TileLayer({
  source: new TileWMS({
    url: "http://api.vworld.kr/req/wms",
    params: {
      SERVICE: "WMS",
      REQUEST: "GetMap",
      VERSION: "1.3.0",
      LAYERS: "lp_pa_cbnd_bonbun,lp_pa_cbnd_bubun",
      STYLES: "lp_pa_cbnd_bonbun_line,lp_pa_cbnd_bubun_line",
      CRS: "EPSG:3857",
      BBOX: "14133818.022824,4520485.8511757,14134123.770937,4520791.5992888",
      WIDTH: '1496',
      HEIGHT: '815',
      FORMAT: "image/png",
      TRANSPARENT: 'false',
      BGCOLOR: '0xFFFFFF',
      EXCEPTIONS: 'text/xml',
      KEY: {본인 KEY 넣으세요.(중괄호 없애야함.)},
      DOMAIN: "http://127.0.0.1:3000",
    },
  }),
});
profile
智(지)! 德(덕)! 體(체)!

0개의 댓글