[シリーズ連載] @googlemaps/react-wrapperで自作マーカーと自作ポリラインを描画する
@googlemaps/react-wrapperでGoogle Map上に自作マーカーを描画する
目次
こんにちは、エンジニアチームのこむたです。
『[シリーズ連載] @googlemaps/react-wrapperで自作マーカーと自作ポリラインを描画する』の第2弾の記事になります。
前回の記事はこちら→https://techlab.q-co.jp/articles/88/
前回は、Google Maps Platformが提供している、ReactでGoogle Mapを表示できる@googlemaps/react-wrapperの解説と開発準備について紹介をして、ReactでGoogle Mapを表示するところまで進めました。
今回の記事では、Google Map上に自作マーカーを描画させる方法を紹介します。
完成形はこちらになります。真ん中に赤いxマークを描画しています。
このマークはGoogle Mapをドラッグしたり、ズームして動かしてもマークも連動して動きます。
環境
- typescript: v5.0.2
- react: v18.2.0
- next: v13.2.4
- @googlemaps/react-wrapper: v1.1.35
自作マーカーを作成
<div>
にスタイルを当てて、自作マーカーを作成します。
今回はわかりやすく赤文字のxをマーカーにします。
// Marker.tsx
import { CSSProperties, FC } from "react";
// マーカーにスタイルを当てる
const wrapperStyle: CSSProperties = {
position: "absolute",
top: 0,
left: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "fit-content",
transform: "translate(-50%, -50%)",
};
// マーカーアイコンのスタイル
const iconStyle: CSSProperties = {
fontSize: "56px",
fontWeight: "bold",
color: "red",
};
export const Marker: FC = () => {
return (
<>
<div style={wrapperStyle}>
<span style={iconStyle}>x</span>
</div>
</>
);
};
google.maps.OverlayViewクラスを宣言する
Maps JavaScript APIではgoogle.maps.OverlayViewクラスを使用して、ポイント、ライン、領域のオブジェクト、またはそれらのオブジェクトの集合を指定してGoogle Map上に描画することができます。これらのオブジェクトはオーバーレイと呼んでいます。
今回の場合は、自作マーカーがオーバーレイとなります。
オーバーレイはGoogle Mapの緯度と経度で表される座標に紐付けて描画することができます。座標に紐付けることでGoogle Mapをズームさせたり、ドラッグして動かすと連動してオーバーレイも動くようになります。
google.maps.OverlayViewクラスを使用する際は、onAdd()
、draw()
、onRemove()
の 3 つのメソッドを実装する必要があります。
これらのメソッドは、google.maps.OverlayViewクラスのsetMap()
の実行をきっかけに自動で実行されます。setMap()
は、自作オーバーレイを描画するGoogle Mapを指定するメソッドです。
オーバーレイを描画する処理の流れをまとめると、下記のようになります。
setMap(map)
でオーバーレイを描画するGoogle Mapを指定する。setMap(map)
が実行されると、onAdd()
が実行されてオーバーレイが作成される。onAdd()
が実行されると、draw()
が実行されてオーバーレイが描画または更新される。setMap(null)
でGoogle Mapの指定が削除されると、onRemove()
が実行されてオーバーレイも削除される。
google.maps.OverlayViewクラスを継承した独自のクラスを作りながら、それぞれのメソッドについて解説していきます。
google.maps.OverlayViewクラスの継承
google.maps.OverlayViewクラスを継承したクラスを作成する関数createOverlay()
を宣言します。
引数には下記の3つを指定します。
- container: HTMLElement
google.maps.OverlayViewクラスを継承したクラスでインスタンス化した子ノードを格納する<div>
を受け取ります。divを取得するので型はHTMLElement
になります。 - pane: keyof google.maps.MapPanes
ペイン(pane)は DOM ツリーのノードを意味しています。ペインを指定することでオブジェクトを描画するレイヤーの順番を指定できます。
google.maps.MapPanesでは、
floatPane > overlayMouseTarget > markerLayer > overlayLayer > mapPane
の順番でオブジェクトを描画するレイヤの位置を選択できます。
例えば、オーバーレイAをpane: "floatPane"
と、オーバーレイBをpane: "overlayMouseTarget"
と指定すれば、オーバーレイAはオーバーレイBの上に描画されます。
正しく選択できるように、型はkey of google.maps.MapPanes
とします。 - position: google.maps.LatLng | google.maps.LatLngLiteral
Google Mapの緯度と経度で表される座標を受け取ります。取得した座標をスタイルに当てることで、オーバーレイの描画位置を決定します。
型はGoogle Mapの緯度と経度に合わせるためgoogle.maps.LatLng
とgoogle.maps.LatLngLiteral
にします。
// Overlay.ts
export const createOverlay = (
container: HTMLElement,
pane: keyof google.maps.MapPanes,
position: google.maps.LatLng | google.maps.LatLngLiteral
) => {
// 処理
}
続いて、google.maps.OverlayViewクラスを継承したクラスを関数内で宣言します。
指定した引数をコンストラクタの初期値になるようにパラメータを設定します。コンストラクタ内で親クラスのgoogle.maps.OverlayViewクラスのメソッドを呼び出すために、キーワードsuper()
も記載します。
super
// Overlay.ts
export const createOverlay = (
container: HTMLElement,
pane: keyof google.maps.MapPanes,
position: google.maps.LatLng | google.maps.LatLngLiteral
) => {
// 追加
class Overlay extends google.maps.OverlayView {
container: HTMLElement;
pane: keyof google.maps.MapPanes;
position: google.maps.LatLng | google.maps.LatLngLiteral;
constructor(
container: HTMLElement,
pane: keyof google.maps.MapPanes,
position: google.maps.LatLng | google.maps.LatLngLiteral
) {
//親クラスのメソッドを使用するために記載
super();
// 初期値を設定
this.container = container;
this.pane = pane;
this.position = position;
}
}
}
続いて、google.maps.OverlayViewクラスに必須の3つのメソッドを実装していきます。
onAdd()を実装する
onAdd()
メソッドは、setMap()
が呼び出された後に1回呼び出されます。オーバーレイのDOM 要素を初期化して、新しくオーバーレイを指定したペインの位置に作成・追加をします。getPanes()
でペインを指定して、appendChild()
でオーバーレイをDOMに追加します。
// Overlay.ts
export const createOverlay = (
// ...省略
this.position = position;
}
// 追加
onAdd(): void {
// ペイン(≒レイヤー)の位置を指定
const pane = this.getPanes()?.[this.pane];
// 指定したペインにDOM要素(<div>)を追加する
pane?.appendChild(this.container);
}
}
}
draw()を実装する
draw()
メソッドは、onAdd()
メソッドで追加したオーバーレイを描画または更新します。このメソッドはonAdd()
の後に呼び出され、Google Mapがズームまたは中心が変化すると呼び出されます。projection.fromLatLngToDivPixel()
でGoogle Map上の緯度と経度を取得して、それらの値をcontainer.style.transform
のtranslate
に当てることで描画する位置を決定します。
// Overlay.ts
export const createOverlay = (
// ...省略
this.position = position;
}
onAdd(): void {
const pane = this.getPanes()?.[this.pane];
pane?.appendChild(this.container);
}
// 追加
draw(): void {
// getProjection()はonAdd()が呼び出されると初期化される。
const projection = this.getProjection();
// fromLatLngToDivPixel()でピクセルからGoogle Map上の経度と緯度を取得する。
const point = projection.fromLatLngToDivPixel(this.position);
// 経度と緯度が取得できなかった場合はretrunを返してエラーを防止します。
if (point === null) {
return;
}
// pointで取得した経度と緯度をスタイルに当てて、オーバーレイの描画位置を決定する。
this.container.style.transform = `translate(${point.x}px, ${point.y}px)`;
}
}
}
onRemove()を実装する
onRemove()
メソッドはオーバーレイをDOMから削除します。このメソッドは、Google Mapを無効にするsetMap(null)
が呼び出された後に1回呼び出されます。Google Mapの更新や非表示の際にオーバーレイも連動して画面から消すために必要です。
container.parentNodeで親ノードを宣言して、親ノードの存在が確認できていればremoveChild()
でオーバーレイをDOMから削除します。
// Overlay.ts
export const createOverlay = (
// ...省略
draw(): void {
const projection = this.getProjection();
const point = projection.fromLatLngToDivPixel(this.position);
if (point === null) {
return;
}
this.container.style.transform = `translate(${point.x}px, ${point.y}px)`;
}
// 追加
onRemove(): void {
// parentNodeが存在していたら実行します。
if (this.container.parentNode !== null) {
// マーカーをDOMから取り除きます。
this.container.parentNode.removeChild(this.container);
}
}
}
}
インスタンスを戻り値に指定する
google.maps.OverlayViewクラスに必須の3つのメソッドを実装できました。
最後に、インスタンスを戻り値に指定します。
// Overlay.ts
export const createOverlay = (
// ...省略
onRemove(): void {
// parentNodeが存在していたら実行します。
if (this.container.parentNode !== null) {
// マーカーをDOMから取り除きます。
this.container.parentNode.removeChild(this.container);
}
}
}
// 追加
// 戻り値にインスタンスを指定します。
return new Overlay(container, pane, position);
}
次は、先程作成した関数createOverlay()
を使用してインスタンス化したコンポーネントを作成します。
インスタンス化したコンポーネントを作成する
インスタンス化したコンポーネントを作成するOverlayView()
を宣言します。
propsには下記4つを指定します。
- position: google.maps.LatLng | google.maps.LatLngLiteral;
オーバーレイの描画位置を受け取ります。
型はcreateOverlay()
と合わせるためにgoogle.maps.LatLng
とgoogle.maps.LatLngLiteral
にします。 - pane?: keyof google.maps.MapPanes
オブジェクトを描画するレイヤーの順番を取得します。今回はオーバーレイであるマーカーを一番上のレイヤーに描画したいので"floatPane"
を初期値にします。
初期値を指定しているのでundefined
を許容します。
型はkey of google.maps.MapPanes
とします。 - map: google.maps.Map
オーバーレイを反映させるGoogle Mapを受け取ります。
型はgoogle.maps.Map
とします。 - children: ReactNode
Google Mapに描画するオーバーレイコンポーネント(今回の場合は自作マーカー)をchildren
として受け取ります。
// OverlayView.tsx
export const OverlayView: FC<{
position: google.maps.LatLng | google.maps.LatLngLiteral;
pane?: keyof google.maps.MapPanes;
map: google.maps.Map;
children: ReactNode;
}> = ({ position, pane = "floatPane", map, children }) => {
// 処理
};
続いて、createOverlay()
に渡す<div>
を作成します。この定数は再生成の必要がないので、依存関係なしでメモ化をしておきます。
// OverlayView.tsx
export const OverlayView: FC<{
// ...省略
// 追加
const container = useMemo(() => document.createElement("div"),[])
};
container
と引数に指定したpane
とposition
を、createOverlay()
に渡してオーバーレイを作成します。オーバーレイは一度作成したら削除されるまでは再生成の必要がないので、メモ化しておきます。
// OverlayView.tsx
export const OverlayView: FC<{
// ...省略
// 追加
// createOverlay()でオーバーレイを作成する。
const overlay = useMemo(() =>
createOverlay(container, pane, position),
[container, pane, position]
);
};
次に、useEffect()
を使ってsetMap()
を発火します。
マウント時には、setMap(map)
を呼び出してオーバーレイを描画するGoogle Mapを指定します。setMap(map)
が呼び出されるとonAdd()
が呼び出され、オーバーレイが作成されます。
アンマウント時にはsetMap(null)
を呼び出してmapを削除します。同時にonRemove()
が呼び出され、オーバーレイも削除されます。
// OverlayView.tsx
export const OverlayView: FC<{
// ...省略
// 追加
useEffect(() => {
// マウント時にオーバーレイを描画したいGoogle Mapを指定する。
overlay?.setMap(map);
// アンマウント時にGoogle Mapの指定を削除する。
return () => overlay?.setMap(null);
}, [map, overlay]);
};
オーバーレイをGoogle Mapの中にレンダーさせるためにcreatePortal(children, domNode, key?)を使用します。createPortal()
を使うとDOM上の別の場所に子要素をレンダーすることができます。第2引数でレンダーする場所を指定できるので、Google Mapと同じ場所のcontainer
を指定します。
Google Mapと同じ場所にレンダーすることで、Google Map内でReactコンポーネントを独立させながら、座標などのGoogle Mapの情報と連動させることができます。
// OverlayView.tsx
export const OverlayView: FC<{
// ...省略
// 追加
// createPortal()を使いGoogle Mapと同じ場所にchildrenをレンダーする。
return createPortal(children, container);
};
画面上のGoogle Mapに自作マーカーを描画させる
上記で宣言した<OverlayView>
に自作マーカーをchildren
として渡して、Google Map上に描画できるオーバーレイにします。
propsには、自作マーカーを描画する場所を示すplace: google.maps.LatLngLiteral
と描画するGoogle Mapを指定するmap?: google.maps.Map
を追加します。後ほど説明しますが、map
はcloneElement()
を使い間接的に渡すためundefined
を許容しておきます。
自作マーカーはGoogle Mapを指定している時だけ描画するので、二項演算子{map && ~}
で記載します。
// Marker.tsx
// ...省略
export const Marker: FC<{
// propsを追加
place: google.maps.LatLngLiteral;
map?: google.maps.Map;
}> = ({ place, map }) => {
return (
<>
{/* 追加 */}
{/* 二項演算子でmapが存在している時だけ自作マーカーを描画する。 */}
{map && (
// 自作マーカーを<OverlayView>に渡してオーバーレイにする。
<OverlayView position={place} map={map}>
<div style={wrapperStyle}>
<span style={iconStyle}>x</span>
</div>
</OverlayView>
)}
</>
);
};
Map.tsx
の<content>
にchildren
を渡せるように調整して、Google Mapと自作マーカーの両方を描画できるようにします。cloneElement()
を使用して、children
にmap
をpropsとして渡します。
// Map.tsx
// ...省略
const Content: FC<{
// 追加
children: ReactNode;
}> = ({ children }) => {
const ref = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<google.maps.Map>();
useEffect(() => {
if (ref.current && !map) {
const option = {
center: DEFAULT.CENTER,
zoom: DEFAULT.ZOOM,
};
setMap(new window.google.maps.Map(ref.current, option));
}
}, []);
return (
<div style={VIEW_STYLE} ref={ref}>
{/* 追加 */}
{cloneElement(children as ReactElement, { map })}
</div>
);
};
<Map>
の中の<content>
に、children
として<Marker>
を渡します。自作マーカーの描画場所は、今回はお試しで描画しているGoogle Mapと同じにしておきます。propsのplace
にはDEFAULT.CENTER
を渡します。
// Map.tsx
// ...省略
const DEFAULT = {
CENTER: {
lat: 35.6973225,
lng: 139.8265658,
} as google.maps.LatLngLiteral,
ZOOM: 16,
} as const;
// ...省略
const Map = () => {
return (
<Wrapper apiKey={MAP_API_KEY}>
{/* 追加 */}
<Content>
<Marker place={DEFAULT.CENTER} />
</Content>
</Wrapper>
);
};
// ...省略
無事にGoogle Mapに自作マーカーが描画されていれば成功です。
完成コード
// Overlay.ts
export const createOverlay = (
container: HTMLElement,
pane: keyof google.maps.MapPanes,
position: google.maps.LatLng | google.maps.LatLngLiteral
) => {
class Overlay extends google.maps.OverlayView {
container: HTMLElement;
pane: keyof google.maps.MapPanes;
position: google.maps.LatLng | google.maps.LatLngLiteral;
constructor(
container: HTMLElement,
pane: keyof google.maps.MapPanes,
position: google.maps.LatLng | google.maps.LatLngLiteral
) {
super();
this.container = container;
this.pane = pane;
this.position = position;
}
onAdd(): void {
const pane = this.getPanes()?.[this.pane];
pane?.appendChild(this.container);
}
draw(): void {
const projection = this.getProjection();
const point = projection.fromLatLngToDivPixel(this.position);
if (point === null) {
return;
}
this.container.style.transform = `translate(${point.x}px, ${point.y}px)`;
}
onRemove(): void {
if (this.container.parentNode !== null) {
this.container.parentNode.removeChild(this.container);
}
}
}
return new Overlay(container, pane, position);
};
// OverlayView.tsx
import { FC, ReactNode, useEffect, useMemo } from "react";
import { createOverlay } from "./Overlay";
import { createPortal } from "react-dom";
export const OverlayView: FC<{
position: google.maps.LatLng | google.maps.LatLngLiteral;
pane?: keyof google.maps.MapPanes;
map: google.maps.Map;
children: ReactNode;
}> = ({ position, pane = "floatPane", map, children }) => {
const container = useMemo(() => document.createElement("div"), []);
const overlay = useMemo(
() => createOverlay(container, pane, position),
[container, pane, position]
);
useEffect(() => {
overlay?.setMap(map);
return () => overlay?.setMap(null);
}, [map, overlay]);
return createPortal(children, container);
};
// Marker.tsx
import { CSSProperties, FC } from "react";
import { OverlayView } from "./OverlayView";
// マーカーにスタイルを当てる
const wrapperStyle: CSSProperties = {
position: "absolute",
top: 0,
left: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
width: "fit-content",
transform: "translate(-50%, -50%)",
};
// マーカーアイコンのスタイル
const iconStyle: CSSProperties = {
fontSize: "56px",
fontWeight: "bold",
color: "red",
};
export const Marker: FC<{
// propsを追加
place: google.maps.LatLngLiteral;
map?: google.maps.Map;
}> = ({ place, map }) => {
return (
<>
{map && (
<OverlayView position={place} map={map}>
<div style={wrapperStyle}>
<span style={iconStyle}>x</span>
</div>
</OverlayView>
)}
</>
);
};
// Map.tsx
import React, {
FC,
ReactElement,
ReactNode,
cloneElement,
useEffect,
useRef,
useState,
} from "react";
import { Wrapper } from "@googlemaps/react-wrapper";
import { Marker } from "./Marker";
const MAP_API_KEY = 'xxxx'; // 前回の記事で生成したAPIキー
// 最初にMapを表示する時の設定
const DEFAULT = {
CENTER: {
lat: 35.6973225,
lng: 139.8265658
} as google.maps.LatLngLiteral,
ZOOM: 16
} as const;
// width指定がないと描画されない。
const VIEW_STYLE = {
width: '100%',
aspectRatio: '16 / 9',
}
const Map: React.FC = () => {
return (
<Wrapper apiKey={MAP_API_KEY}>
<Content>
<Marker place={DEFAULT.CENTER} />
</Content>
</Wrapper>
);
};
const Content: FC<{
children: ReactNode;
}> = ({ children }) => {
const ref = useRef<HTMLDivElement>(null);
const [map, setMap] = useState<google.maps.Map>();
useEffect(() => {
if (ref.current && !map) {
const option = {
center: DEFAULT.CENTER,
zoom: DEFAULT.ZOOM,
};
setMap(new window.google.maps.Map(ref.current, option));
}
}, []);
return (
<div style={VIEW_STYLE} ref={ref}>
{cloneElement(children as ReactElement, { map })}
</div>
);
};
export default Map;
まとめ
今回は自作マーカーをGoogle Map上に描画しました。
次回はラインを描画するAPIのPolyline
を使用して、2点の自作マーカー間にラインを描画します。
- 01 Google Maps Platformと@googlemaps/react-wrapperの準備 >
- 02 @googlemaps/react-wrapperでGoogle Map上に自作マーカーを描画する
- 03 @googlemaps/react-wrapperでGoogle Map上をクリックした2点の間にラインを描画する >
- 04 @googlemaps/react-wrapperでGoogle Map上に五芒星を描画する >