kintone × MapLibre で旅の想い出を可視化する

この記事は [kintone アドベントカレンダー 2024](https://qiita.com/advent-calendar/2024/kintone) の 7 日目の記事です。

## 前置き

筆者の長年の趣味として、バイクであちこち自由気ままに旅をすると言うものがあります。
このところはすっかりソロツー派で、多くは日帰りで県内あるいは隣県に、時には 1 泊 2 日でちょっと遠めの土地にキャンプツーリングに、そして 1,2 年に 1 回くらいの頻度で長いお休みを取って 1 週間以上に渡るロングツーリングを敢行したりもしています。
今年の秋には遅い夏休みをくっつけて 10 連休を取り、名古屋までフェリーで移動しそこから主に海沿いの各都県を通過して帰郷すると言う自分史上最大のツーリングを実施しました。
（大雨の影響で 1 日旅程を切り上げなきゃいけませんでしたが）

ツーリングの際には記録をガッツリ残すと言う事を重視していて、

- ハンドル左側にマウントした GoPro で **映像を記録**
- ハンドル右側にマウントした iPhone （本来はナビ用途）のボイスレコーダーアプリ（自作）で **音声を記録**
- タンクバッグに格納した iPad mini の **[ZweiteGPS](https://apps.apple.com/jp/app/zweitegps/id635080232)** と言うアプリで **走行経路を記録**

https://apps.apple.com/jp/app/zweitegps/id635080232

といった要領でいろんな情報を残しています。
それに加え、立ち寄った場所では iPhone で **写真をバッシバッシ撮ったりしている** わけで、旅の想い出はたまっていく一方です。

![各種情報記録システム](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/790e664d-0eb1-5065-f310-bc5108f1c2a9.jpeg)

## 想い出の振り返り方に困る

旅のたびにいろんなデータが増えていくわけですが、これらはそれぞれバラバラに存在しているわけです。
走行経路は ZweiteGPS アプリ上で見る事になるし、撮影した写真は「写真」アプリのライブラリを見なきゃいけない。
それぞれに位置情報があるけれども、 **これらのデータは特段連動はしていない** わけです。
映像や音声はともかく、走行経路の記録とその道中どこそこでこんな写真を撮った、と言うのは **同じ画面で見られた方が想い出の記録としては有用** ですよね。

## よろしい、ならば kintone だ

と言うわけで、こう言う個々にバラバラな情報をガツンとつなげてシナジーを生み出すのは kintone の得意とするところ。
今回は上述通り **走行経路データと写真データを地図上にマッピングして 1 画面で表示できる** ようなものを作ろうと考えました。

## 実装方針を考える

### 地図ライブラリの選定

一口に地図を表示すると言ってもいろいろ方法があります。
一般的に考えられるのは [Google Maps API](https://developers.google.com/maps?hl=ja) あたりを使う事でしょうが、世の中には他にもいくつか地図ライブラリが存在する。
有名どころだと [Leaflet](https://leafletjs.com/) や [OpenLayers](https://openlayers.org/) などがあると思いますが、今回は近頃界隈で最もアツいと評判の [MapLibre](https://maplibre.org/) を使用してみたいと思います。

https://maplibre.org/

### 走行経路データの取得

iPad で使用している **ZweiteGPS** と言うロガーアプリは軽量で安定感があり長く愛用させて頂いています。
このアプリでは **記録した経路データをフォーマット変換したうえでエクスポートする** 機能があり、GeoJSON、KML、GPX などの形式で出力可能です。
kintone 勢にとっては GeoJSON を採用したいところですが、生憎と GeoJSON はデータフォーマット的に日時を持つ事ができず、位置情報はバッチリでも何月何日の何時何分にその位置にいたかを持つ事ができません。
そこで今回は GPS ロガー界隈で古くから用いられデファクトスタンダードとなっている **GPX フォーマット** を採用する事にしました。
記録の仕方として、朝にお宿をスタートしてから夜その日のお宿にゴールするまでロガーアプリはずっと起動しっぱなし（道中どこかに立ち寄る際は一時停止しておく）と言うスタイルであるため、1 つの GPX ファイルには 3,000〜5,000 くらいの地点データが含まれます。
これを kintone 側で取り込むと考えた場合、サブテーブル 1 行に 1 地点データと言う形で列挙するのは非現実的です。
そこで今回は添付ファイルフィールドに添付された GPX ファイルを都度読み込みパースして表示する仕様にしておきます。

### 写真の撮影場所の特定

これは読者の皆さんも容易に想像がつくと思いますが、スマホで撮影した写真には撮影日時、場所、カメラの機種やレンズの種類など様々な情報が **EXIF データ** として格納されます。
この EXIF データの中から **位置情報** と **撮影日時** を取り出し、地図上にマッピングしてやれれば目的を果たせそうです。
JavaScript で EXIF データを取り扱う [EXIF.js](https://github.com/exif-js/exif-js) と言うライブラリがあるので、これを使用する事にします。
kintone × EXIF の組合せに関しては以下のような記事もありました。

https://qiita.com/yukataoka/items/3717c1de63e7fcb5f121

### kintone アプリの構成

それほど複雑な形にはしないでおきたいです。

- 旅のタイトル（文字列 1 行フィールド）
- 記録日（日付フィールド）
- GPX ファイルデータ（添付ファイルフィールド）
- 写真データを管理するテーブル
  - 写真画像（添付ファイルフィールド）
  - 写真のコメント（文字列 1 行フィールド）
- 地図を描画するためのコンテナ（スペースフィールド）

くらいあればそれなりのものはできそうです。
写真に関しては画像と共に簡単なコメントも表示できると良いなと思ったのでテーブルにしましたが、1 つの添付ファイルフィールドに GPX ファイルも画像もまとめてどんどこ追加するような形でも別に良いとは思います。

![フォーム設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/cecd3c5e-f98c-cc96-9f7e-3e06120d66ad.png)

カスタマイズとしては、

- スペースフィールドに MapLibre で地図を描画
- 添付ファイルフィールドに添付された GPX ファイルを読み込み、地図上にポリラインを描画
- 写真テーブルに添付された画像とコメントを用いて地図上にマーカー・ポップアップを描画
- アニメーション機能で走行ルートを再生できる
- 標高データを活かした 3D ビュー表示

と言ったあたりの機能が実装できれば良いかなと思います。

## 実装に取り組む

コード全体としてはけっこうな分量になってしまいますので、ポイントを絞って解説していきます。
全体像はリポジトリを参照してください。
折りたたんでいますので適宜展開してご覧ください。
実際のコードは細かく関数に分割していますが、解りやすいようにできるだけ上から順に処理を追えるよう展開して書いています。

### MapLibre で地図コンテンツを表示する

最低限地図を表示するだけなら以下のクイックスタートで解説されている通りです。

https://maplibre.org/maplibre-gl-js/docs/

今回は下絵（タイル）に [OpenStreetMap](https://www.openstreetmap.org/) を使いたかったので、初期化は以下のような感じにしました。

<details><summary><strong>OpenStreetMap で地図を初期化する</strong></summary>

```javascript
// 指定のコンテナに地図を描画する
const map = new Map({
  container: "map_container",
  style: {
    version: 8,
    sources: {
      osm: {
        type: "raster",
        tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
        tileSize: 256,
        attribution:
          '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
      },
    },
    layers: [
      {
        id: "osm-layer",
        type: "raster",
        source: "osm",
      },
    ],
    sky: {},
  },
  center: [139.6917, 35.6895],
  zoom: 10,
});
```

</details>

下絵としては他に [地理院タイル](https://maps.gsi.go.jp/development/ichiran.html) を利用する事もできます。

https://qiita.com/k96mz/items/c3bf08affc60164ca3da

### GPX ファイルから地点データを読み取る

上述通り、kintone アプリの添付ファイルフィールドに GPX ファイルを添付しており、そこからファイルを取得して XML にパース、地点情報（`<trkpt>`）を抽出してそこから緯度・経度・記録日時を取得すると言う流れになります。

<details><summary><strong>GPX ファイルから地点データを読み取る</strong></summary>

```javascript
/**
 * GPLファイルを読み込んで XML データにパースする
 */

// 添付ファイルフィールドから GPX ファイルを抽出する
const files = record["GPXファイル"].value;
const gpxFile = files.find((f) => f.name.endsWith(".gpx"));
if (!gpxFile) return;

// kintone REST API Client でファイルをダウンロードする
const client = new KintoneRestAPIClient();
const arrayBuffer = await client.file.downloadFile({
  fileKey: gpxFile.fileKey,
});

// ダウンロードしたデータを XML テキストにデコードする
const textDecoder = new TextDecoder();
const xmlStr = textDecoder.decode(arrayBuffer);

// XML テキストデータを GPX ドキュメントオブジェクトにパースする
const parser = new DOMParser();
const doc = parser.parseFromString(xmlStr, "application/xml");

/**
 * GPX ドキュメントオブジェクトから trkpt を読み取り
 * 座標配列・記録日時配列を作成する
 */

// trkpt 要素を抽出する
const trkpts = doc.querySelectorAll("trkpt[lat][lon]");

// JSON オブジェクトに変換する
const trackPoints = Array.from(trkpts).map((trkpt) => {
  // 緯度経度
  const obj = {
    lat: Number(trkpt.attributes.lat.value),
    lon: Number(trkpt.attributes.lon.value),
  };

  // 標高
  const ele = trkpt.querySelector("ele");
  if (ele) {
    obj.ele = Number(ele.innerHTML);
  }

  // 記録日時
  const time = trkpt.querySelector("time");
  if (time) {
    obj.time = new Date(time.innerHTML);
  }

  return obj;
});

// 地点データでループし座標配列・記録日時配列に積み込む
const coordinates = [];
const timestamps = [];
trackPoints.forEach((trkpt, idx) => {
  // 経度・緯度・高度
  if (trkpt.lat && trkpt.lon) {
    const data = [trkpt.lon, trkpt.lat];
    if (trkpt.ele) {
      data.push(trkpt.ele);
    }
    coordinates.push(data);

    // 記録日時
    if (trkpt.time) {
      timestamps[idx] = trkpt.time;
    }
  }
});
```

</details>

1 つ 1 つ追っていくと特に複雑な事はやっていない事が解るかと思います。

### 地図上に経路データをポリラインで描画する

最初のコードで作成した `map` オブジェクトと、2 つめのコードで作成した `coordinates` 配列、`timestamps` 配列のデータを利用して地図上にポリラインを描画します。

<details><summary><strong>地図上に経路データをポリラインで描画する</strong></summary>

```javascript
/**
 * 座標配列と日時配列をもとに地図上にポリラインを描画する
 */

// ポリラインデータ
const line = {
  type: "FeatureCollection",
  features: [
    {
      type: "Feature",
      geometry: {
        type: "LineString",
        coordinates,
      },
      properties: {
        timestamps,
      },
    },
  ],
};

// 地図に描画する
map.addSource("line", {
  type: "geojson",
  data: line,
});

map.addLayer({
  id: "line-layer",
  type: "line",
  source: "line",
  layout: {
    "line-cap": "round",
    "line-join": "round",
  },
  paint: {
    "line-color": "#0000FF",
    "line-width": 7,
  },
});
```

</details>

`coordinates` と `timestamps` を持つ `line` オブジェクトを作成し、それを `map` オブジェクトのソースとして追加、そしてそのソースをレイヤーとして追加、と言う手順を踏む事で目に見える形で地図上に走行経路がマッピングできます。

### 画像ファイル（JPEG）から EXIF データを読み取る

コード量がだいぶ多くなっています。
1 つ 1 つ紐解いていけばやっている事はそんな難しくないんですがね。

<details><summary><strong>画像をダウンロードし EXIF データを取得する</strong></summary>

```javascript
/**
 * 写真ファイルを取得する
 */

// kintone REST API Client
const client = new KintoneRestAPIClient()

// 写真テーブルから JGP 画像とコメントを取得する
// テーブルの個々の行には画像は1ファイルの想定
const photos = []
if (record['画像ファイルテーブル'].value.length) {
  record['画像ファイルテーブル'].value.forEach((row) => {
    if (
      row.value['画像ファイル'].value &&
      row.value['画像ファイル'].value.length &&
      row.value['画像ファイル'].value[0].contentType === 'image/jpeg'
    ) {
      // 画像をダウンロードして blob を取得する
      const fileKey = row.value['画像ファイル'].value[0],fileKey
      const arrayBuffer = await client.file.downloadFile({ fileKey })
      const blob = new Blob([arrayBuffer])

      // 配列に積み込む
      photos.push({
        ...row.value['画像ファイル'].value[0],
        comment: row.value['画像コメント'].value || '',
        blob,
      })
    }
  })
}

/**
 * 写真画像を受け取り地図上にマーカーで描画する
 * 写真画像はポップアップ内に表示する
 */

// 画像データを読み取る
const images = []
for (const photo of photos) {
  images.push({
    ...(await readImageExifData(photo)),
    comment: photo.comment,
  })
}

/**
 * 画像データから Exif データを読み込む
 */
const readImageExifData = async (file) => {
  // Blob URL（表示用データURL）
  const blobUrl = window.URL.createObjectURL(file.blob)

  // File オブジェクト
  const fileObj = new File([file.blob], file.name, { type: file.blob.type })

  // Exif データを読み取る
  const exif = await getExifData(fileObj)

  // Exif データから緯度経度高度情報を得る
  const coordinate = exifToLatLonAlt(exif)

  // Exif データから撮影日時を得る
  const timestamp = exifToTimestamp(exif)

  // 各種情報をまとめて返却する
  return {
    name: file.name,
    blobUrl,
    coordinate,
    timestamp,
  }
}

/**
 * File オブジェクトから Exif データを得る
 */
const getExifData = async (fileObj) => {
  return new Promise((resolve, reject) => {
    try {
      EXIF.getData(fileObj, function () {
        const allMetaData = EXIF.getAllTags(this)
        resolve(allMetaData)
      })
    } catch (e) {
      reject(e)
    }
  })
}

/**
 * Exif データから緯度・経度・高度の情報を取得する
 */
const exifToLatLonAlt = (exif) => {
  if (exif.GPSLatitude && exif.GPSLongitude) {
    return {
      lat:
        exif.GPSLatitude[0] +
        exif.GPSLatitude[1] / 60 +
        exif.GPSLatitude[2] / 3600,
      lon:
        exif.GPSLongitude[0] +
        exif.GPSLongitude[1] / 60 +
        exif.GPSLongitude[2] / 3600,
      alt: Number(exif.GPSAltitude),
    }
  }

  return null
}

/**
 * Exif データからタイムスタンプ（撮影日時）の情報を取得する
 */
const exifToTimestamp = (exif) => {
  // DateTimeOriginal の値（`YYYY:MM:DD HH:mm:SS`形式）
  const dateTimeOriginal =
    exif.DateTimeOriginal || exif.DateTimeDigitized || exif.DateTime || ''
  const pattern =
    /^([0-9]+)[:/]([0-9]+)[:/]([0-9]+)[T ]([0-9]+):([0-9]+):([0-9]+)/
  const matched = dateTimeOriginal.match(new RegExp(pattern))

  if (matched && matched.length) {
    const dateTimeText = `${matched[1]}/${matched[2]}/${matched[3]} ${matched[4]}:${matched[5]}:${matched[6]}`
    return new Date(dateTimeText)
  }
  return null
}
```

</details>

[EXIF.js](https://github.com/exif-js/exif-js) はなにぶんだいぶ古いプロジェクトのようなので、ライブラリの素のままだと `async / await` 的なモダンな書き方ができません。
上記コードでは `getExifData()` 関数でラップする事で `async / await` できるようにしています。

### 地図上に画像をマーカー＋ポップアップで配置する

<details><summary><strong>取得した緯度経度の地点にマーカーを配置する</strong></summary>

```javascript
/**
 * 写真画像でループして地図上にマーカーで描画する
 * 写真画像はポップアップ内に表示する
 */

// 地図上にマーカーを配置する
images.forEach((image) => {
  if (image.coordinate) {
    // ポップアップの内容物
    const popupBody = document.createElement("div");

    // ボディ部
    popupBody.classList.add("popup-body");

    // -- 画像部
    const img = document.createElement("img");
    img.classList.add("popup-body-image");
    img.src = image.blobUrl;
    popupBody.appendChild(img);

    // -- 説明部
    const desc = document.createElement("div");
    desc.classList.add("popup-body-desc");

    // ---- コメント
    const comment = document.createElement("div");
    comment.classList.add("popup-body-desc-comment");
    comment.innerHTML = image.comment;
    desc.appendChild(comment);

    // ---- 撮影日時
    const timestamp = document.createElement("div");
    timestamp.classList.add("popup-body-desc-timestamp");
    timestamp.innerHTML = image.timestamp ? dateToString(image.timestamp) : "";
    desc.appendChild(timestamp);

    popupBody.appendChild(desc);

    // ポップアップ
    const popup = new Popup({ className: "popup" })
      .setMaxWidth("400px")
      .setDOMContent(popupBody);

    // マーカー
    const marker = new Marker()
      .setLngLat([image.coordinate.lon, image.coordinate.lat])
      .setPopup(popup)
      .addTo(map);
    console.log(marker);
  }
});
```

</details>

ポップアップの中身はまあ好きなようにすれば良いと言う感じはありますが、

```javascript
const blobUrl = URL.createObjectURL(file.blob);
img.src = image.blobUrl;
```

のところがちょっとしたポイントです。
ダウンロードした ArrayBuffer から Blob データを作り、 [URL.createObjectURL()](https://developer.mozilla.org/ja/docs/Web/API/URL/createObjectURL_static) に投げる事で `img` タグの `src` 属性に割り当て可能な URL が得られます。

### アニメーション機能を実装する

アニメーションに関しては公式のドキュメントを見ると解りやすいです。

https://maplibre.org/maplibre-gl-js/docs/examples/animate-a-line/

極端な事を言うとポリラインの配列に座標を追加してやるたびに描画が走る挙動で、`requestAnimationFrame()` で再描画を指示する、と言う感じです。
再生速度はうまいことすればコントロールできるとは思いますが、 `setTimeout()` とかだとカクカクした動きになるので今回は再生速度調整は省きました。
（描画するものの重さによってはフレームレートの維持は難しくなりますし）

<details><summary><strong>ポリラインのアニメーション</strong></summary>

```javascript
/**
 * ポリラインをアニメーションで描画する
 */
const animateLine = ({ map, line, coordinates, timestamps, index }) => {
  // 再生停止されたら戻る
  if (!isPlaying) return;

  // 新しい座標を追加する
  line.features[0].geometry.coordinates.push(coordinates[index]);

  // センターをセットする
  movePlayheadTo({ map, coordinates, timestamps, index });

  // ヘディングアップの場合は地図を回転する
  if (!isNorthUpView && index + 1 < coordinates.length) {
    const degree = getDegreeBy2Coordinates(
      coordinates[index],
      coordinates[index + 1]
    );
    map.rotateTo(degree);
  }

  // GeoJSONソースを更新する
  map.getSource("line").setData(line);

  // アニメーションが続く限り再帰的に呼び出す
  if (++index < coordinates.length) {
    requestAnimationFrame(() => {
      animateLine({ map, line, coordinates, timestamps, index });
    });
  } else {
    // 末尾に到達したら再生停止する
    console.log("末尾に到達したら再生停止する");
    stopPlay();
  }
};
```

</details>

これらのアニメーションをコントロールするのは画面右上に置いた各種ボタンですが、ここは特筆すべきところもないので解説は省きます。
気になる方は元ソースをご覧ください。

### 地理院タイルを使い 3D ビューで表示する

以下の記事が非常に詳しく解説しています。

https://qiita.com/Kanahiro/items/1e9c1a4ad6be76b27f0f

記事に従い `maplibre-gl-gsi-terrain` ライブラリをインストールしたうえで、

```javascript
const gsiTerrainSource = useGsiTerrainSource(maplibreGl.addProtocol);
const gsiTerrainParams = {
  style: {
    version: 8,
    sources: {
      seamlessphoto: {
        type: "raster",
        tiles: [
          "https://cyberjapandata.gsi.go.jp/xyz/seamlessphoto/{z}/{x}/{y}.jpg",
        ],
        maxzoom: 18,
        tileSize: 256,
        attribution:
          '<a href="https://maps.gsi.go.jp/development/ichiran.html">地理院タイル</a>',
      },
      terrain: gsiTerrainSource,
    },
    layers: [
      {
        id: "seamlessphoto",
        type: "raster",
        source: "seamlessphoto",
      },
    ],
    terrain: {
      source: "terrain",
      exaggeration: 1.2,
    },
    sky: {
      "sky-color": "#2481f9",
      "sky-horizon-blend": 0.5,
      "horizon-color": "#8fcbf0",
      "horizon-fog-blend": 0.1,
      "fog-color": "#ffffff",
      "fog-ground-blend": 0.5,
    },
  },
  center: [139.6917, 35.6895],
  zoom: 13,
  pitch: 60,
  maxPitch: 85,
};
```

と言ったパラメータを `new Map()` の際に引き渡してやれば 3D 表示できます。
実にお手軽！先人の知恵に感謝！

## 完成！

そんなわけで完成したものを以下でご覧いただけます。
（kintone ではなく GitHub Pages でホストしているものですが）

https://ishinkai.github.io/gpx-viewer-standalone/dist/

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/5df15009-a755-06e9-fb7a-d75fa92422ec.png" width="49.5%"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/ab7851e4-c120-9989-8897-ed805dc1b337.png" width="49.5%">

サンプルとして前置きのところでお話した今年秋のロングツーリングの前半数日のデータを格納しています。

kintone での見た目はこんな感じ。
カスタマイズビューでの表示と、レコード詳細画面での表示です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/bf8cc862-8f2d-7572-a1d6-b2668f4f02fa.png" width="49.5%"> <img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/162138/a2579b8e-1962-5c45-6e6b-4da1cfc2c1e3.png" width="49.5%">


## リポジトリ

**kintone で動かすバージョン** は以下からどうぞ。

https://github.com/iShinkai/gpx-viewer-kintone

アプリテンプレートも含んでいますので、GPX データや写真画像（EXIF 情報を含んだ JPEG ファイル）があればお試しいただけるかと思います。

また上記の GitHub Pages でホストしているものは kintone とは無関係に自前で Web サーバを立てれば動かせるものです。データ取得周り以外には機能的な違いはありません。
こちらは **スタンドアロン版** と呼んでいます。

https://github.com/iShinkai/gpx-viewer-standalone

なお GPX データは上述通り [ZweiteGPS](https://apps.apple.com/jp/app/zweitegps/id635080232) と言うロガーアプリから出力したものを表示に使っており、それ以外のデータは特に検証していません。
GPX データの内容次第ではうまく動かないとかもあるかも知れません。

## まとめ

と言うわけで、 [MapLibre](https://maplibre.org/) と言う地図ライブラリを用いて添付ファイルフィールドに添付した **GPX データ等を可視化** するアプローチについてご紹介しました。
正直言って kintone ならではみたいなところは特になく、 **データ置き場として使っているだけじゃね？ これ kintone じゃなくても良くね？** と言うところは大いにあるとは思いますが、kintone 界隈では MapLibre 案件はほとんど話題に上がった事がないと思うので、 **kintone ＋ 地図コンテンツ** と言うプリミティブな部分だけ抜き出してみるとそれはそれで刺さる人も居るんじゃないかなと思います。
それも特段・・・と言う方は、添付ファイルに置いたファイルを取得してうにゃうにゃやる部分だけでもお役に立つんじゃないかと！

ともあれ、お読みいただきありがとうございました！