dogandme / experiment

배포 전 이것 저것 실험 해보는 레파지토리입니다. Vercel 을 통해 배포됩니다.
0 stars 1 forks source link

[Feature] 클러스터링 연구실 #18

Open yonghyeun opened 4 weeks ago

yonghyeun commented 4 weeks ago

기존 클러스터링 방법의 문제점

현재 구글 맵스 API 에서 클러스터링을 할 때 js-markerclusters 라이브러리를 이용 할 것을 권장하고 있다.

현재 모든 코드를 이해 한 것은 아니지만 전체적인 작동 방식이 현재의 맵의 zoom 정도를 인수로 받아 인수로 받은 marker 들을 클러스터링 시킨 마커로 변환하여 반환하는 것으로 보인다.

/**
*  supercluster.ts 의 일부 : https://github.com/googlemaps/js-markerclusterer/blob/main/src/algorithms/supercluster.ts
*/
  public calculate(input: AlgorithmInput): AlgorithmOutput {
    let changed = false;
    const state = { zoom: input.map.getZoom() };

    if (!equal(input.markers, this.markers)) {
      changed = true;
      // TODO use proxy to avoid copy?
      this.markers = [...input.markers];

      const points = this.markers.map((marker) => {
        const position = MarkerUtils.getPosition(marker);
        const coordinates = [position.lng(), position.lat()];
        return {
          type: "Feature" as const,
          geometry: {
            type: "Point" as const,
            coordinates,
          },
          properties: { marker },
        };
      });
      this.superCluster.load(points);
    }

    if (!changed) {
      if (this.state.zoom <= this.maxZoom || state.zoom <= this.maxZoom) {
        changed = !equal(this.state, state);
      }
    }

    this.state = state;

    if (changed) {
      this.clusters = this.cluster(input);
    }

    return { clusters: this.clusters, changed };
  }

  public cluster({ map }: AlgorithmInput): Cluster[] {
    return this.superCluster
      .getClusters([-180, -90, 180, 90], Math.round(map.getZoom()))
      .map((feature: ClusterFeature<{ marker: Marker }>) =>
        this.transformCluster(feature)
      );
  }

  protected transformCluster({
    geometry: {
      coordinates: [lng, lat],
    },
    properties,
  }: ClusterFeature<{ marker: Marker }>): Cluster {
    if (properties.cluster) {
      return new Cluster({
        markers: this.superCluster
          .getLeaves(properties.cluster_id, Infinity)
          .map((leaf) => leaf.properties.marker),
        position: { lat, lng },
      });
    }

    const marker = properties.marker;

    return new Cluster({
      markers: [marker],
      position: MarkerUtils.getPosition(marker),
    });
  }

만약 js-markercluster 라이브러리를 사용하면 발생 할 수 있을 것이라 생각되는 문제점은 두 가지이다.

첫 번째론 js-markercluster 로 인해 클러스터링 된 마커의 인터페이스를 우리가 원하는데로 조작하는 것이 불가능해보인다는 점,

두 번째론 클러스터링을 하기 위해선 결국 인수로 들어갈 marker 를 모두 받아와야 한다는 점이다. N 개의 marker 정보를 모두 인수로 받은 후 supercluster 에 존재하는 알고리즘 옵션을 통해 marker -> cluster marker 로 변환하여 렌더링 하는 것인데 이 것이 과연 적절한 방식인가 ? 라는 물음이 든다.

오히려 우리가 원하는 방식은 mapzoom 정도에 따라 서로 다른 api 응답값을 받아 렌더링 하기를 기대하고 있는 것인데 말이다.

yonghyeun commented 4 weeks ago

결국 생각나는 것은 Zoom Level 에 따라서 서버에서 받고자 하는 응답 데이터의 형태가 다르다는 점이다. 슈도 코드로 작성해보면 이런 식의 흐름은 어떨까 ?

image

만약 리액트 쿼리를 쓴다면 ContextQueryClient 가 될 것이다.

리액트 쿼리 프로바이더를 앱 최상단에 두기보다 GoogleMap 내부에만 선언해두면 어떨까 ? 캐싱되는 많은 데이터들을 메모리에 저장해두면 비효율적이라고 생각되기 때문이다.

혹은 어쩌면 캐싱이 꼭 필요할까 ? 싶기도 하다. 그냥 매번 디바운싱으로 현재 뷰포트 + ?? % 정도 되는 영역에 해당하는 Cluster , Marker 에 사용될 json 데이터들을 받으면 어떨까 ?

yonghyeun commented 4 weeks ago

우선 가장 중요한 점은 google.maps.map 의 정보들을 이용해 virtual domreactive 해져야 하기 때문에 maps 이벤트 핸들러를 활용해주도록 하자

google.maps.map 에 부착 할 수 있는이벤트 핸들러들의 타입 선언은 다음과 같다.

export type MapEventProps = Partial<{
    onBoundsChanged: (event: MapCameraChangedEvent) => void;
    onCenterChanged: (event: MapCameraChangedEvent) => void;
    onHeadingChanged: (event: MapCameraChangedEvent) => void;
    onTiltChanged: (event: MapCameraChangedEvent) => void;
    onZoomChanged: (event: MapCameraChangedEvent) => void;
    onCameraChanged: (event: MapCameraChangedEvent) => void;
    onClick: (event: MapMouseEvent) => void;
    onDblclick: (event: MapMouseEvent) => void;
    onContextmenu: (event: MapMouseEvent) => void;
    onMousemove: (event: MapMouseEvent) => void;
    onMouseover: (event: MapMouseEvent) => void;
    onMouseout: (event: MapMouseEvent) => void;
    onDrag: (event: MapEvent) => void;
    onDragend: (event: MapEvent) => void;
    onDragstart: (event: MapEvent) => void;
    onTilesLoaded: (event: MapEvent) => void;
    onIdle: (event: MapEvent) => void;
    onProjectionChanged: (event: MapEvent) => void;
    onIsFractionalZoomEnabledChanged: (event: MapEvent) => void;
    onMapCapabilitiesChanged: (event: MapEvent) => void;
    onMapTypeIdChanged: (event: MapEvent) => void;
    onRenderingTypeChanged: (event: MapEvent) => void;
}>;
/**
 * Sets up effects to bind event-handlers for all event-props in MapEventProps.
 * @internal
 */
export declare function useMapEvents(map: google.maps.Map | null, props: MapEventProps): void;
export type MapEvent<T = unknown> = {
    type: string;
    map: google.maps.Map;
    detail: T;
    stoppable: boolean;
    stop: () => void;
    domEvent?: MouseEvent | TouchEvent | PointerEvent | KeyboardEvent | Event;
};
export type MapMouseEvent = MapEvent<{
    latLng: google.maps.LatLngLiteral | null;
    placeId: string | null;
}>;
export type MapCameraChangedEvent = MapEvent<{
    center: google.maps.LatLngLiteral;
    bounds: google.maps.LatLngBoundsLiteral;
    zoom: number;
    heading: number;
    tilt: number;
}>;

이벤트 객체에서 map 에 접근하여 google.maps.map 에 존재하는 프로퍼티에 접근하여 현재 지도의 zoom ,center 등과 같은 정보를 가져오고 전역 상태로 저장하자

yonghyeun commented 4 weeks ago

image

MapCameraChangedEvent 가 반환하는 이벤트 객체 , detail 에 지도에 관련된 정보들이 존재한다.

'use client';

import { Map, MapCameraChangedEvent } from '@vis.gl/react-google-maps';
import { NEXT_PUBLIC_GOOGLE_MAP_ID } from '@/shared/constant/env';
import { useMapStore } from '../hooks/store';

const MapGround = ({ children }: { children: React.ReactNode }) => {
  const setCenter = useMapStore((state) => state.setCenter);
  const setZoom = useMapStore((state) => state.setZoom);
  const setBounds = useMapStore((state) => state.setBounds);

  return (
    <Map
      mapId={NEXT_PUBLIC_GOOGLE_MAP_ID}
      defaultCenter={{ lat: 37.553441, lng: 126.9696769 }}
      defaultZoom={20}
      onZoomChanged={(camera: MapCameraChangedEvent) => {
        setZoom(camera.detail.zoom);
      }}
      onCameraChanged={(camera: MapCameraChangedEvent) => {
        setCenter(camera.detail.center);
      }}
      onBoundsChanged={(camera: MapCameraChangedEvent) => {
        setBounds(camera.detail.bounds);
      }}
    >
      {children}
    </Map>
  );
};

export default MapGround;

다음과 같이 MapCameraChangedEvent 의 이벤트 핸들러를 이용하여 responsible 한 상태로 사용 할 수 있도록 하자