見出し画像

React.js で Google Maps JavaScript API を使いこなす

はじめに

縁あってか、個人 / 仕事問わず React.js + Google Maps JavaScript API な構成の Web アプリケーションを作る機会が多くありました。

位置情報や移動に関わる Web サービスにとって最早なくてはならない Google Maps JS API という技術ですが、React.js と組み合わせたときに若干のテクニックが要求される場面もあります。

この記事では、これまでの開発の中で得られたノウハウや Tips をできる限りまとめていきたいと思います。今後の位置情報 ・移動アプリケーションの発展の助けになれば幸いです。

サードパーティーのライブラリを「使わない」

まず第一に、サードパーティーのライブラリを使う必要はありません。ググればいくつかの React 向けの Wrapper ライブラリが出てきますが、特段の事情がない限りはネイティブの API で事足ります。

むしろ、安易にライブラリを入れてしまうと細かいパフォーマンスチューニングやスタイルの調整がやりづらくなってしまいます。

具体的な方法は後述していきます。

js-api-loader を使って API をロードする

以前は <script> タグで API キーやら初期化関数やらを指定するアナログな方法しかなく React から扱いづらいことこの上なかったのですが、最近は公式から @googlemaps/js-api-loader というものが提供されています。

こいつと Hooks をよしなに使い、API のロードが完了したら Google マップを内包するコンポーネントを出力するというごく自然なコードが書けるようになりました。

// pages/map.tsx

const [googleMapsApiLoaded, setGoogleMapsApiLoaded] = useState<boolean>(
  false
);
 
const initGoogleMapsApi = useCallback(async () => {
  const loader = new Loader({
    apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY,
    version: 'weekly',
    region: 'JP',
    language: 'ja',
    libraries: ['places']
  });
  await loader.load();
  setGoogleMapsApiLoaded(true);
}, []);

return (
  <>
    {googleMapsApiLoaded && (
      <GoogleMaps>
        <MyOverlays />
      </GoogleMaps>
    )}
  </>
);

Context API を使って Map インスタンスにアクセスする

API をロードしたら次は Map インスタンスの初期化です。

親コンポーネントでインスタンスを初期化したら、Context に保持しておくと良いでしょう。

// components/GoogleMaps.tsx

const [googleMap, setGoogleMap] = useState<google.maps.Map>(null);

const initGoogleMaps = useCallback(() => {
  const map = new google.maps.Map(document.querySelector('#map'), {});
  setGoogleMap(map);
}, []);

useEffect(() => {
  initGoogleMaps();
}, []);

return (
  <>
    <div id="map" className={classes.map} />

    <GoogleMapsContext.Provider
      value={{
        googleMap: googleMap
      }}
    >
      {children}
    </GoogleMapsContext.Provider>
  </>
);
// context/GoogleMapsContext.ts

import { createContext } from 'react';

type Props = {
  googleMap: google.maps.Map;
};

export const GoogleMapsContext = createContext<Props>({
  googleMap: null
});

こうすることで、マーカーなどの子コンポーネントからは useContext を使って現在の Map インスタンスにアクセスできるようになります。

// components/MyMarker.tsx

const { googleMap } = useContext(GoogleMapsContext);

// マーカークリックでマップ移動したりズームしたり
const handleClick = useCallback(async () => {
  googleMap.setCenter({
    lat: latestLocation.coords.latitude,
    lng: latestLocation.coords.longitude
  });
  googleMap.setZoom(17);
}, [googleMap]);

react-div-100vh を使って全画面表示を実現する

地図をサービスの中心に据えたアプリケーションの場合、全画面で Google マップを表示したいことも多いと思います。
height: 100vh とかでいいじゃんと思うところですが、スマホのブラウザではアドレスバー部分の表示 / 非表示が高さの計算に影響を与えたりするため、正確に画面いっぱいに Google マップを表示するには若干のコツがいります。

// pages/map.tsx

// https://mui.com/components/use-media-query/#using-muis-breakpoint-helpers
const theme = useTheme();
const mdUp = useMediaQuery(theme.breakpoints.up('md'));

const height = use100vh();

const wrapperHeight = useMemo(() => {
  // ヘッダー部分の高さを除く
  const marginTop = mdUp ? theme.spacing(16) : theme.spacing(14);

  return height ? height - marginTop : `calc(100vh - ${marginTop}px)`;
}, [height, mdUp]);

return (
  <Box
    position="relative"
    style={{
      height: wrapperHeight
    }}
  >
    {googleMapsApiLoaded && (
      <GoogleMaps>
        <MapOverlays />
      </GoogleMaps>
    )}
  </Box>
);
// components/GoogleMaps.tsx

const styles = {
  map: {
    height: '100%'
  }
};

return (
  <div id="map" style={styles.map} />
);

react-div-100vh というライブラリを使用することで、画面の高さの変動に合わせて、Google マップの表示領域の高さを動的に設定しています。
自分で計算してもいいのですが、若干ややこしいコードになるのでありがたくライブラリを使わせていただきます。

React コンポーネントとしてマーカーを出力する

デフォルトの Marker は、インスタンスを new するときのオプションで Map インスタンスを指定するか、marker.setMap(map) することで出力されます。
Remove するときは maker.setMap(null) です。

このあたりは昔から変わらずなかなかプリミティブな感じなのですが、null を返すだけの React コンポーネントとしてまとめることで、Hooks を使っていい感じにライフサイクルを制御できます。

// components/MyMarker.tsx

const { googleMap } = useContext(GoogleMapsContext);

const createMarker = useCallback(() => {
  const marker = new google.maps.Marker({
    position: currentPosition,
    map: googleMap
  });

  return marker;
}, [currentPosition, googleMap]);

const removeMarker = useCallback((marker: google.maps.Marker) => {
  marker.setMap(null);
}, []);

useEffect(() => {
  if (!googleMap) {
    return;
  }

  const marker = createMarker();

  return () => removeMarker(marker);
}, [googleMap]);

return null;

React コンポーネントの mount / unmount に合わせてマーカーの作成 / 削除を行うことで、通常の React コンポーネントとしてシンプルに取り扱うことができるようになります。
Polygon 等も同様の方法で制御することが可能です。

2021/12/02 追記
書いたあとで発見したけど、公式から似たような方法が紹介されてましたね…↓

https://github.com/googlemaps/react-wrapper は大したことやってないので使わなくてもいいかなぁ。

createPortal でカスタムオーバーレイを作る

デフォルトのマーカーを使ってもある程度のことはできるのですが、細かくイベントを制御したり自由にスタイルをカスタマイズしたりしたくなったときにやはり限界があります。
そういったときにはカスタムオーバーレイを使って自作の要素を Google マップ上に描画します。

ただこの OverlayView クラスが絶望的にプリミティブで扱いづらいので、例によって Hooks を使って Wrap してみます。
試行錯誤を経て下記のような React コンポーネントになりました (長い)。

// components/OverlayView.tsx

import {
  useEffect,
  useCallback,
  useContext,
  ReactNode,
  useMemo,
  memo
} from 'react';
import { createPortal } from 'react-dom';
import { GoogleMapsContext } from '../../context/GoogleMapsContext';

type Props = {
  position: google.maps.LatLng;
  size: number;
  enableRealtimeUpdate?: boolean;
  children: ReactNode;
};

export const OverlayView = memo((props: Props) => {
  const { googleMap } = useContext(GoogleMapsContext);
  const { position, size, enableRealtimeUpdate, children } = props;

  let container = useMemo<HTMLDivElement>(() => {
    return document.createElement('div');
  }, []);

  const overlayView = useMemo<google.maps.OverlayView>(() => {
    return new google.maps.OverlayView();
  }, []);

  const unmount = useCallback(() => {
    overlayView.setMap(null);
  }, [overlayView]);

  const onAdd = useCallback(() => {
    google.maps.OverlayView.preventMapHitsFrom(container);
    container.style.position = 'absolute';
    overlayView.getPanes().overlayMouseTarget.appendChild(container);
  }, [container, overlayView]);

  const draw = useCallback(() => {
    const projection = overlayView.getProjection();
    if (!projection) {
      return;
    }

    const point = projection.fromLatLngToDivPixel(position);
    // 要素の大きさに合わせて、正しい緯度経度に描画されるように調整します
    container.style.left = `${point.x - size}px`;
    container.style.top = `${point.y - size}px`;
  }, [overlayView, container, position, size]);

  const onRemove = useCallback(() => {
    if (container) {
      (container.parentNode as HTMLElement).removeChild(container);
      container = null; // メモリ解放
    }
  }, [container]);

  const initOverlay = useCallback(() => {
    overlayView.onAdd = onAdd.bind(this);
    overlayView.draw = draw.bind(this);
    overlayView.onRemove = onRemove.bind(this);

    overlayView.setMap(googleMap);
  }, [overlayView, googleMap, onAdd, draw, onRemove]);

  useEffect(() => {
    if (position && enableRealtimeUpdate) {
      draw();
      overlayView.draw = draw.bind(this);
    }
  }, [position]);

  useEffect(() => {
    initOverlay();

    return () => unmount();
  }, []);

  return createPortal(children, container);
});

createPortal によって、Google マップの中に独立した React コンポーネントを作り出すことができます。

この独自の OverlayView コンポーネントを使うことで、カーソルを当てたときにオシャレなツールチップを出したり、緯度経度を動的に渡してリアルタイムアップデートを実現したりといった、通常の React コンポーネントのような機能をマーカーに持たせることが可能になります。

最近ハリーポッターの忍びの地図のような Web アプリを作ったのですが、複数のマーカーがうねうねとリアルタイムにブラウザ上で動いている様を眺めるのはなかなか面白いです。

// components/UserMarker.tsx

if (!active || !latLng) {
  return null;
}

return (
  <OverlayView position={latLng} enableRealtimeUpdate>
    <Tooltip title={profile.name}>
      <Fab size="small" className={classes.fab}>
        <Avatar className={classes.avatar}>
          <UserIcon />
        </Avatar>
      </Fab>
    </Tooltip>
  </OverlayView>
);

Recoil で bounds を管理して大量のマーカーを捌く

Google Maps JS API は出力するマーカーが増えれば増えるほど重くなります。
しかし、作成するアプリケーションの要件によっては 1000 件超のマーカーを一つのマップに出力する必要があったりもするでしょう。
そういったシーンでのパフォーマンスチューニングの一つの方法として、ビューポート内に入ったマーカーだけ出力するというテクニックがあります。

// components/GoogleMaps.tsx

const setBounds = useSetRecoilState(boundsState);

const [googleMap, setGoogleMap] = useState<google.maps.Map>(null);

const handleIdle = useCallback(() => {
  // ドラッグやズーム等のユーザー操作が終わったタイミングで bounds を取得
  setBounds(googleMap.getBounds());
}, [googleMap]);

const initGoogleMaps = useCallback(() => {
  const map = new google.maps.Map(document.querySelector('#map'), {});
  setGoogleMap(map);

  // マップ初期化時にも bounds を取得
  setBounds(map.getBounds());
}, []);

useEffect(() => {
  initGoogleMaps();
}, []);

useEffect(() => {
  if (!googleMap) {
    return;
  }

  const idleListener = googleMap.addListener('idle', handleIdle);

  return () => {
    idleListener.remove();
  };
}, [googleMap]);
// recoilStates/index.ts

import { atom, selector } from 'recoil';

function locationInBounds(
  location: google.maps.LatLng,
  bounds: google.maps.LatLngBounds
): boolean {
  if (!location) {
    return false;
  }

  const southWest = bounds.getSouthWest();
  const northEast = bounds.getNorthEast();

    
  // 南西の端と北東の端を境界として、この領域に location が収まっている要素を抽出する
  return (
    location.lat() > southWest.lat() &&
    location.lng() > southWest.lng() &&
    location.lat() < northEast.lat() &&
    location.lng() < northEast.lng()
  );
}

export const boundsState = atom<google.maps.LatLngBounds>({
  key: 'bounds',
  default: null
});

export const spotsState = atom<Spot[]>({
  key: 'spots',
  default: []
});

export const boundsSpotsState = selector<Spot[]>({
  key: 'boundsSpots',
  get: ({ get }) => {
    const spots = get(spotsState);
    const bounds = get(boundsState);

    if (!bounds) {
      return [];
    }

    return spots.filter(spot => locationInBounds(spot.location, bounds));
  }
});

マップの移動が発生したタイミングで getBounds を呼び出して現在のビューポートの境界 (南西の緯度経度と北東の緯度経度) を取得し、Recoil の selector を使ってマーカーの配列に対してリアルタイムにフィルターをかけています。あとはこのフィルターされたマーカーを出力するだけです。
これで全体として 1000 件超のマーカーが存在していたとしても、一度に描画される数は最小限に制御することができます。

クラスタリングで大量のマーカーが出力されるのを防ぐ

Bounds による制御によってある程度マーカーの数を絞り込むことができますが、ズームアウトするとどうしてもビューポート内に大量にマーカーが存在する状態になってしまいます。
そういったシーンではマーカークラスタリングを行うことで、一度に大量にマーカーが出力されるのを防ぐことができます。

React で書くと下記のようなコードになると思います。
一定以上ズームアウトしたときにリージョンレベルのマーカーに切り替えるという極めてシンプルな処理です。

// components/MyOverlays.tsx

const [zoom, setZoom] = useState<number>(15);
const { googleMap } = useContext(GoogleMapsContext);

const handleIdle = useCallback(() => {
  setZoom(googleMap.getZoom());
}, [googleMap]);

useEffect(() => {
  if (!googleMap) {
    return;
  }

  const listener = googleMap.addListener('idle', handleIdle);

  return () => listener.remove();
}, [googleMap]);

return (
  <>
    {zoom > 12 ? (
      <>
        {users.map(user => (
          <UserMarker
            key={user.id}
            user={user}
            onClick={handleUserClick}
          />
        ))}
      </>
    ) : (
      <>
        {regions.map(region => (
          <RegionMarker
            key={region.id}
            region={region}
            onClick={handleRegionClick}
          />
        ))}
      </>
    )}
  </>
);

また、世界を股にかけての移動や描画が発生するようなアプリケーションでなければ、最小ズーム値を設定しておく (一定以上ズームアウトできないようにしておく) のも良いでしょう。

// components/GoogleMaps.tsx

const mapOptions = {
  zoom: 15,
  minZoom: 7
};

const map = new google.maps.Map(document.querySelector('#map'), mapOptions);

独自の Autocomplete フォームを作る

オートコンプリートでプレイスを検索して、プレイスを選択したらその場所にマップを移動するといった UI はよくあるパターンだと思います。
Google Maps JS API では、オートコンプリートを実現する 2 つの手段が用意されています。

  1. 既成のウィジェットを使う

  2. フォームは自前で用意して API で検索結果を取得・表示する

シンプルに機能だけ実現するなら 1 のウィジェットでもよいかもしれませんが、やはり React からの扱いやすさやカスタマイズ性を考えると 2 の自前パターンがよさそうです。

Material-UI (MUI) と Place Autocomplete を組み合わせる場合のサンプルコードがあったので、こちらを参考にさせていただきました。

// hooks/useAutocompleteService.ts

import { useCallback, useEffect, useMemo } from 'react';
import throttle from 'lodash/throttle';

type AutocompleteService = {
  current: google.maps.places.AutocompleteService;
};

const autocompleteService: AutocompleteService = {
  current: null
};

export function useAutocompleteService() {
  const fetch = useMemo(
    () =>
      // lodash/throttle を使うことで、必要以上に API を叩かないように制御しています
      throttle((request, callback) => {
        autocompleteService.current.getPlacePredictions(request, callback);
      }, 200),
    []
  );

  const fetchPlacePredictions = useCallback(
    (inputValue: string, callback) => {
      fetch({ input: inputValue }, results => {
        callback(results);
      });
    },
    [fetch]
  );

  useEffect(() => {
    if (!autocompleteService.current) {
      autocompleteService.current = new google.maps.places.AutocompleteService();
    }
  }, []);

  return {
    fetchPlacePredictions: fetchPlacePredictions
  };
}
// hooks/usePlacesService.ts

import { useCallback, useEffect } from 'react';

type PlacesService = {
  current: google.maps.places.PlacesService;
};

const placesService: PlacesService = {
  current: null
};

export function usePlacesService(googleMap: google.maps.Map) {
  const fetchPlaceDetails = useCallback(
    (placeId: string): Promise<google.maps.places.PlaceResult> => {
      return new Promise((resolve, reject) => {
        if (!placesService.current) {
          reject('Places service is not initialized');
        }

        if (!placeId) {
          reject('Place ID is required');
        }

        const params = {
          placeId: placeId,
          fields: ['name', 'place_id', 'geometry']
        };

        placesService.current.getDetails(params, (place, status) => {
          if (status === google.maps.places.PlacesServiceStatus.OK) {
            resolve(place);
          } else {
            reject();
          }
        });
      });
    },
    [placesService]
  );

  useEffect(() => {
    if (googleMap && !placesService.current) {
      placesService.current = new google.maps.places.PlacesService(googleMap);
    }
  }, [googleMap]);

  return { fetchPlaceDetails };
}
// components/PlaceAutocomplete.tsx

import {
  memo,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState
} from 'react';
import { createStyles, makeStyles, TextField, Theme } from '@material-ui/core';
import { Autocomplete } from '@material-ui/lab';
import { PlaceAutocompleteOption } from './PlaceAutocompleteOption';
import { ArrowDropDown, Close, Search } from '@material-ui/icons';
import { useAutocompleteService } from '../../hooks/useAutocompleteService';
import { GoogleMapsContext } from '../../context/GoogleMapsContext';

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    muiInput: {
      color: theme.palette.secondary.main
    },
    input: {
      paddingLeft: theme.spacing(1),
      paddingRight: theme.spacing(7)
    },
    endAdornment: {
      top: 'auto',
      right: theme.spacing(1)
    }
  })
);

type Props = {
  onChange: Function;
};

const PlaceAutocomplete = memo((props: Props) => {
  const { onChange } = props;
  const classes = useStyles();

  const inputEl = useRef(null);
  const { googleMap } = useContext(GoogleMapsContext);

  const [value, setValue] = useState(null);
  const [inputValue, setInputValue] = useState('');
  const [options, setOptions] = useState([]);

  const { fetchPlacePredictions } = useAutocompleteService();

  const handleChange = useCallback(
    (_event, newValue) => {
      setOptions(newValue ? [newValue, ...options] : options);
      setValue(newValue);
      onChange(newValue);
    },
    [onChange, options]
  );

  const handleInputChange = useCallback((_event, newInputValue) => {
    setInputValue(newInputValue);
  }, []);

  const handleMapClick = useCallback(() => {
    // スマホでソフトウェアキーボードが出しっぱなしになってしまうのを防ぎます
    inputEl.current.blur();
  }, [inputEl]);

  useEffect(() => {
    if (!googleMap) {
      return;
    }
    const listener = googleMap.addListener('click', handleMapClick);

    return () => listener.remove();
  }, [googleMap]);

  useEffect(() => {
    let active = true;

    if (inputValue === '') {
      setOptions(value ? [value] : []);
      return undefined;
    }

    fetchPlacePredictions(inputValue, results => {
      if (active) {
        let newOptions = [];

        if (value) {
          newOptions = [value];
        }

        if (results) {
          newOptions = [...newOptions, ...results];
        }

        setOptions(newOptions);
      }
    });

    return () => {
      active = false;
    };
  }, [value, inputValue]);

  return (
    <Autocomplete
      id="place-autocomplete"
      getOptionLabel={option =>
        typeof option === 'string' ? option : option.description
      }
      filterOptions={x => x}
      options={options}
      autoComplete
      includeInputInList
      filterSelectedOptions
      blurOnSelect
      value={value}
      onChange={handleChange}
      onInputChange={handleInputChange}
      closeIcon={<Close color="secondary" />}
      popupIcon={<ArrowDropDown color="secondary" />}
      renderInput={params => (
        <TextField
          {...params}
          fullWidth
          variant="outlined"
          InputProps={{
            ...params.InputProps,
            margin: 'none',
            startAdornment: <Search />,
            className: classes.muiInput,
            inputRef: inputEl
          }}
          inputProps={{
            ...params.inputProps,
            className: classes.input
          }}
        />
      )}
      renderOption={option => {
        return <PlaceAutocompleteOption option={option} />;
      }}
      classes={{
        endAdornment: classes.endAdornment
      }}
    />
  );
});
// components/PlaceAutocompleteOption.tsx

import { memo, useMemo } from 'react';
import parse from 'autosuggest-highlight/parse';
import {
  createStyles,
  Grid,
  makeStyles,
  Theme,
  Typography
} from '@material-ui/core';
import { LocationOn } from '@material-ui/icons';

type Props = {
  option: google.maps.places.AutocompletePrediction;
};

const useStyles = makeStyles((theme: Theme) =>
  createStyles({
    icon: {
      color: theme.palette.text.secondary,
      marginRight: theme.spacing(2)
    }
  })
);

export const PlaceAutocompleteOption = memo((props: Props) => {
  const { option } = props;

  const classes = useStyles();

  const matches = useMemo(() => {
    return option.structured_formatting.main_text_matched_substrings;
  }, [option]);

  const parts = useMemo(() => {
    return matches
      ? parse(
          option.structured_formatting.main_text,
          matches.map(match => [match.offset, match.offset + match.length])
        )
      : [];
  }, [option, matches]);

  return (
    <Grid container alignItems="center">
      <Grid item>
        <LocationOn className={classes.icon} />
      </Grid>
      <Grid item xs>
        {parts.map((part, index) => (
         // 入力文字列に一致するテキストをハイライトします
          <span key={index} style={{ fontWeight: part.highlight ? 700 : 400 }}>
            {part.text}
          </span>
        ))}

        <Typography variant="body2" color="textSecondary">
          {option.structured_formatting.secondary_text}
        </Typography>
      </Grid>
    </Grid>
  );
});
// components/MyOverlays.tsx

const { googleMap } = useContext(GoogleMapsContext);
const { fetchPlaceDetails } = usePlacesService(googleMap);

const handlePlaceSelected = useCallback(
  async (place: google.maps.places.AutocompletePrediction) => {
    if (!place) {
      return;
    }

    const placeDetails = await fetchPlaceDetails(place.place_id);

    if (
      !placeDetails ||
      !placeDetails.geometry ||
      !placeDetails.geometry.location ||
      !googleMap
    ) {
      return;
    }

    // プレイスが選択されたらプレイスの位置に移動 & ズームします
    googleMap.setCenter(placeDetails.geometry.location);
    googleMap.setZoom(20);
  },
  [fetchPlaceDetails, googleMap]
);

return (
  <>
    <PlaceAutocomplete onChange={handlePlaceSelected} />
  </>
);

最近は Session による課金方式も導入されました。アプリケーションの要件によってはこちらの方式を採用しても良いかもしれません。

おわりに

長くなってしまいましたが、以上になります。
現実世界を拡張する地図アプリケーションは、作るのも使うのも大変面白いです。
メタバースが叫ばれる昨今ですが、こういった位置情報・移動に関わるソフトウェア技術が、仮想世界と現実世界をつなぐ架け橋になっていけばいいなぁと思っています。


この記事が気に入ったらサポートをしてみませんか?