ギャラリーを使って静止画地図上にクリック可能なPinを配置するコンポーネントを作る

::: note info
2024/12/11追記
マイクロソフト様により、プレミアム機能ではありますが、マップコントロールが神アップデートしています。動的地図なので移動や拡大縮小が自由、地図タップでのタップ位置（緯度経度）の取得が可能です。Pin数も5000に増え、タップでイベントを発生してくれるため、処理が書きやすくなっています。
[ご利用のキャンバス アプリに地理空間コントロールを追加する](https://learn.microsoft.com/ja-jp/power-apps/maker/canvas-apps/geospatial-overview)
:::
この記事は「[PowerAppsでBingMapsを活用する時のお決まり計算をコンポーネントに](https://qiita.com/kenjik/items/d7ef4c5ba05cb8997ae7)」「[BingMapsScaleコンポーネントの改善](https://qiita.com/kenjik/items/3b6c25b295b41718de52)」「[地図表示範囲内のポイントを近い順に絞り込む（BingMapsScaleコンポーネントの改善２）](https://qiita.com/kenjik/items/ecf871c0966ba96ccd34)」の続きになります。
もう忘れちゃった？...私も忘れていました。

# はじめに
　前回記事を投稿してから長い時間が経ってしまいました。というのも、[Pin数が18に限られた中で](https://docs.microsoft.com/en-us/bingmaps/rest-services/imagery/get-a-static-map#pushpin-limits)地図をタップ可能にしても、実務上使い物にならないことが多く、かといって沢山のコントロールを自前でPin用に準備する（当時はそう思っていました）のも面倒になって、急速にやる気が失せてしまったためです。少しでも興味を持ってくださった方には、ほったらかしにして申し訳ありませんでした。
　ところが先日「写真の好きな場所にTextInputを追加して数字を入力し、合計を出したい」というニーズがあって、ギャラリーで実現したところ、「これをアイコン画像に代えたら地図に配置出来るんじゃね？」と閃き、ついに３年の時を経て送り出すことが出来ました。
（すでに動的地図のコントロールも出ている中で、静止画かよ、という声もありそうですが）
**追記：この手法、Power Appsでゲームを作られている方にはメジャーな方法だったようです。３年もかけずに、もっと勉強しておくんだった...**

# ギャラリーを使った画像の配置
　コンポーネントを作成する前に、ギャラリーで画像をどのようにして２次元配置したかを説明します。ただしこれはあくまで私個人の試行錯誤の結果であり、ある日突然出来なくなるかも知れないことをご了承ください。

## 確認手順
>1 垂直ギャラリーコントロールを用意します。
2 ギャラリーのItemsにSequence(50)を入れます。
3 ギャラリー内のイメージコントロール以外を削除します。
4 イメージコントロールのXの値をThisItem.Valueにします（見やすくするため）。
5 イメージコントロールのYの値を0にします。
6 ギャラリーのTemplatePaddingの値を0にします。
7 ギャラリーのTemplateSizeの値を0にします。

これだけです！
って、何かおかしいですよね。
![スクリーンショット 2022-12-07 110802.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278581/8542299e-1d67-f689-56d2-37776eb050d2.png)
画像が少しずつ下にずれているようです。それに、TemplateSizeに0を入れたはずなのに、プロパティの「テンプレートのサイズ」の値は1になっています！どうやらTemplateSizeは1以上でないとダメなようです。
そこで、5番目と7番目の処理を以下のように変更します。

>5 イメージコントロールのYの値を「1 - ThisItem.Value」にします。
7 ギャラリーのTemplateSizeの値を1にします。

![スクリーンショット 2022-12-07 110956.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278581/1df1dde3-e7c0-b143-74f1-cdeec9058281.png)
今度はバッチリ合ったようです。

考え方はこれだけなので後は省略しますが、Yの補正だけ考えれば、ギャラリーコントロールの左上を起点として好きな場所に配置可能なようでした。
# コンポーネントを作ろう
## 準備するもの
* 過去３つの記事で作成した最終形態の「cmpBingMapsScale」コンポーネント
## まずは出来上がりから
コンポーネント（cmpBingMapsTapMap）には、地図と、中心を示す十字アイコン、Pinとラベルを表示するためのギャラリーが含まれます。
![スクリーンショット 2022-12-07 223829.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278581/fbfa92fe-065b-df6f-c7d3-bbc7f2575586.png)

既に初期データの値でアイコン画像が散らばっていることがわかると思います。

## カスタムプロパティ（入力項目）の設定

| 表示名/名前 | プロパティの型 | データ型 | コメント |
|:-:|:-:|:-:|:-:|
| CenterLatitude | 入力 | 数値 | 地図中央の緯度。初期値は仮に35とします |
| CenterLongitude | 入力 | 数値 | 地図中央の経度。初期値は仮に135とします |
| ZoomLevel | 入力 | 数値 | ズームレベル。初期値は仮に15とします |
| MapXPixels | 入力 | 数値 | 地図の横幅をピクセルで設定します。初期値は仮に400とします |
| MapYPixels | 入力 | 数値 | 地図の高さをピクセルで設定します。初期値は仮に300とします |
| BingMapsAPIKey | 入力 | テキスト | BingMapsのAPIキー。初期値はありません。APIキーが有効でない場合は地図が表示されません |
| TapPointSize | 入力 | 数値 | タップアイコン画像のサイズ。初期値は仮に40ピクセルとします |
| TapAbove | 入力 | ブール値 | タップアイコン画像の配置をポイントの上部にするか中央にするか。初期値はtrue（ポイントの上部）とします |
| PointList | 入力 | テーブル | 対象とするポイントのリスト。以下参照 |
### 入力テーブル（PointList）の構造
| 項目名 | データ型 | コメント |
|:-:|:-:|:-:|
| ID | 数値 | 入力テーブルをギャラリーに設定した時のインデックス番号。Y座標の補正で使うため、必ず１から始まり１ずつ増えなければなりません。本来コンポーネント内で作るべき項目ですが、内部で計算させるとコンポーネント作成中のPin位置が0,0に固定されてしまい理解しづらいため、今回は外部入力とします |
| UniqueKey | テキスト | データを一意に区別可能な文字列を設定してください |
| PointLatitude | 数値 | 緯度 |
| PointLongitude | 数値 | 経度 |
| GapX | 数値 | 地図の中心からの経度方向のピクセル差 |
| GapY | 数値 | 地図の中心からの緯度方向のピクセル差 |
| PinImage | 画像 | アイコン画像 |
| LabelColor | 色 | ラベルの色 |
| LabelString | テキスト | アイコン画像上部に表示されるラベルテキスト |

```php:cmpBingMapsTapMap.PointListに今回設定した初期値
Table(
{ID:1, UniqueKey:"0000000004", PointLatitude:35.708014, PointLongitude:139.756669, GapX:0, GapY:0, PinImage:SampleImage, LabelColor:Blue, LabelString:"ラベル1"},
{ID:2, UniqueKey:"0000000032", PointLatitude:35.708093, PointLongitude:139.758136, GapX:20, GapY:-60, PinImage:SampleImage, LabelColor:Blue, LabelString:"ラベル2"},
{ID:3, UniqueKey:"0000000050", PointLatitude:35.708818, PointLongitude:139.754156, GapX:50, GapY:-90, PinImage:SampleImage, LabelColor:Blue, LabelString:"ラベル3"},
{ID:4, UniqueKey:"0000000091", PointLatitude:35.707769, PointLongitude:139.758134, GapX:-10, GapY:-40, PinImage:SampleImage, LabelColor:Blue, LabelString:"ラベル4"},
{ID:5, UniqueKey:"0000000092", PointLatitude:35.708093, PointLongitude:139.758136, GapX:-40, GapY:60, PinImage:SampleImage, LabelColor:Blue, LabelString:"ラベル5"},
{ID:6, UniqueKey:"0000000121", PointLatitude:35.708578, PointLongitude:139.757479, GapX:65, GapY:75, PinImage:SampleImage, LabelColor:Blue, LabelString:"ラベル6"},
{ID:7, UniqueKey:"0000000158", PointLatitude:35.708554, PointLongitude:139.756996, GapX:100, GapY:30, PinImage:SampleImage, LabelColor:Blue, LabelString:"ラベル7"}
)
```

## コンポーネント（cmpBingMapsTapMap）自体の設定
| プロパティ名 | 設定値 | コメント |
|:-:|:-:|:-:|
| Width | Self.MapXPixels | 地図と同じ大きさ |
| Height | Self.MapYPixels | 地図と同じ大きさ |

## コントロールの追加と設定

### コントロール一覧
| コントロール名 | 種類 | 説明 |
|:-:|:-:|:-:|
| imgTapMapSpace_cmpBMTM | 画像 | 地図を表示するスペースです |
| icoCenterMark_cmpBMTM | アイコン | 地図の中心を示す十字アイコンです。タップして選択をリセットします |
| galPinList_cmpBMTM | ギャラリー | Pinとラベルを表示するための情報を保持する垂直ギャラリーです |
| imgPin_cmpBMTM | 画像 | 上記ギャラリー中に配置するPinアイコン。タップすると出力項目が選択値に変わります。タップされたことはTapCounterの値の変化で把握します |
| lblPin_cmpBMTM | ラベル | 上記ギャラリー中に配置する、Pinを打つポイントのラベルです |

### 地図表示用画像コントロール（imgTapMapSpace_cmpBMTM）の設定
| プロパティ名 | 設定値 | コメント |
|:-:|:-:|:-:|
| Image | "https://dev.virtualearth.net/REST/v1/Imagery/Map/Road/" & cmpBingMapsTapMap.CenterLatitude & "," & cmpBingMapsTapMap.CenterLongitude & "/"& cmpBingMapsTapMap.ZoomLevel & "?ms=" &cmpBingMapsTapMap.MapXPixels & "," & cmpBingMapsTapMap.MapYPixels & " & c=ja-JP&Key="&cmpBingMapsTapMap.BingMapsAPIKey | このバージョンではPinを立てないので、シンプルな背景地図を切り出します |
| X | 0 |  |
| Y | 0 |  |
| Width | cmpBingMapsTapMap.MapXPixels |  |
| Height | cmpBingMapsTapMap.MapYPixels |  |

### 地図中央の十字アイコン（icoCenterMark_cmpBMTM）の設定
| プロパティ名 | 設定値 | コメント |
|:-:|:-:|:-:|
| Icon | Icon.Add |  |
| OnSelect | Set(gblSelectedItemID_cmpBMTM,0);<BR>Set(gblTappedUniqueKey_cmpBMTM,"");<BR>Set(gblTapCounter_cmpBMTM,0) | 押されたら出力項目をリセット |
| X | (imgTapMapSpace_cmpBMTM.Width-cmpBingMapsTapMap.TapPointSize)/2 | 十字が地図の中央に来るように設定 |
| Y | (imgTapMapSpace_cmpBMTM.Height-cmpBingMapsTapMap.TapPointSize)/2 | 十字が地図の中央に来るように設定 |
| Width | cmpBingMapsTapMap.TapPointSize |  |
| Height | cmpBingMapsTapMap.TapPointSize |  |
| Color | RGBA(255, 0, 0, 1) |  |

### ポイントPin表示用のギャラリー（galPinList_cmpBMTM）の設定
| プロパティ名 | 設定値 | コメント |
|:-:|:-:|:-:|
| TemplateSize | 1 |  |
| TemplatePadding | 0 |  |
| X | 0 |  |
| Y | 0 |  |
| Width | cmpBingMapsTapMap.MapXPixels |  |
| Height | cmpBingMapsTapMap.MapYPixels |  |
| Items | 以下参照 |  |
| OnSelect | 以下参照 |  |

```php:galPinList_cmpBMTM.Items
//入力リストに、ポイントのXYと、PinのXY（TapAboveによるY座標の調整含む）を追加
AddColumns(
    AddColumns(cmpBingMapsTapMap.PointList,
        "pointX",imgTapMapSpace_cmpBMTM.Width/2+GapX,
        "pointY",imgTapMapSpace_cmpBMTM.Height/2+GapY
    ),
    "pinX",pointX-cmpBingMapsTapMap.TapPointSize/2,
    "pinY",pointY-If(cmpBingMapsTapMap.TapAbove,cmpBingMapsTapMap.TapPointSize,cmpBingMapsTapMap.TapPointSize/2)
)
```

```php:galPinList_cmpBMTM.OnSelect
//タップされたポイントのIDを保存
Set(gblSelectedItemID_cmpBMTM,ThisItem.ID);
//IDに対応するレコードのユニークキーを保存
Set(gblTappedUniqueKey_cmpBMTM,ThisItem.UniqueKey);
//カウンターをカウントアップ
If(IsBlank(gblTapCounter_cmpBMTM),Set(gblTapCounter_cmpBMTM,1),Set(gblTapCounter_cmpBMTM,gblTapCounter_cmpBMTM+1))
```

### ギャラリー内のPin表示用の画像コントロール（imgPin_cmpBMTM）の設定
| プロパティ名 | 設定値 | コメント |
|:-:|:-:|:-:|
| Image | ThisItem.PinImage |  |
| X | ThisItem.pinX |  |
| Y | ThisItem.pinY + (1 - ThisItem.ID) | Y座標を調整 |
| Width | cmpBingMapsTapMap.TapPointSize |  |
| Height | cmpBingMapsTapMap.TapPointSize |  |
| OnSelect | Select(Parent) |  |

### ギャラリー内のラベル文字列用のラベルコントロール（lblPin_cmpBMTM）の設定
| プロパティ名 | 設定値 | コメント |
|:-:|:-:|:-:|
| Text | Left(ThisItem.LabelString,8) | ラベルが長すぎると改行してしまうため、ここでは8文字までとしました || Color | ThisItem.LabelColor |  |
| Size | 8 |  |
| Align | Align.Center |  |
| X | ThisItem.pointX-Self.Width/2 | ポイントと中央を合わせる |
| Y | imgPin_cmpBMTM.Y-Self.Height | Pinの上部に表示 |
| Width | 100 |  |
| Height | 10 |  |
| OnSelect | Select(Parent) |  |

## カスタムプロパティ（出力項目）の設定と紐づけ
| 表示名/名前 | プロパティの型 | データ型 | コメント | 出力する値 |
|:-:|:-:|:-:|:-:|:-:|
| TapCounter | 出力 | 数値 | タップ毎に加算される、タップを検知するためのカウンター | gblTapCounter_cmpBMTM |
| SelectedID | 出力 | 数値 | タップされたポイントのID | gblSelectedID_cmpBMTM
| SelectedUniqueKey | 出力 | テキスト | タップされたポイントのユニークキー | gblSelectedUniqueKey_cmpBMTM |

以上でコンポーネントの作成は終了です。

## タップされたことをどのように検知するか
　将来的にはカスタムプロパティでビヘイビアを使って、imgPin_cmpBMTM.OnSelectに関連付けたアクションを定義できるようになると思いますが、現時点では以下の各案程度でしょうか。
1. スライダーコントロールを設置し、DefaultにTapCounterを設定して、OnChangeに処理を書く
2. タイマーコントロールを配置し、定期的にTapCounterをチェックして増えていたらアクションを起こす
3. そもそもタップを検知せず、その時点のSelectedIDやSelectedUniqueKeyでLookUp等の処理を行う

# 試してみよう
　さて、コンポーネントが出来たので、動作させてみましょう。
とはいえ詳細に説明すると長くなるので、コンポーネント呼び出し前後以外は端折ってお話します（乱暴ですいません）。

## 出来上がりイメージ
　以下のようなアプリを作って動作を確認しました。
　今回も使うリストは、[東京都オープンデータカタログサイト](https://portal.data.metro.tokyo.lg.jp/)に掲載されている、[文京区のAED設置箇所一覧(aedsettikasyoitiran.xlsx)](https://catalog.data.metro.tokyo.lg.jp/dataset/t131059d0000000002)を事前に取り込んでいます。毎度ありがとうございます。
また、アイコン画像（私は素材サイト'ICOOON MONO'にあった'[地図マーカーのフリー素材6](https://icooon-mono.com/10986-%e5%9c%b0%e5%9b%b3%e3%83%9e%e3%83%bc%e3%82%ab%e3%83%bc%e3%81%ae%e3%83%95%e3%83%aa%e3%83%bc%e7%b4%a0%e6%9d%906/)'を使いました）を準備し、メディアに取り込んであります。

![スクリーンショット 2022-12-08 202738.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/278581/61516b22-80ae-e41f-3e42-488970b56f0c.png)

## アプリ作成のポイントの説明
### 主なコントロール
| コントロール名 | 種類 | 説明 |
|:-:|:-:|:-:|
| conParameters | コンテナ | このコンテナ内で、アプリで使う各種パラメーターをグローバル変数に設定します。<BR>gblLatitude: 地図中心の緯度<BR>gblLongitude: 地図中心の経度<BR>gblZoomLevel: ズームレベル<BR>gblMapWidth: 地図の幅<BR>gblMapHeight: 地図の高さ<BR>gblAPIKey: BingMapsのAPIキー<BR>また、cmpBingMapsScale_1の出力PointListOutputをcmpBingMapsTapMap_1のPointListに入力可能なように整形します |
| cmpBingMapsScale_1 | コンポーネント | 上記パラメーター値を使い、地図の緯度経度情報をピクセル情報に対応付けます。指定範囲内のポイントに絞り、中心から近い順にIDを振ります |
| cmpBingMapsTapMap_1 | コンポーネント | 今回作成したコンポーネントです。上記整形後の出力を受け取り、地図とPinを表示します |
| conInfoCard | コンテナ | 上記コンポーネント上のタップを検知して選択されたポイントを元データから抽出し、内容を表示します |
| dtTapMapList | データシート | cmpBingMapsTapMap_1に入力するリストを確認用に表示します |

### cmpBingMapsScale_1 の設定
| カスタムプロパティ | 設定値 | コメント |
|:-:|:-:|:-:|
| CenterLatitude | gblLatitude |  |
| CenterLongitude | gblLongitude |  |
| ZoomLevel | gblZoomLevel |  |
| MapXPixels | gblMapWidth |  |
| MapYPixels | gblMapHeight |  |
| PointListInput | AddColumns(RenameColumns(テーブル1,"NO","UniqueKey"),"PointLatitude",Round(Value(緯度),8),"PointLongitude",Round(Value(経度),8),"LabelString",名称) |  |
| MaxPoints | 50 | 適当な数字 |
| Visible | false | 非表示で使います |

### cmpBingMapsTapMap_1 の設定
| カスタムプロパティ | 設定値 | コメント |
|:-:|:-:|:-:|
| X | 700 | 適当に配置 |
| Y | 70 | 適当に配置 |
| CenterLatitude | gblLatitude |  |
| CenterLongitude | gblLongitude |  |
| ZoomLevel | gblZoomLevel |  |
| MapXPixels | gblMapWidth |  |
| MapYPixels | gblMapHeight |  |
| BingMapsAPIKey | gblAPIKey | 各自で取得したAPIキーです |
| TapPointSize | 40 | 適当な数字 |
| TapAbove | true | 準備した地図マーカーの場合 |
| PointList | colTapMapListInput | 以下参照 |

　cmpBingMapsScale_1 の出力 PointListOutput を cmpBingMapsTapMap_1 の PointList に渡すには、ID、PinImage、LabeColorの３項目を追加する必要があります。このうちIDの作成にはどうしても作業用のコレクションが必要なため、btnPrepareTapMapList に処理を設定してパラメーター更新時にSelectします。

```php:btnPrepareTapMapList.OnSelect の設定値
//TapMap用の入力リストを準備する
Clear(coltmp);
//ID列の追加
ForAll(Sequence(CountRows(cmpBingMapsScale_1.PointListOutput)),
    Collect(coltmp,{ID:Value,TR:Index(cmpBingMapsScale_1.PointListOutput,Value)})
);
//PinImage、LabelColor列の追加
ClearCollect(colTapMapListInput,
    ShowColumns(
        AddColumns(coltmp,
            "PinImage",地図マーカーのフリー素材6,
            "LabelColor",Color.Black,
            "UniqueKey",TR.UniqueKey,
            "PointLatitude",TR.PointLatitude,
            "PointLongitude",TR.PointLongitude,
            "GapX",TR.GapX,
            "GapY",TR.GapY,
            "LabelString",TR.LabelString
        ),
        "ID","UniqueKey","PointLatitude","PointLongitude","GapX","GapY","PinImage","LabelColor","LabelString"
    )
);
Clear(coltmp);
```

### conInfoCard.timGetSelectedRecord の設定
　選択されているレコードの内容を表示するだけならタイマーである必要はありませんが、使い方の例として設定します。
なお、タイマーコントロールは編集状態では動作しないので、動作確認は「再生」で行ってください。

| プロパティ名 | 設定値 | コメント |
|:-:|:-:|:-:|
| AutoStart | true |  |
| Repeat | true |  |
| Duration | 100 | 適当な値 |
| OnTimerEnd | If(cmpBingMapsTapMap_1.TapCounter<>gblTapCounter,<BR>    Set(gblTapCounter,cmpBingMapsTapMap_1.TapCounter);<BR>Set(gblSelectedRecord,LookUp(テーブル1,NO=cmpBingMapsTapMap_1.SelectedUniqueKey))<BR>) | TapCounterの値が変わっていたら、データソース（テーブル 1）から該当レコードを探して変数に入れる |

　あとは変数内の情報をカードで表示するだけです。
　以上がコンポーネントをアプリで利用するうえでのポイントです。
いかがでしょう。アプリは動作しましたでしょうか。

# 改善点はまだまだある
　これでPinの数に制約を受けることなくPinの表示とタップ対応が出来ましたが、まだまだ足りない点はあります。
1. Pinが重なったところでは上に表示されたポイントしかタップ出来ない
2. 外部から与えられるIDがルール通り採番されているとは限らない

長くなりましたのでこれ以上記事にはしませんが、私は以下のように対応しました。
1. 押されたPinとラベルの一部を含む長方形の範囲に入るポイントを抽出し、そこに別のギャラリーをポップアップさせて一覧から選択出来るようにしました。場合によってはポップアップがコンポーネント外にはみ出すことがあるので、位置の調整も必要になります。
2. galPinList_cmpBMTMに非表示ラベルを追加し、そこでインデックスを計算するようにしました。計算方法は[こちら](https://www.linkedin.com/pulse/power-apps-tip-calculate-index-elements-gallery-emmanuel-gallis/ "Power Apps TIP - Calculate the index of elements in a gallery")が参考になります。あるいは、リストがセットされたらOnResetでIDを振るのも良いかも知れません。うん、なんかこっちのほうが良さそう。

# 最後に
　本当は2022年のアドベントカレンダーで公開したかったのですが、準備が遅くて埋まってしまいました。
　今回Pin数18の呪縛から逃れられたお陰で、スマホでアプリを起動すると、地図上に現在位置近辺の商談先が様々なアイコンで表示され、気になったところをタップして各種情報にアクセス後、ワンタッチで電話連絡を入れてから訪問する、といったスマートな流れが可能になったのではないかと思います。
　とはいえ本当はマップコントロールを使って動的地図が簡単に作れるのが希望です。マイクロソフトさん、地図の拡充を何卒よろしくお願いします。
　それでは皆様、良いお年を。