[GBFS] 地図にサイクルポートを表示するWeb Componentを作ってみた

シェアサイクルポートのオープンデータを使って、地図にポート情報を表示するWeb Componentを作ってみました。↓

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/5f7eab9a-3208-92b2-cb05-7afa7d00d5c3.png)

この地図はライブラリとして配布しており、以下のような形で任意のサイトに埋め込むことができるコンポーネントになっています。

```html
<script src="https://deno.land/x/gbfs_map/mod.js" charset="utf-8" type="module"></script>
<gbfs-map
  x-url="https://api-public.odpt.org/api/v4/gbfs/hellocycling/gbfs.json,https://api-public.odpt.org/api/v4/gbfs/docomo-cycle-tokyo/gbfs.json"
  x-default-lat="35.68123355100922"
  x-default-lon="139.76712357086677"
  x-preferred-languages="ja"
  x-cors
></gbfs-map>
```

[デモサイト](https://gbfs-map-demo.deno.dev/)

https://gbfs-map-demo.deno.dev/

ちなみにSafari非対応です。

## 使い方

まず、ライブラリ読み込みのために`<script>`タグを設置します。

```html
<script src="https://deno.land/x/gbfs_map/mod.js" charset="utf-8" type="module">
```

ライブラリを読み込むことで`<gbfs-map>`タグが有効化されます。
あとはお好きな場所に`<gbfs-map>`タグを配置するだけです。

### オプション

属性値でオプションを設定することができます。

#### `x-url`

GBFSデータが配信されているAPIのエンドポイントを指定します。
カンマ区切りで複数指定することができます。

```html:例
<gbfs-map
  x-url="https://api-public.odpt.org/api/v4/gbfs/hellocycling/gbfs.json,https://api-public.odpt.org/api/v4/gbfs/docomo-cycle-tokyo/gbfs.json"
></gbfs-map>
```

#### `x-default-lat`,`x-default-lon`

地図の初期座標（中心）を指定することができます。
`x-default-lat`に緯度を、`x-default-lon`に経度を指定します。
以下は東京駅を初期座標として指定する場合の例です。

```html:例
<gbfs-map
  x-url="..."
  x-default-lat="35.68123355100922"
  x-default-lon="139.76712357086677"
></gbfs-map>
```

#### `x-preferred-languages`

GBFSフォーマットでは多言語のデータが配信されていることがあります。
`"ja"`と指定すると日本語データが、`"en"`と指定すると英語のデータが表示されます。
カンマ区切りで複数指定することができます。
指定された言語のデータが無い場合や、そもそもこの属性が指定されていない場合は、APIから返される0番目の値にフォールバックします。

```html:例
<gbfs-map
  x-url="..."
  x-preferred-languages="ja,en"
></gbfs-map>
```


#### `x-cors`


この属性を付けると、CORSプロキシが使用されます。

このライブラリは、フロントエンドから直接APIを叩くため、CORS制限がかかっているとエラーになります。
この状態を回避するには、`x-cors`属性を付けます。この属性を付けることによって、CORS制限を回避するためのサービスである https://cors.deno.dev/ というサイト経由でデータを取得するようになります。


```html:例
<gbfs-map
  x-url="..."
  x-cors
></gbfs-map>
```

この挙動が望ましくない場合は、JavaScriptを使用して任意のCORSプロキシを使用するように変更できます。

```ts
import { GbfsMap } from "https://deno.land/x/gbfs_map/mod.js";

// `toCorsUrl`を上書きすることで、どのCORSプロキシを使うかを指定できる
// URL文字列を受け取り、CORSプロキシを使用するURLに書き換える関数
GbfsMap.toCorsUrl = url => `https://my-cors-proxy/${url}`
```


# 実装について

### シェアサイクルのオープンデータ：GBFSフォーマット

シェアサイクルのデータは以下のURLからオープンデータとして提供されています。

https://ckan.odpt.org/dataset/?res_format=GBFS

こちらのデータですが、[GBFSフォーマット](https://github.com/MobilityData/gbfs/)のJSONファイルとして配布されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/d402870f-f866-5de6-028c-415fdae0e8d8.png)
▲データ例

### Web Component

今回はWeb Componentという技術を用いました。

> ウェブコンポーネントは、再利用可能なカスタム要素を作成し、ウェブアプリの中で利用するための、一連のテクノロジーです。コードの他の部分から独立した、カプセル化された機能を使って実現します。
https://developer.mozilla.org/ja/docs/Web/Web_Components

つまり、

- Reactコンポーネントのようなものがネイティブに作成できる
- CSSのカプセル化ができる
- ライブラリを使わずに書けるため、バンドルサイズが小さく済む

といった特徴があります。
実体としてはHTMLElementなので、ユーザーがReactを使っているかVue.jsを使っているかにかかわらず使用できます。そのため、UIをライブラリとして配布するのに向いています。

### Leaflet + OpenStreetMap

地図表示ライブラリにはLeafletとOpenStreetMapを使用しました。

## Web Componentの実装

IE対応も特に必要なくなったということで、ES Modulesを使って書いていきます。

#### Denoのlanguage serverでTypeScript開発

今回はJavaScriptにJSDocを記載することでTypeScriptによる型チェックを行いたいと思います。
ただし、VSCodeなどのエディタはデフォルトではNode.js向けの構文にしか対応していません。また、設定ファイルの記述やコンパイルステップの追加などが必要になります。

そこで、今回はNode.jsの代わりにDenoを使った型チェックを実施します。
[Deno](https://deno.land) をインストールしておくと、設定ファイルが不要で型チェックが働きます。また、URLを使用したimport文を直接解釈できるため、コンパイルステップが不要となります。
Denoのインストールや有効化は、公式サイトを参照してください。

https://deno.land/manual@v1.28.3/getting_started/installation

https://deno.land/manual@v1.28.3/references/vscode_deno#using-visual-studio-code

#### Leafletの読み込み

地図表示ライブラリであるLeafletはnpmに公開されているため、https://esm.sh/leaflet からimportすることができます。

```ts
import * as L from "https://esm.sh/leaflet@1.9.3";
```

#### Web Component作成に使えるライブラリ

Reactなどのライブラリを使用してWeb Componentを作成することもできます。
ただしその場合、ユーザー視点から見ると、「ライブラリのユーザーが使用したいReactのバージョン」と、「ライブラリの内部で使用しているReactのバージョン」が違う場合、2種類のReactがクライアント側にロードされてしまいます。
10行程度短く書くために追加で130KB入ってくるのはさすがに考えられないため、今回はDOM操作ライブラリ無しで書いていきます。

:::note warn
Web Component系のフレームワークは、サイズが大きかったりビルドが必要だったりすると、そもそもWeb Componentであるメリットを失ってしまいます。
しかし、現状ではそういった課題を解決しているライブラリがほぼ無く、「だったらReact/Vue.js/Angularでいいじゃん」となり、結果あまり普及していない感じがします。
:::

```ts
export class GbfsMap extends HTMLElement {
  /** @type {AbortController | undefined} */
  #abortController;
  #shadowRoot;
  constructor() {
    super();
    this.#shadowRoot = this.attachShadow({ mode: "closed" });
  }
  connectedCallback() {
    // abort when disconnected
    this.#abortController = new AbortController();
    const preferredLanguagesStr = this.getAttribute("x-preferred-languages");
    const preferredLanguages = preferredLanguagesStr
      ? preferredLanguagesStr.split(",")
      : [];
    this.#initElement({ preferredLanguages });
  }
  disconnectedCallback() {
    this.#abortController?.abort();
    this.#shadowRoot.innerHTML = "";
  }
  #initElement({ preferredLanguages }) {
    const i18n = getI18n(preferredLanguages);
    const defaultLat = this.getAttribute("x-default-lat") || 0;
    const defaultLon = this.getAttribute("x-default-lon") || 0;
    const checkboxWrapper = document.createElement("section");
    const availableBikeCheckboxLabel = document.createElement("label");
    const availableDockCheckboxLabel = document.createElement("label");
    const availableBikeCheckboxElement = document.createElement("input");
    const availableDockCheckboxElement = document.createElement("input");
    const mapElement = document.createElement("div");
    availableBikeCheckboxElement.setAttribute("type", "checkbox");
    availableDockCheckboxElement.setAttribute("type", "checkbox");
    ...(中略)
    this.#shadowRoot.append(checkboxWrapper, mapElement);

    const map = L.map(mapElement).setView([+defaultLat, +defaultLon], 15);

    L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
      attribution:
        '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
    }).addTo(map);
    L.control.scale().addTo(map);
  }
}
customElements.define("gbfs-map", GbfsMap);
```

### LeafletのCSSをWeb Componentで使う

Leafletを使う場合は、Leafletから提供されているCSSファイルを読み込む必要があります。
Web Componentに対してCSSを適用するには、`adoptedStyleSheets`を使うことができます。

```ts
// 1. CSSStyleSheetオブジェクトを作成
const leafletStyle = new CSSStyleSheet();
(async () => { // avoid tla
  const res = await fetch("https://esm.sh/leaflet@1.9.3/dist/leaflet.css");
  leafletStyle.replace(await res.text());
})();

export class GbfsMap extends HTMLElement {
  constructor() {
    // 2. adoptedStyleSheetsでCSSを適用
    this.#shadowRoot.adoptedStyleSheets = [leafletStyle];
  }
}
```

:::note warn
（ちなみにSafariについては、現時点ではTechnology Preview版しか`adoptedStyleSheets`に対応していないようです。Safari対応の必要がある場合は`<link>`タグで差し込むなどの手間が必要になります。）

[![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/50d47e2e-27e2-968c-f1d1-29a6c769970b.png)](https://caniuse.com/mdn-api_shadowroot_adoptedstylesheets)
:::

なお、最新版のChromeではCSSを直接importできる「CSS Module Script」が使えます。
この機能を使うと、CSSの定義をもっと短く書くことができます。
（参考：https://web.dev/css-module-scripts/）

```ts:最近のChromeのみ対応
// 1. leafletからCSSを読み込み
import leafletStyle from "./styles.css" assert { type: "css" };

export class GbfsMap extends HTMLElement {
  constructor() {
    // 2. adoptedStyleSheetsでCSSを適用
    this.#shadowRoot.adoptedStyleSheets = [leafletStyle];
  }
}
```

#### オブジェクト指向 vs 関数型

Web Componentの書き方は`HTMLElement`クラスを`extends`するというもので、ザ・オブジェクト指向といった感じです。
ただし、ライフサイクルコールバックが

- DOMノードに接続された時に`connectedCallback`が呼ばれる
- DOMノードから切断された時に`disconnectedCallback`が呼ばれる

という形となっています。これが実はTypeScriptと相性が悪いです。
というのも、`connectedCallback`でプロパティを初期化すると、そのプロパティの型は必ずundefined型とのUnion型になってしまいます。そのため、あらゆるプロパティアクセスでundefinedチェックが入ってきます。

```ts
export class MyElemnt extends HTMLElement {
  #prop1: string | undefined; // constructorで初期化していないため、string | undefined型になる
  connectedCallback() {
    this.#prop = "hello";
  }
  method() {
    // this.#propにアクセスする度にundefinedチェックが必要になる（実際はundefinedが入らない場合でも）
    if (this.#prop) {
      // ...
    }
  }
}
```

プロパティが2つ程度ならまだいいのですが、4~5個以上になってくると全てのメソッドに毎回長いif文を書いてundefinedチェックする必要があるため、プログラムがかなり冗長になってしまいます。

そこで、今回はクラスにメソッドを生やしていくのではなく、クラスの外に関数を書き、それをクラス側から呼ぶ形にします。プロパティアクセスの代わりに引数で与える形になるので、undefinedチェックが不要になります。

```ts
export class GbfsMap extends HTMLElement {
  connectedCallback() {
    // ...(中略)

    // クラスの外に置いた関数をconnectedCallbackから呼ぶ
      observeMapData(map, {
        availableBikeCheckboxElement,
        availableDockCheckboxElement,
        url,
        urlConverter,
        preferredLanguages,
      });
  }
  // ... (中略)
}
customElements.define("gbfs-map", GbfsMap);

// ↓↓メソッドではなく、クラスの外で関数として定義する

async function observeMapData(map, {
  url,
  availableBikeCheckboxElement,
  availableDockCheckboxElement,
  urlConverter,
  preferredLanguages,
}) {
  const {
    systemInformationEndpoint,
    informationEndpoint,
    statusEndpoint
  } = await getRegistryInformation(url, preferredLanguages);
  const informationPromise = getStationInformation(informationEndpoint);
  const systemInformationPromise = getSystemInformation(systemInformationEndpoint);
  const stationStatusPromise = getStationStatus(statusEndpoint);

  // 以下、アイコンなどのレンダリング処理
}


async function getRegistryInformation(url, languages) {
  // APIエンドポイントの一覧を取得
}
async function getSystemInformation(url) {
  // サービス情報（サービス名など）を取得
}
async function getStationInformation(url) {
  // ポート情報（ポート名や緯度経度）を取得
}
async function getStationStatus(url) {
  // ポートのステータス情報（現在の台数など）を取得
}
```

#### AbortControllerを使ったライフサイクル

書き方をオブジェクト指向から関数型に寄せたことで、DOMのライフサイクル（生成～破棄）の書き方も変える必要があります。
今回はDOM破棄時の処理を`AbortController`を使って書いていきます。

`AbortController`は任意の関数の中断処理が書ける便利なWeb標準APIです。

- `AbortController#signal.addEventListener("abort", callback)`で中断時の処理を書く
- `AbortController.abort()`で処理を中断する
- `AbortController`を関数の引数に渡すことで、関数の外側からまとめて処理を中断できる

という特徴があります。特に、`addEventListener`を複数行っても、まとめて一括で`abort`することができるのが便利ポイントです。


```ts:AbortController使用例

const controller = new AbortController();

controller.signal.addEventListener("abort", () => {
  // 中断時の処理
  // controller.abort()を呼んだときにこの部分が実行される
  console.log("abortされました")
});

// abort()メソッドを呼ぶと、addEventListener("abort", fn)した中断時の処理が実行される
controller.abort();
```

今回はDOMが破棄された時（`disconnectedCallback`が呼ばれた時）に`abort()`が呼ばれるようにします。

その上で、`AbortController#signal`を各関数に引数で渡していき、関数内ではDOM破棄時の処理を`addEventListener("abort", fn)`に書いていくことにしました。

```ts
export class GbfsMap extends HTMLElement {
  /** @type {AbortController | undefined} */
  #abortController;
  connectedCallback() {
    // AbortControllerを生成
    this.#abortController = new AbortController();
    // (中略)
      observeMapData(map, {
        // AbortControllerを引数に渡す
        signal: this.#abortController.signal,
      });
  }
  disconnectedCallback() {
    // AbortControllerをabort
    this.#abortController?.abort();
  }
}

function observeMapData(map, { signal }) {
  signal.addEventListener("abort", () => {
    // DOM破棄時に呼ばれるコールバック
  });
  // 以下略
}
```

#### ライブラリの公開とJSDocの設定

今回作るライブラリはペライチのjsファイルなので、https://deno.land/x に公開しました。
https://deno.land/x はDeno向けのライブラリホスティングサービスですが、ブラウザ向けのライブラリを公開する場所としても使えるらしいです。

https://deno.land/x に公開するとドキュメントの生成を自動で行ってくれます。
ドキュメントの内容はJSDocから自動生成されるため、エクスポートする関数にJSDocを記載するだけでOKです。

```ts
/** ここに書いた文章がドキュメントとして表示される */
export class GbfsMap {}
```

生成されたドキュメントについては[こちら](https://deno.land/x/gbfs_map@2.3.0/mod.js?s=GbfsMap)で見ることができます。

https://deno.land/x/gbfs_map@2.3.0/mod.js?s=GbfsMap

## まとめ

今回はシェアサイクルのオープンデータを使用して、Web componentを使ったUIのライブラリ化を試しました。

- オープンデータについて
  - シェアサイクルの運営会社によって情報量に差がある。プロパティが生えていたり生えていなかったりする。
  - GBFSフォーマットの仕様自体のアップデートが予定されている。プロパティ名なども変わる予定らしいので、その時にAPIがどのような変更が入るのか分からない
    - ある日突然プロパティ名が変わるかもしれないし、エンドポイントが変更されるかもしれない
- Web Componentについて
  - フレームワークやライブラリを入れずに`document.createElement()`をチマチマ書いていくのは厳しい。ただ、そこでライブラリを使ってしまうとサイズが増加して、Web Componentを使用するメリットが薄れてしまうというジレンマ。
    - 軽量なWeb Componentライブラリがあったら使いたい。
  - とはいえCSSをカプセル化できるメリットは大きい（今回の場合、leaflet用のCSSをカプセル化したことで、知らない所でCSS指定がバッティングしてしまう可能性を0にできた）

今回書いたソースコードはGitHubにあります。

https://github.com/ayame113/gbfs-map

UIをライブラリとして公開するときにWeb componentという選択肢は結構良さそうです。
今回のコードはかなりボイラープレートの割合が大きいので、そこの部分だけが入ったミニマムなフレームワークとかがあると小さく使えて便利かもしれないですね。