Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

mapbox-gl-js で複雑な表示のアイコンを作る場合や描画順序を変更する場合の工夫

はじめに

駅メモ!チームでエンジニアをしている id:wgg00sh です。

この記事では、駅メモ!内で地図クライアントとして使用している mapbox-gl-js を使うにあたって工夫した点などを紹介していきます。

駅メモ!では、2024 年 6 月に、「タイムラインと地図の切替機能」(以降:タイムライン地図、本機能)をリリースしました。 本機能の実現にあたって、苦労した点やその解決方法を書いていきます。

本記事で扱う内容

この記事では、主に Mapbox GL JS の以下の機能・プロパティに関する話をします。

  • icon-allow-overlap, text-allow-overlap
  • icon-ignore-placement, text-ignore-placement
  • symbol-sort-key

機能の概要説明

はじめに、本機能についての説明をします。

通常のタイムライン タイムライン地図

駅メモ!はスマートフォンの位置情報を取得して、最寄り駅にアクセスして遊ぶゲームです。タイムライン地図では画像右側のように現在地や周辺の情報を地図上に表示しながらプレイすることが可能になります。 これにより、従来よりも直感的に駅の情報を確認しながらプレイすることができるようになりました。

実現にあたって直面した問題

ここからは、いくつか本機能の実現にあたって苦労した点をその解決策と合わせて説明します。

addLayer() を使って複数の画像を組み合わせたアイコンを描画したい場合

今回のタイムライン地図機能では、駅メモ!の遊びである「駅の収集」と「駅の取り合い」の両方に焦点を合わせて駅の情報を描画しています。

タイムライン地図で描画したいアイコンの内容

タイムライン地図で使用している駅アイコンはこのようなものです。

このアイコンの描画に必要な情報として、3 つの要素に分解することができます。

駅属性 アクセス状況 駅名
  • 駅属性は、ゲーム内で駅の取り合いをするにあたって影響するパラメータで、4 つ (heat、eco、cool, および廃駅にのみ適用される「属性無し」) のうちいずれか 1 つを必ず持ちます。
  • アクセス状況は、ユーザがその駅にアクセスしたかの状態で、4 つ(未アクセス、当月未アクセス、当月アクセス済み、当日アクセス済み)のいずれか 1 つの状態になります。
  • 駅名は、その名前の通り駅に割り振られた名称で、何かしらの文字列になります。

アイコンの描画手法について

ここで苦労したのが、アクセス状況と駅属性を両方含むアイコンを描画する点です。

Mapbox GL JS で地図上の特定の座標にアイコンを描画するには、大きく 2 つの手法があります。

本機能では、描画されるアイコンの数は非常に多くなりうるため、HTML マーカーを用いることでパフォーマンスの悪化が懸念されます。 そのためレイヤー機能で描画する選択肢しかありませんでした。しかし、この場合描画できる内容に大きな制約がかかります。

symbol レイヤーが表示できる内容

前述のリンク先に記載されていますが、symbol レイヤーでは同一レイヤーの各要素に対して、「1 つの文字列」と「1 つの画像」を組み合わせて描画します。(文字列や画像自体はプロパティを参照できるので要素ごとに異なるデータを渡せます)

symbol レイヤーを用いてデータを描画する場合のサンプルは下記のようになります。

map.addLayer({
  id: "stations",
  source: "stations",
  type: "symbol",
  layout: {
    "text-field": "{name}", // 駅名
    "icon-image": ["get", "icon-key"], // 画像
  },
})

1 つのアイコンには 1 つの画像までしか利用することができません。 そのため「駅属性・アクセス状況それぞれに対応する画像を用意し、それら 2 つの画像の組み合わせを用いて 1 つのアイコンを描画する」といったことはできませんでした。

この問題を解決する為に、今回 2 つの案を試しました。

  • 案 1: レイヤーを 2 つ用意する
  • 案 2: 2 つの画像を組み合わせた単一の画像をあらかじめ用意しておく

案 1: レイヤーを 2 つ用意する

駅属性画像とアクセス状況画像をそれぞれ用意(4+4=8 種)して、2 つのレイヤーでそれぞれ描画してみます。

map.addLayer({
  id: "stations",
  source: "stations",
  type: "symbol",
  layout: {
    "icon-anchor": "center",
    "icon-image": ["get", "element"],
    "icon-size": 0.5,
    "icon-offset": [0, -60],

    // 駅名
    "text-field": "{name}",
    "text-anchor": "top",
    "text-offset": [0, 0.5],
  },
})

map.addLayer({
  id: "stations2",
  source: "stations",
  type: "symbol",
  layout: {
    "icon-anchor": "center",
    "icon-image": ["get", "status"],
    "icon-size": 0.5,
    "icon-offset": [0, -15],
    "symbol-sort-key": ["get", "sort-key"],
  },
})

このように、ほとんどの場所で正しく表示されなくなってしまいます。

通常、Mapbox GL JS の symbol レイヤーは,アイコン・テキスト同士に対して衝突検出を行い、重なっていれば一方を描画しないようにします。 これは、icon-allow-overlaptext-allow-overlap がそれぞれデフォルトで false になっているためです。

(参考: https://docs.mapbox.com/help/troubleshooting/optimize-map-label-placement/)

この機能のおかげで、描画すべきアイコンが膨大な場合にも適切な見やすさを保つことができ、描画負荷も抑えられます。 この仕組みを無効にするには、それぞれのレイヤーに icon-ignore-placement: true, text-ignore-placement: true を付与します。

map.addLayer({
  id: "stations",
  source: "stations",
  type: "symbol",
  layout: {
    // 他の設定
    "icon-ignore-placement": true,
    "text-ignore-placement": true,
  },
})

map.addLayer({
  id: "stations2",
  source: "stations",
  type: "symbol",
  layout: {
    // 他の設定
    "icon-ignore-placement": true,
    "text-ignore-placement": true,
  },
})

非常に見づらくなってしまってしまいました。

これは、icon-ignore-placementtext-ignore-placement を付与したことで、同じ駅の属性・アクセス状況は重ねて描画できるようになったものの、他の駅との重なりも無視されるようになってしまったためです。 駅数が少ない地域では、駅同士が密接することはなくこれでも問題ないかもしれませんが、都心部では画像のとおりに上手く描画できません。

そのため、このアプローチは不十分と判断しました。

案 2: 2 つの画像を組み合わせた単一の画像をあらかじめ用意しておく

もう一方のアプローチは、あらかじめ 2 つの画像を組み合わせて 1 つの画像として書き出しておくものです。

cool かつ、アクセス済み heat かつ、今月未アクセス 属性無しかつ、本日未アクセス

このように、属性4種 x アクセス状況4種 = 16種 の画像をあらかじめ作成しておきます。

type Station = {
  name: string
  lat: number
  lng: number
  element: string
  status: string
}

["heat", "cool", "eco", "none"].forEach((element) => {
  [
    "unaccessed",
    "unaccessed_month",
    "unaccessed_today",
    "accessed_today",
  ].forEach((status) => {
    map.loadImage(
      `/img/station_element/${element}_${status}.png`,
      (error, image) => {
        if (error || !image) {
          console.error("Failed to load image", error)
          return
        }
        map.addImage(`${element}_${status}`, image)
      }
    )
  })
})

const getIconKey = (station) => {
  return `${station.element}_${station.status}`
}

map.addSource("stations", {
  type: "geojson",
  data: {
    type: "FeatureCollection",
    features: stations.map((station: Station, index) => {
      return {
        type: "Feature",
        geometry: {
          type: "Point",
          coordinates: [station.lng, station.lat],
        },
        properties: {
          name: station.name,
          "icon-key": getIconKey(station),
        },
      }
    }),
  },
})

map.addLayer({
  id: "stations",
  source: "stations",
  type: "symbol",
  layout: {
    "icon-anchor": "center",
    "icon-image": ["get", "icon-key"],
    "icon-size": 0.5,
    "icon-offset": [0, -30],

    "text-field": "{name}",
    "text-max-width": 10,
    "text-size": 14,
    "text-anchor": "top",
    "text-offset": [0, 0.5],
  },
})

このようにすることで、間引きにも対応して複数画像を組み合わせたアイコンを描画することができました。

案 2 の懸念点

今回のケースでは、存在する画像の組み合わせが、16 通りと多くはないため全て予め作成しておく形で対処しましたが、ケースによってはそれが難しい場合もあるかもしれません。 例えば、ここに 4 通りのパラメータが 1 つ・2 つと増えれば全部で 64 通り・256 通りとなり、画像の生成が自動化できない場合は作成に掛かる作業コストも大きく、全て loadImage() で保持する場合メモリの圧迫も心配になってきます。

その場合、解決策としては

  • 実際に使われる組み合わせのみ読み込む
  • 画像の作成を実行時に動的に行う

といった工夫が必要になってくると思われます。 今回は画像の数が少数かつ予め決まっていたため、全パターンを作成する方法を採りました。

駅が密集している場合に、優先して描画される駅を指定したい

Mapbox GL JS では、駅が密集しているとアイコンの描画がある程度自動的に間引かれるという話を先にしました。 タイムライン地図機能では、ライブラリのデフォルトの優先度ではなく、ユーザのプレイ状況に応じて優先的に描画する駅を選択しています。

具体的には、ユーザの現在地付近の駅や、大規模な駅などは間引かれづらくなるようにしています。

例として、「山手線の各駅を優先して間引く・間引かない」という 2 つの条件で、同じ描画範囲の画面を用意してみました。

山手線をなるべく間引く 山手線を優先して残す

後者には、東京・品川・池袋など、主要な駅が間引かれずに残っていることがわかると思います。

const TARGET_STATION_IDS: number[] = [ xxx, yyy, ... ] // 山手線の各駅のID

map.addSource('stations', {
  type: 'geojson',
  data: {
    type: 'FeatureCollection',
    features: stations.map((station: Station, index) => {
      return {
        type: 'Feature',
        geometry: {
          type: 'Point',
          coordinates: [station.lng, station.lat]
        },
        properties: {
          'sort-key': TARGET_STATION_IDS.includes(station.id) ? 0 : 1,
        }
      };
    })
  }
});

map.addLayer({
  id: 'stations',
  source: 'stations',
  type: 'symbol',
  layout: {
    'symbol-sort-key': ['get', 'sort-key'],
  },
});

実際の駅メモ!内では、このコードのように特定の駅だけ優先度を上げる形ではなく、ユーザのプレイ状況に応じて複数の観点から優先度を決定しています。

おわりに

タイムライン地図機能の実装にあたって、描画周りでの工夫を紹介しました。 描画内容・順序を工夫することで、ユーザにとって見やすい地図を提供することができました。

今回の開発を通して、Mapbox GL JS にこんな機能があったのかと学びもあり多く、工夫次第でさまざまな表現が可能であることを実感しました。

参考