Vue3 + js-api-loader で地図上にオブジェクトを描写する

## はじめに

これは半分、備忘録とすることを目的とした記事です。

何かしらの住所をマップで表示させるなどの機能を実現させるために、GoogleMap 上にマーカーやその他図形を描写する機能を作成する機会は多くあります。

しかし、描写処理を描く場合、機能が多くなればなるほど複雑になりがちなため、どう書けば読みやすくなるのかを、現時点での出来る限りを尽くして考えてみることにしました。
観点は、関心ごと単位での処理のカプセル化（隠蔽化）です。

## セットアップ

まず、 js-api-loader をインストールします。

```
yarn add @googlemaps/js-api-loader
yarn add --dev @types/google.maps
```

続いて maps ライブラリーをロードして google map を操作できるようにします。

```javascript
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 パッケージを使用する](https://developers.google.com/maps/documentation/javascript/load-maps-js-api?hl=ja) を参考にすると上記のようになりますが、 `loader.load` メソッドは非推奨となっているようでした。

NPM の Readme に書かれているように `loader.importLibrary` を使用するのが良いようです。

```javascript
// Promise for a specific library
loader
  .importLibrary('maps')
  .then(({ Map }) => {
    new Map(document.getElementById("map"), { ... });
  })
  .catch((e) => {
    // do something
  });
```

https://www.npmjs.com/package/@googlemaps/js-api-loader

ここからは実際に組み込んでいきます。

## Map Component

Map 上にオブジェクトを描写する Component を作成します。
出来上がりイメージは次のようになります。

![スクリーンショット 2024-02-21 16.48.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333070/685f21cf-c1da-b56b-c80b-c9e8581def2e.png)


google maps の操作は全て Composable にカプセル化し、当該 Component では以下のみを関心ごととします。
- Composableの各処理の呼び出し
- 座標データの管理
- 地図の中心座標の作成

地図の中心座標は以下のように指定できるようにします。
- Pops で特定座標を指定する（最優先）
- 地図に描写するオブジェクトの座標の中心にする

```vuejs:Map.vue
<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 に追加可能な図形はひとつのみとします。

```typescript:googleMaps.ts
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 を表示するようにします。

```typescript:googleMaps.ts
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の更新を監視して多角形を描写し直すようにします。

```typescript:googleMaps.ts
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 が更新されるようにします。
また、この際、完成後図形のチェック処理を行っておきます。

```typescript:googleMaps.ts
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,
  }
}
```

## ポリゴン判定処理

指定した座標（頂点）が多角形を形成できるパターンになっているかチェックします。
チェック内容は以下です。
- 頂点の数が３以上であること
- 隣り合う頂点を結んでできる線分が互いに交差していないこと

線分の交差判定は以下の記事を参考にさせて頂きました。

https://qiita.com/ykob/items/ab7f30c43a0ed52d16f2

```typescript
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)
}
```

## 最後に
現時点では、このような形にまとまりました。
もちろん、これが最適な形とも、完成系とも考えていません。

次に書くときには、もっと良い形を思い付いているかもしれません。
この内容がその際の足がかりになれば良いなと思い書き残すことにします。（忘れっぽいので）

## 参考情報

https://www.npmjs.com/package/@googlemaps/js-api-loader

https://qiita.com/ykob/items/ab7f30c43a0ed52d16f2