はじめに
これは半分、備忘録とすることを目的とした記事です。
何かしらの住所をマップで表示させるなどの機能を実現させるために、GoogleMap 上にマーカーやその他図形を描写する機能を作成する機会は多くあります。
しかし、描写処理を描く場合、機能が多くなればなるほど複雑になりがちなため、どう書けば読みやすくなるのかを、現時点での出来る限りを尽くして考えてみることにしました。
観点は、関心ごと単位での処理のカプセル化(隠蔽化)です。
セットアップ
まず、 js-api-loader をインストールします。
yarn add @googlemaps/js-api-loader
yarn add --dev @types/google.maps
続いて maps ライブラリーをロードして google map を操作できるようにします。
import { Loader } from "@googlemaps/js-api-loader";
const loader = new Loader({
apiKey: "YOUR_API_KEY",
version: "weekly",
...additionalOptions,
});
loader.load().then(async () => {
const { Map } = await google.maps.importLibrary("maps") as google.maps.MapsLibrary;
map = new Map(document.getElementById("map"), { ... });
});
Google Maps Platform の NPM js-api-loader パッケージを使用する を参考にすると上記のようになりますが、 loader.load
メソッドは非推奨となっているようでした。
NPM の Readme に書かれているように loader.importLibrary
を使用するのが良いようです。
// Promise for a specific library
loader
.importLibrary('maps')
.then(({ Map }) => {
new Map(document.getElementById("map"), { ... });
})
.catch((e) => {
// do something
});
ここからは実際に組み込んでいきます。
Map Component
Map 上にオブジェクトを描写する Component を作成します。
出来上がりイメージは次のようになります。
google maps の操作は全て Composable にカプセル化し、当該 Component では以下のみを関心ごととします。
- Composableの各処理の呼び出し
- 座標データの管理
- 地図の中心座標の作成
地図の中心座標は以下のように指定できるようにします。
- Pops で特定座標を指定する(最優先)
- 地図に描写するオブジェクトの座標の中心にする
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useGoogleMap } from './googleMaps'
import type { Coordinate, Options } from './googleMaps'
type Props = Partial<Options> & {
modelValue?: Coordinate[]
radius?: number
label?: string
}
type Emit = {
(event: 'update:modelValue', value: Props['modelValue']): void
}
const props = withDefaults(defineProps<Props>(), {
shape: 'point',
gestureHandling: 'cooperative',
zoom: 15,
zoomControl: true,
multiple: false,
readonly: false,
})
const emits = defineEmits<Emit>()
const getCenter = () => {
const { modelValue, center } = props
if (center) {
return center
}
if (modelValue && modelValue.length > 0) {
const lat =
modelValue.reduce((acc, { lat }) => acc + lat, 0) / modelValue.length
const lng =
modelValue.reduce((acc, { lng }) => acc + lng, 0) / modelValue.length
return {
lat,
lng,
}
}
return undefined
}
const { coordinates, initMap, drawObjects, setRadiusForAdd, setLabelForAdd } =
useGoogleMap({
...props,
center: getCenter(),
})
const googleMapRootRef = ref<HTMLElement>()
watch(coordinates, (v) => {
emits(
'update:modelValue',
v.map(({ lat, lng, label, radius }) => ({
lat,
lng,
label,
radius,
}))
)
})
watch(
() => props.radius,
(v) => setRadiusForAdd(v)
)
watch(
() => props.label,
(v) => setLabelForAdd(v)
)
watch(
() => props.modelValue,
(v) => drawObjects(v)
)
watch(googleMapRootRef, async () => {
if (!googleMapRootRef.value) return
await initMap(googleMapRootRef.value)
setRadiusForAdd(props.radius)
setLabelForAdd(props.label)
drawObjects(props.modelValue)
})
</script>
<template>
<div ref="googleMapRootRef" class="h-full w-full"></div>
</template>
Google Map 操作 Composable
Map 生成 hooks
まず、Mapを生成する処理を作成します。
Map上の任意の地点をクリックすることで描写オブジェクトを追加できるようにします。
オブジェクトを描写する処理自体は別のhooksにカプセル化しておきます。
描写するオブジェクトの座標データには Well-known text の内、 Point と MultiPoint, Polygon 形式を想定します。
ただし、ここでは wkt の原文ではなく { lat: number, lng: number, ... }[]
の形式にパースされた coordinates
部分のデータを扱うものとします。
-
Point, MultiPoint のデータを描写する場合
shapeにpoint
が設定される想定とします。この場合、マーカーを追加します。
オプションのmultipleが指定されている場合は、複数追加可能とし、それ以外の場合では、マーカー追加前に定義済みのマーカーを全て削除することで常にひとつのみが描写されるようにします。
マーカーにはラベルとサークルを描写可能とします。これらは、それぞれ labelForAdd 、 radiusForAdd に値が設定されているのみ描写されるようにします。 -
Polygon のデータを描写する場合
shapeにpolygon
が設定される想定とします。この場合、座標を頂点とする図形を追加します。
ひとつの Map に追加可能な図形はひとつのみとします。
import { Loader } from '@googlemaps/js-api-loader'
import { ref, toRaw, watch } from 'vue'
import type { Ref } from 'vue'
export type Coordinate = {
lat: number
lng: number
radius?: number
label?: string
}
type CoordinateItem = Coordinate & { key: string }
type MapOptions = {
center?: {
lat: number
lng: number
}
zoomControl: boolean
zoom: number
gestureHandling: 'cooperative' | 'greedy' | 'none' | 'auto'
}
export type Options = MapOptions & {
shape: string
multiple: boolean
readonly: boolean
}
// 地図中心のデフォルト座標を指定する
const DEFAULT_CENTER = { lat: ..., lng: ... }
const loader = new Loader({
apiKey: 'YOUR_API_KEY',
version: 'weekly',
libraries: ['places'],
})
export const useGoogleMap = (options: Options) => {
const radiusForAdd = ref<number>()
const labelForAdd = ref<string>()
const coordinates = ref<CoordinateItem[]>([])
const map = ref<google.maps.Map>()
const infoWindow = ref<google.maps.InfoWindow>()
const createKey = () => {
// 省略: map単位で一意になるキーを作成する
}
const {
updateEditingLineSegment,
drawNextEditingLineSegment,
initEditingLineSegment,
addPolygon,
} = usePolygon(map, coordinates, options.readonly, createKey)
const { addPoint, resetPoint } = usePoint(
map,
infoWindow,
coordinates,
options.readonly,
options.multiple
)
const drawObjects = (positions: Coordinate[] = []) => {
if (!map.value) return
if (options.shape === 'polygon') {
const latLngs = positions.map(
({ lat, lng }) => new google.maps.LatLng(lat, lng)
)
addPolygon(createKey(), latLngs)
initEditingLineSegment()
return
}
if (options.shape === 'point') {
resetPoint()
positions.forEach(({ lat, lng, radius, label }) => {
const latLng = new google.maps.LatLng(lat, lng)
addPoint(createKey(), latLng, radius, label)
})
return
}
}
const initMap = async (mapDom: HTMLElement) => {
await loader.importLibrary('maps')
const center = options.center
? new google.maps.LatLng(options.center.lat, options.center.lng)
: DEFAULT_CENTER
map.value = new google.maps.Map(mapDom, {
center,
disableDefaultUI: true,
clickableIcons: false,
zoomControl: options.zoomControl,
zoom: options.zoom,
gestureHandling: options.gestureHandling,
})
infoWindow.value = new google.maps.InfoWindow({
content: '',
disableAutoPan: true,
})
if (!options.readonly) {
map.value.addListener('click', (event: google.maps.MapMouseEvent) => {
if (!event.latLng) return
if (options.shape === 'polygon') {
updateEditingLineSegment(event.latLng)
return
}
if (options.shape === 'point') {
addPoint(
createKey(),
event.latLng,
radiusForAdd.value,
labelForAdd.value
)
return
}
})
map.value.addListener('mousemove', (event: google.maps.MapMouseEvent) => {
if (options.shape !== 'polygon') return
if (!event.latLng) return
drawNextEditingLineSegment(event.latLng)
})
}
}
const setRadiusForAdd = (v?: number) => {
radiusForAdd.value = v
}
const setLabelForAdd = (v?: string) => {
labelForAdd.value = v
}
return {
coordinates,
initMap,
drawObjects,
setRadiusForAdd,
setLabelForAdd,
}
}
続いて上記で使用しているオブジェクトの描写hooksを作成していきます。
Marker 描写 hooks
指定座標にマーカーとマーカーに追従するサークルを描写する処理を作成します。
マーカーはクリックで削除、ドラッグで座標の変更ができるようにします。
また、マーカーは mouseover でラベルが設定された infoWindow を表示するようにします。
const usePoint = (
map: Ref<google.maps.Map | undefined>,
infoWindow: Ref<google.maps.InfoWindow | undefined>,
coordinates: Ref<CoordinateItem[]>,
readonly: boolean,
multiple: boolean
) => {
const points = ref<Record<string, google.maps.Marker>>({})
const createMarker = (position: google.maps.LatLng) => {
return new google.maps.Marker({
position,
draggable: !readonly,
map: map.value,
})
}
const createCircle = (center: google.maps.LatLng, radius: number) => {
return new google.maps.Circle({
center,
radius,
strokeColor: '#FF0000',
fillColor: '#FF0000',
fillOpacity: 0.5,
map: map.value,
})
}
const removePoint = (key: string) => {
toRaw(points.value)[key].setPosition(null)
delete points.value[key]
coordinates.value = coordinates.value.filter((e) => e.key !== key)
}
const removeAllPoint = () => {
Object.keys(toRaw(points.value)).forEach((key) => {
removePoint(key)
})
}
const updatePoint = (key: string, latLng: google.maps.LatLng) => {
const target = coordinates.value.find((e) => e.key === key)
if (!target) return
target.lat = latLng.lat()
target.lng = latLng.lng()
}
const addPoint = (
key: string,
latLng: google.maps.LatLng,
radius: number | undefined,
label: string | undefined
) => {
if (!multiple) {
removeAllPoint()
}
const marker = createMarker(latLng)
if (!readonly) {
marker.addListener('click', () => {
removePoint(key)
})
marker.addListener('dragend', (e: google.maps.MapMouseEvent) => {
if (!e.latLng) return
updatePoint(key, e.latLng)
})
}
marker.addListener('mouseover', () => {
if (!label) return
infoWindow.value?.setContent(label)
infoWindow.value?.open(map.value, marker)
})
if (radius !== undefined) {
const circle = createCircle(latLng, radius)
circle.bindTo('center', marker, 'position')
}
points.value[key] = marker
coordinates.value.push({
lat: latLng.lat(),
lng: latLng.lng(),
radius,
key,
})
}
const resetPoint = () => {
points.value = {}
coordinates.value = []
}
return {
addPoint,
resetPoint,
}
}
Polygon 描写 hooks
指定の座標からなる多角形を描写する処理を作成します。
この多角形はクリックすることで削除されるようにします。
また、Polygonの追加を行う際には編集中図形のグラフィックを描写するようにします。
この編集中図形のデータ管理(fixedCoordinates)と編集中図形の描写処理は、更に別のhooksにカプセル化します。
fixedCoordinatesの更新を監視して多角形を描写し直すようにします。
const usePolygon = (
map: Ref<google.maps.Map | undefined>,
coordinates: Ref<CoordinateItem[]>,
readonly: boolean,
createKey: () => string
) => {
const addPolygon = (key: string, latLngs: google.maps.LatLng[]) => {
const polygon = new google.maps.Polygon({
paths: latLngs,
strokeColor: '#FF0000',
fillColor: '#FF0000',
map: map.value,
})
if (!readonly) {
polygon.addListener('click', () => {
polygon?.setPaths([])
coordinates.value = []
})
}
latLngs.forEach((latLng) => {
coordinates.value.push({
lat: latLng.lat(),
lng: latLng.lng(),
radius: undefined,
key,
})
})
}
const {
fixedCoordinates,
updateEditingLineSegment,
drawNextEditingLineSegment,
initEditingLineSegment,
} = usePolygonEditGraphics(map)
watch(fixedCoordinates, (v) => {
if (v) {
addPolygon(createKey(), v)
}
})
return {
fixedCoordinates,
updateEditingLineSegment,
drawNextEditingLineSegment,
initEditingLineSegment,
addPolygon,
}
}
編集中 Polygon 描写 hooks
編集中図形管理用の処理を作成します。
編集中のグラフィクスとして、頂点と、隣り合う頂点を結んだ線分を描写します。
最後に追加した頂点と指定の座標を結んだ線分を描写する処理を定義します。
こちらは Map の mouseover イベントで使用することによって、次に追加される頂点と線分が可視化されるようになることを想定します。
最初に追加された頂点をクリックすることで図形が完成するものとします。
図形が完成した場合に、 fixedCoordinates が更新されるようにします。
また、この際、完成後図形のチェック処理を行っておきます。
const usePolygonEditGraphics = (map: Ref<google.maps.Map | undefined>) => {
const fixedCoordinates = ref<google.maps.LatLng[]>()
const editingApexes = ref<google.maps.Marker[]>([])
const editingLineSegment = ref<google.maps.Polyline>()
const nextEditingLineSegment = ref<google.maps.Polyline>()
const fixCoordinates = () => {
if (!editingLineSegment.value) return
const latLngs = editingLineSegment.value.getPath().getArray()
if (!isPolygonValid(latLngs)) return
fixedCoordinates.value = latLngs
editingLineSegment.value?.setPath([])
nextEditingLineSegment.value?.setPath([])
toRaw(editingApexes.value).forEach((marker) => {
marker.setMap(null)
})
editingApexes.value = []
}
const removeEditingLineSegment = (latLng: google.maps.LatLng) => {
if (!editingLineSegment.value) return
const polylinePath = editingLineSegment.value
.getPath()
.getArray()
.filter(
(position) =>
position.lat() !== latLng.lat() || position.lng() !== latLng.lng()
)
editingLineSegment.value.setPath(polylinePath)
}
const removeEditingApex = (latLng: google.maps.LatLng) => {
if (!editingLineSegment.value) return
toRaw(editingApexes.value).forEach((apex) => {
const position = apex.getPosition()
if (
position?.lat() === latLng.lat() &&
position?.lng() === latLng.lng()
) {
apex.setMap(null)
}
})
}
const createEditingApex = (latLng: google.maps.LatLng) => {
const apex = new google.maps.Marker({
position: latLng,
icon: {
path: 'M-4,0a4,4 0 1,0 8,0a4,4 0 1,0 -8,0',
strokeWeight: 0,
fillColor: '#FF0000',
fillOpacity: 1,
},
map: map.value,
})
if (editingApexes.value.length === 0) {
apex.addListener('click', () => {
fixCoordinates()
})
} else {
apex.addListener(
'click',
(event: google.maps.MapMouseEvent) => {
if (!event.latLng) return
removeEditingLineSegment(event.latLng)
removeEditingApex(event.latLng)
}
)
}
return apex
}
const updateEditingLineSegment = (latLng: google.maps.LatLng) => {
if (!editingLineSegment.value) return
fixedCoordinates.value = undefined
const path = editingLineSegment.value.getPath().getArray()
path.push(latLng)
editingLineSegment.value?.setPath(path)
const apex = createEditingApex(latLng)
editingApexes.value.push(apex)
}
const drawNextEditingLineSegment = (latLng: google.maps.LatLng) => {
if (!editingLineSegment.value) return
const prevPolyLines = editingLineSegment.value.getPath()
if (prevPolyLines.getLength() === 0) return
nextEditingLineSegment.value?.setPath([
prevPolyLines.getAt(prevPolyLines.getLength() - 1),
latLng,
])
}
const initEditingLineSegment = () => {
editingLineSegment.value = new google.maps.Polyline({
path: [],
clickable: false,
strokeColor: '#FF0000',
map: map.value,
})
nextEditingLineSegment.value = new google.maps.Polyline({
path: [],
clickable: false,
strokeColor: '#FF0000',
strokeOpacity: 0.5,
map: map.value,
})
}
return {
fixedCoordinates,
updateEditingLineSegment,
drawNextEditingLineSegment,
initEditingLineSegment,
}
}
ポリゴン判定処理
指定した座標(頂点)が多角形を形成できるパターンになっているかチェックします。
チェック内容は以下です。
- 頂点の数が3以上であること
- 隣り合う頂点を結んでできる線分が互いに交差していないこと
線分の交差判定は以下の記事を参考にさせて頂きました。
function areIntersected(
p1: google.maps.LatLng,
p2: google.maps.LatLng,
q1: google.maps.LatLng,
q2: google.maps.LatLng
): boolean {
const ta =
(q1.lng() - q2.lng()) * (p1.lat() - q1.lat()) +
(q1.lat() - q2.lat()) * (q1.lng() - p1.lng())
const tb =
(q1.lng() - q2.lng()) * (p2.lat() - q1.lat()) +
(q1.lat() - q2.lat()) * (q1.lng() - p2.lng())
const tc =
(p1.lng() - p2.lng()) * (q1.lat() - p1.lat()) +
(p1.lat() - p2.lat()) * (p1.lng() - q1.lng())
const td =
(p1.lng() - p2.lng()) * (q2.lat() - p1.lat()) +
(p1.lat() - p2.lat()) * (p1.lng() - q2.lng())
return tc * td < 0 && ta * tb < 0
}
const hasThreePointsOrMore = (path: google.maps.LatLng[]): boolean => {
return path.length >= 3
}
const hasNoSelfIntersections = (path: google.maps.LatLng[]): boolean => {
for (let i = 0; i < path.length - 1; i++) {
const line1Start = path[i]
const line1End = path[i + 1]
for (let j = i + 1; j < path.length - 1; j++) {
const line2Start = path[j]
const line2End = path[j + 1]
if (areIntersected(line1Start, line1End, line2Start, line2End)) {
return false
}
}
}
return true
}
export const isPolygonValid = (path: google.maps.LatLng[]): boolean => {
return hasThreePointsOrMore(path) && hasNoSelfIntersections(path)
}
最後に
現時点では、このような形にまとまりました。
もちろん、これが最適な形とも、完成系とも考えていません。
次に書くときには、もっと良い形を思い付いているかもしれません。
この内容がその際の足がかりになれば良いなと思い書き残すことにします。(忘れっぽいので)
参考情報