Mapbox GL JS でAttributionをコントロールする -調査編-

[前回](https://qiita.com/OttyLab/items/2c1f7e3be95441939bc6)はMapboxにおけるAttributionについて取り扱いました。今回はMapbox GL JSにおけるAttributionのコントロールについて、[Mapbox GL JS v1.13.0](https://github.com/mapbox/mapbox-gl-js/tree/v1.13.0)のソースを追いかけながら見ていきます。

# Text attribution

## オプションでの設定

Mapbox GL JSでは`Map`クラスをインスタンス化する際の[オプション](https://docs.mapbox.com/mapbox-gl-js/api/map/#map-parameters:~:text=options.attributionControl)でtext attributionのコントロールが行なえます。

このオプションによる制御は[map.jsの469,470行目](https://github.com/mapbox/mapbox-gl-js/blob/v1.13.0/src/ui/map.js#L469-L470)で行われています。

```js:map.js
 469         if (options.attributionControl)
 470             this.addControl(new AttributionControl({customAttribution: options.customAttribution}));
```

つまり、`attributionControl`が`true`(デフォルト) の場合のみ、`AttributionControl`をコントロールとして地図上に追加します。

`AttributionControl`クラスのオプションは[attribution_control.jsの9-12行目](https://github.com/mapbox/mapbox-gl-js/blob/v1.13.0/src/ui/control/attribution_control.js#L9-L12)に定義されています。

```js:attribution_control.js
  9 type Options = {
 10     compact?: boolean,
 11     customAttribution?: string | Array<string>
 12 };
```
`compact`はattributionを折りたたむ表示をします。ただしこのオプションは`Map`クラスをインスタンス化する際には指定する方法がありません。使用したい場合には以下のように`attributionControl: false`とした上で、新たに`AttributionControl`インスタンスを`addControl`します。

```js
    var map = new mapboxgl.Map({
        container: 'map', // container id
        style: 'mapbox://styles/mapbox/streets-v11', // style URL
        center: [-74.5, 40], // starting position [lng, lat]
        zoom: 9, // starting zoom
        attributionControl: false
    });

    map.addControl(new mapboxgl.AttributionControl({
        compact: true,
        customAttribution: ['<a href="https://example.com/custom1">custom1</a>', 'custom2']}));
```

`customAttribution`は文字列か文字列の配列です。記載した内容がそのままattributionとして表示されます。HTMLによるリンクの設定も可能です。

また、[SDKのドキュメント](https://docs.mapbox.com/mapbox-gl-js/api/markers/#attributioncontrol)にも定義及び使用例が記載されています。

## Attributionの追加処理

`AttributionControl`クラスの中で実際に[attributionを追加する処理](https://github.com/mapbox/mapbox-gl-js/blob/v1.13.0/src/ui/control/attribution_control.js#L146-L157)は以下の部分になります。


```js:attribution_control.js
146         if (this.options.customAttribution) {
147             if (Array.isArray(this.options.customAttribution)) {
148                 attributions = attributions.concat(
149                     this.options.customAttribution.map(attribution => {
150                         if (typeof attribution !== 'string') return '';
151                         return attribution;
152                     })
153                 );
154             } else if (typeof this.options.customAttribution === 'string') {
155                 attributions.push(this.options.customAttribution);
156             }
157         }
```
文字列 / 文字列配列を判定して追加していく様子がわかります。


## Mapboxのデフォルトtext attribution
何も指定しないのにデフォルトで表示されているMapboxのtext attributionはどこからやってくるのでしょうか？

スタイルを読み込んだ段階で、ソースの調査を行います。[Style Specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/)で定義されている通り、Styleにはソースに関する情報が記載されています。ベクタータイルであればTilesetの情報となります。これはベクタータイルではTileJSONと呼ばれるAPIで取得できます。[以下のコード](https://github.com/mapbox/mapbox-gl-js/blob/v1.13.0/src/source/vector_tile_source.js#L99-L116)では`loadTileJSON`でTileJSON APIにアクセスし、`tileJSON`に結果を格納しています。

```js:vector_tile_source.js
 99         this._tileJSONRequest = loadTileJSON(this._options, this.map._requestManager, (err, tileJSON) => {
100             this._tileJSONRequest = null;
101             this._loaded = true;
102             if (err) {
103                 this.fire(new ErrorEvent(err));
104             } else if (tileJSON) {
105                 extend(this, tileJSON);
106                 if (tileJSON.bounds) this.tileBounds = new TileBounds(tileJSON.bounds, this.minzoom, this.maxzoom);
107                 postTurnstileEvent(tileJSON.tiles, this.map._requestManager._customAccessToken);
108                 postMapLoadEvent(tileJSON.tiles, this.map._getMapId(), this.map._requestManager._skuToken, this.map._requestManager._customAccessToken);
109
110                 // `content` is included here to prevent a race condition where `Style#_updateSources` is called
111                 // before the TileJSON arrives. this makes sure the tiles needed are loaded once TileJSON arrives
112                 // ref: https://github.com/mapbox/mapbox-gl-js/pull/4347#discussion_r104418088
113                 this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
114                 this.fire(new Event('data', {dataType: 'source', sourceDataType: 'content'}));
115             }
116         });
```

street-v8 tilesetの場合は[こんなJSON](https://api.mapbox.com/v4/mapbox.mapbox-streets-v8.json?access_token=pk.eyJ1IjoieW9jaGkiLCJhIjoiY2tjZThvdWExMDV2dDJxcDgxZzBwbzlxYSJ9.M0yRA6SXDMRgXzXGuYnvsg)が取得できます。Attributionが設定されているのがわかりますね。

```json
{
  "attribution": "<a href=\"https://www.mapbox.com/about/maps/\" target=\"_blank\">&copy; Mapbox</a> <a href=\"http://www.openstreetmap.org/about/\" target=\"_blank\">&copy; OpenStreetMap</a> <a class=\"mapbox-improve-map\" href=\"https://www.mapbox.com/map-feedback/\" target=\
"_blank\">Improve this map</a>",
  "bounds": [
    -180,
    -85,
    180,
    85
  ],
  "center": [
    0,
    0,
    0
  ],
...
```

### いつでもTileJSON APIを呼ぶの？
TileJSON APIを呼ぶかどうかはStyleの記述の仕方により異なります。

以下のように`url`で指定されている場合はTileJSON APIが呼ばれます。

```json:style.json
sources: {
    street: {
        type: 'vector',
        url: 'mapbox://mapbox.mapbox-streets-v8'
    }
},
```

以下のように`tiles`で指定されている場合はTileJSON APIは呼ばれません。

```json:style.json
sources: {
    street: {
        type: 'vector',
        tiles: ['http://a.tiles.mapbox.com/v4/mapbox.mapbox-streets-v8/{z}/{x}/{y}.vector.pbf?access_token=pk.eyJ1IjoieW9jaGkiLCJhIjoiY2tjZThvdWExMDV2dDJxcDgxZzBwbzlxYSJ9.M0yRA6SXDMRgXzXGuYnvsg'],
        tileSize: 512
    }
},
```

これは`loadTileJSON`で実行される処理内で`options.url`による分岐が発生し、[urlを指定しているときのみTileJSON APIを呼ぶ実装](https://github.com/mapbox/mapbox-gl-js/blob/v1.13.0/src/source/load_tilejson.js#L34-L38)となっているためです。


```js:load_tilejson.js
 34     if (options.url) {
 35         return getJSON(requestManager.transformRequest(requestManager.normalizeSourceURL(options.url), ResourceType.Source), loaded);
 36     } else {
 37         return browser.frame(() => loaded(null, options));
 38     }

```

## Styleでのattributionの指定
[Style Specification](https://docs.mapbox.com/mapbox-gl-js/style-spec/)にあるように、[styleのsourceでもattributionを指定](https://docs.mapbox.com/mapbox-gl-js/style-spec/sources/#vector-attribution)できます。

TileJSON APIでattributionを取得したかどうかに関わらず、styleで指定されたattributionによる上書きが行われます(以下の`extend(tileJSON, options)`の処理)。

```js:load_tilejson.js
 14     const loaded = function(err: ?Error, tileJSON: ?Object) {
 15         if (err) {
 16             return callback(err);
 17         } else if (tileJSON) {
 18             const result: any = pick(
 19                 // explicit source options take precedence over TileJSON
 20                 extend(tileJSON, options),
 21                 ['tiles', 'minzoom', 'maxzoom', 'attribution', 'mapbox_logo', 'bounds', 'scheme', 'tileSize', 'encoding']
 22             );
 23
 24             if (tileJSON.vector_layers) {
 25                 result.vectorLayers = tileJSON.vector_layers;
 26                 result.vectorLayerIds = result.vectorLayers.map((layer) => { return layer.id; });
 27             }
 28
 29             result.tiles = requestManager.canonicalizeTileset(result, options.url);
 30             callback(null, result);
 31         }
 32     };
```


## イベントチェーン

アトリビューション取得後は、イベントチェーンにより`AttributionControl`クラスへ処理が渡されます。

Styleロード時に`Map`クラスに対して[イベントを発行](https://github.com/mapbox/mapbox-gl-js/blob/v1.13.0/src/style/style.js#L280)します。

```js:style.js
244     _load(json: StyleSpecification, validate: boolean) {
...
252         for (const id in json.sources) {
253             this.addSource(id, json.sources[id], {validate: false});
254         }
...
280         this.fire(new Event('data', {dataType: 'style'}));
...
```
これを受けて`Map`クラスは`styledata`[イベントを発行](https://github.com/mapbox/mapbox-gl-js/blob/v1.13.0/src/ui/map.js#L481)します

```js:map.js
479         this.on('data', (event: MapDataEvent) => {
480             this._update(event.dataType === 'style');
481             this.fire(new Event(`${event.dataType}data`, event));
482         });
```

`AttrubutionControl`クラスはこのイベントを受けた際に、attributionの変更を行います。`source.attribution`にattributionが格納されています。

```js:attribution_control.js
...
71         this._map.on('styledata', this._updateData);
...
136     _updateData(e: any) {
137         if (e && (e.sourceDataType === 'metadata' || e.sourceDataType === 'visibility' || e.dataType === 'style')) {
138             this._updateAttributions();
139             this._updateEditLink();
140         }
141     }
...
143     _updateAttributions() {
...
165         const sourceCaches = this._map.style.sourceCaches;
166         for (const id in sourceCaches) {
167             const sourceCache = sourceCaches[id];
168             if (sourceCache.used) {
169                 const source = sourceCache.getSource();
170                 if (source.attribution && attributions.indexOf(source.attribution) < 0) {
171                     attributions.push(source.attribution);
172                 }
173             }
174         }
```


# Mapbox wordmark
Mapbox wordmark表示・非表示は`Map`クラスのオプションでコントールすることができません。

以下のコードでMapbox wordmarkを初期化しています。

```js:map.js
362     constructor(options: MapOptions) {
...
472         this.addControl(new LogoControl(), options.logoPosition);
...
510     addControl(control: IControl, position?: ControlPosition) {
...
522         const controlElement = control.onAdd(this);
...
```

```js:logo_control.js
 27     onAdd(map: Map) {
 28         this._map = map;
...
 39         this._map.on('sourcedata', this._updateLogo);
 40         this._updateLogo();
...
 58     _updateLogo(e: any) {
 59         if (!e || e.sourceDataType === 'metadata') {
 60             this._container.style.display = this._logoRequired() ? 'block' : 'none';
 61         }
 62     }
 63
 64     _logoRequired() {
 65         if (!this._map.style) return;
 66
 67         const sourceCaches = this._map.style.sourceCaches;
 68         for (const id in sourceCaches) {
 69             const source = sourceCaches[id].getSource();
 70             if (source.mapbox_logo) {
 71                 return true;
 72             }
 73         }
 74
 75         return false;
 76     }
```

Styleを読み込んだ際、ソースに`mapbox_logo`があるかどうかで表示・非表示を切り替えていることがわかります。つまりTileJSON APIもしくはStyleのSourceに`mapbox_logo: true`が含まれていればMapbox wordmarkが表示されます(Style Specificationに`mapbox_logo`はありませんが…)。

![tilejson.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/85105/be2881b9-e810-667f-017a-6cbf9601d7f7.png)


次回はユーザーコードで実際に挙動を見ていきます。