シェアサイクルポートのオープンデータを使って、地図にポート情報を表示するWeb Componentを作ってみました。↓
この地図はライブラリとして配布しており、以下のような形で任意のサイトに埋め込むことができるコンポーネントになっています。
<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>
ちなみにSafari非対応です。
使い方
まず、ライブラリ読み込みのために<script>
タグを設置します。
<script src="https://deno.land/x/gbfs_map/mod.js" charset="utf-8" type="module">
ライブラリを読み込むことで<gbfs-map>
タグが有効化されます。
あとはお好きな場所に<gbfs-map>
タグを配置するだけです。
オプション
属性値でオプションを設定することができます。
x-url
GBFSデータが配信されているAPIのエンドポイントを指定します。
カンマ区切りで複数指定することができます。
<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
に経度を指定します。
以下は東京駅を初期座標として指定する場合の例です。
<gbfs-map
x-url="..."
x-default-lat="35.68123355100922"
x-default-lon="139.76712357086677"
></gbfs-map>
x-preferred-languages
GBFSフォーマットでは多言語のデータが配信されていることがあります。
"ja"
と指定すると日本語データが、"en"
と指定すると英語のデータが表示されます。
カンマ区切りで複数指定することができます。
指定された言語のデータが無い場合や、そもそもこの属性が指定されていない場合は、APIから返される0番目の値にフォールバックします。
<gbfs-map
x-url="..."
x-preferred-languages="ja,en"
></gbfs-map>
x-cors
この属性を付けると、CORSプロキシが使用されます。
このライブラリは、フロントエンドから直接APIを叩くため、CORS制限がかかっているとエラーになります。
この状態を回避するには、x-cors
属性を付けます。この属性を付けることによって、CORS制限を回避するためのサービスである https://cors.deno.dev/ というサイト経由でデータを取得するようになります。
<gbfs-map
x-url="..."
x-cors
></gbfs-map>
この挙動が望ましくない場合は、JavaScriptを使用して任意のCORSプロキシを使用するように変更できます。
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からオープンデータとして提供されています。
こちらのデータですが、GBFSフォーマットのJSONファイルとして配布されています。
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 をインストールしておくと、設定ファイルが不要で型チェックが働きます。また、URLを使用したimport文を直接解釈できるため、コンパイルステップが不要となります。
Denoのインストールや有効化は、公式サイトを参照してください。
Leafletの読み込み
地図表示ライブラリであるLeafletはnpmに公開されているため、https://esm.sh/leaflet からimportすることができます。
import * as L from "https://esm.sh/leaflet@1.9.3";
Web Component作成に使えるライブラリ
Reactなどのライブラリを使用してWeb Componentを作成することもできます。
ただしその場合、ユーザー視点から見ると、「ライブラリのユーザーが使用したいReactのバージョン」と、「ライブラリの内部で使用しているReactのバージョン」が違う場合、2種類のReactがクライアント側にロードされてしまいます。
10行程度短く書くために追加で130KB入ってくるのはさすがに考えられないため、今回はDOM操作ライブラリ無しで書いていきます。
Web Component系のフレームワークは、サイズが大きかったりビルドが必要だったりすると、そもそもWeb Componentであるメリットを失ってしまいます。
しかし、現状ではそういった課題を解決しているライブラリがほぼ無く、「だったらReact/Vue.js/Angularでいいじゃん」となり、結果あまり普及していない感じがします。
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:
'© <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
を使うことができます。
// 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];
}
}
なお、最新版のChromeではCSSを直接importできる「CSS Module Script」が使えます。
この機能を使うと、CSSの定義をもっと短く書くことができます。
(参考:https://web.dev/css-module-scripts/)
// 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チェックが入ってきます。
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チェックが不要になります。
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
することができるのが便利ポイントです。
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)
に書いていくことにしました。
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です。
/** ここに書いた文章がドキュメントとして表示される */
export class GbfsMap {}
生成されたドキュメントについてはこちらで見ることができます。
まとめ
今回はシェアサイクルのオープンデータを使用して、Web componentを使ったUIのライブラリ化を試しました。
- オープンデータについて
- シェアサイクルの運営会社によって情報量に差がある。プロパティが生えていたり生えていなかったりする。
- GBFSフォーマットの仕様自体のアップデートが予定されている。プロパティ名なども変わる予定らしいので、その時にAPIがどのような変更が入るのか分からない
- ある日突然プロパティ名が変わるかもしれないし、エンドポイントが変更されるかもしれない
- Web Componentについて
- フレームワークやライブラリを入れずに
document.createElement()
をチマチマ書いていくのは厳しい。ただ、そこでライブラリを使ってしまうとサイズが増加して、Web Componentを使用するメリットが薄れてしまうというジレンマ。- 軽量なWeb Componentライブラリがあったら使いたい。
- とはいえCSSをカプセル化できるメリットは大きい(今回の場合、leaflet用のCSSをカプセル化したことで、知らない所でCSS指定がバッティングしてしまう可能性を0にできた)
- フレームワークやライブラリを入れずに
今回書いたソースコードはGitHubにあります。
UIをライブラリとして公開するときにWeb componentという選択肢は結構良さそうです。
今回のコードはかなりボイラープレートの割合が大きいので、そこの部分だけが入ったミニマムなフレームワークとかがあると小さく使えて便利かもしれないですね。