Angularで簡単にWeb GISを作ってみた

[上司に「ひなたGISみたいなの午後までに作って」と言われた時のために](https://qiita.com/Yfuruchin/items/9f466d4bedcb5bda3038)
↑実際に言われた（？）ので、手持ちの技術で実現可能かどうか検証したかった記録です。

実際に動いているサイトはこちら。
https://angular-web-gis.firebaseapp.com/

GitHubはこちら。
https://github.com/kaito3desuyo/angular-web-gis

#AngularでOpenlayersを使う
JavaScriptによる地図表示ライブラリで有名なものとして、leafletとOpenlayersがあります。
両方ともAngularでラップしたコンポーネントがnpmで公開されているのですが、試しに使ってみたところ、OpenlayersのAngularラッパーである[quentin-ol/ngx-openlayers](https://github.com/quentin-ol/ngx-openlayers)のほうが、デザインや拡張性という観点から使いやすかったので、Openlayersの世界で修業を始めることにしました。

※leafletのAngularラッパーは[asymmetrik/ngx-leaflet](https://github.com/Asymmetrik/ngx-leaflet)です。

##地図を表示してみる
```html:openlayers-page.component.html
<div class="map-container" style="position: relative">
  <aol-map [width]="'100vw'" [height]="'100vh'">

    <aol-view [zoom]="zoom">
      <aol-coordinate [x]="135" [y]="35" [srid]="'EPSG:4326'"></aol-coordinate>
    </aol-view>
    <aol-layer-tile *ngFor="let map of maps" [opacity]="map.opacity" [visible]="map.visible">
      <aol-source-xyz [url]="map.url"></aol-source-xyz>
    </aol-layer-tile>

    <aol-interaction-default></aol-interaction-default>

    <aol-control-defaults></aol-control-defaults>
    <aol-control>
      <aol-content style="position:absolute; bottom: 0px; right: 0px;">

        <small style="background:rgba(255,255,255,0.54)">
          出典：<a href="https://maps.gsi.go.jp/development/ichiran.html">国土地理院</a>
        </small>

      </aol-content>
    </aol-control>

  </aol-map>

</div>
```

```typescript:openlayers-page.component.ts
import { Component, OnInit, ViewChild } from '@angular/core';
import { customAnimations } from 'src/app/animations';
import { ViewComponent } from 'ngx-openlayers';

@Component({
  selector: 'app-openlayers-page',
  templateUrl: './openlayers-page.component.html',
  styleUrls: ['./openlayers-page.component.scss']
})
export class OpenlayersPageComponent implements OnInit {

  zoom = 10;

  maps = [
    {
      name: '国土地理院　標準地図',
      url: 'https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png',
      opacity: 1,
      visible: true
    },
    {
      name: '国土地理院　陰影起伏図',
      url: 'https://cyberjapandata.gsi.go.jp/xyz/hillshademap/{z}/{x}/{y}.png',
      opacity: 0.5,
      visible: true
    }
  ];

  constructor() { }

  ngOnInit() {

  }

}
```

ngx-openlayersには、Openlayersで対応するクラス（？）と同じ名前のカスタムコンポーネントが定義されているので、階層さえ間違えなければこれだけで地図は表示されます。

``<aol-map>``で地図全体のサイズを指定、``<aol-view>``で初回読み込み時の中心位置やズームを指定、``<aol-layer-tile>``でタイルレイヤーを生成、``<aol-source-xyz>``でURLを指定するとタイル画像を取得してきてくれる、という感じです。

``<aol-interaction-default>``を指定しないと、ドラッグアンドドロップで地図を移動させたり、スクロールホイールで拡大／縮小ができなかったりするので注意が必要です。

##コントロールを追加する

※これより先、地図レイヤ情報のハンドリングに``akita``というFluxライクなストアライブラリを使用しています。

```html
<aol-control>
      <aol-content style="position:absolute; bottom: 0px; right: 0px;">

        <small style="background:rgba(255,255,255,0.54)">
          出典：<a href="https://maps.gsi.go.jp/development/ichiran.html">国土地理院</a>
        </small>

      </aol-content>
</aol-control>
```

``<aol-control-*>``というカスタムコンポーネントで、地図を操作するためのコンポーネントを定義できます。

###ズームボタン

```html:openlayers-page.component.html
<aol-control-zoom>
      <aol-content style="position:absolute; top: 24px; left: 24px;">
        <app-map-zoom-button [instance]="view.instance"></app-map-zoom-button>
      </aol-content>
</aol-control-zoom>
```

```html:map-zoom-button.component.html
<mat-card style="padding: 0px;" fxLayout="column">

  <div class="zoom-button" fxLayout="row" fxLayoutAlign="center" (click)="zoomIn()">
    <mat-icon>add</mat-icon>
  </div>

  <div>
    <mat-divider></mat-divider>
  </div>

  <div class="zoom-button" fxLayout="row" fxLayoutAlign="center" (click)="zoomOut()">
    <mat-icon>remove</mat-icon>
  </div>

</mat-card>
```

```typescript:map-zoom-button.component.ts
import { Component, OnInit, Input } from '@angular/core';

@Component({
  selector: 'app-map-zoom-button',
  templateUrl: './map-zoom-button.component.html',
  styleUrls: ['./map-zoom-button.component.scss']
})
export class MapZoomButtonComponent implements OnInit {

  @Input() instance: any;

  constructor() { }

  ngOnInit() {
    console.log(this.instance);
  }

  zoomIn() {
    const zoom = this.instance.getZoom() + 1;
    this.instance.setZoom(zoom);
  }

  zoomOut() {
    const zoom = this.instance.getZoom() - 1;
    this.instance.setZoom(zoom);
  }

}
```

ズームボタンを実装してみました。
``<aol-view>``のインスタンスを子コンポーネントに渡すと、Openlayers固有のメソッドが使えるようになるので、それを使って拡大縮小させます。

###レイヤ選択

```html
<aol-control>
      <aol-content style="position:absolute; top: 24px; right: 24px;">
        <app-map-layer-toolbar [maps]="maps"></app-map-layer-toolbar>
      </aol-content>
</aol-control>
```

```html:map-layer-toolbar.component.html
<mat-card fxLayout="row" style="max-width: 360px">
  <div [@openClose]="layerMenuState ? 'open' : 'closed'" style="overflow: hidden" fxLayout="column" fxLayoutGap="12px">

    <div cdkDropList (cdkDropListDropped)="drop($event)">
      <div cdkDrag class="map-selector" *ngFor="let map of maps; let i = index">
        <div class="map-operation-box" fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="12px">
          <button mat-icon-button cdkDragHandle>
            <mat-icon>
              reorder
            </mat-icon>
          </button>

          <div fxLayout="column" fxLayoutGap="8px" fxFlex="156px">
            <div>
              <p [matTooltip]="map.name">{{map.name}}</p>
            </div>
            <div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px" style="overflow: visible">
                <mat-checkbox [(ngModel)]="map.visible"></mat-checkbox>
                <mat-slider min="0" max="1" step="0.01" [(ngModel)]="map.opacity" fxFlex></mat-slider>
            </div>
          </div>
          <button mat-icon-button (click)="onClickDeleteLayer(map.id)">
            <mat-icon>delete</mat-icon>
          </button>
        </div>
      </div>
    </div>

    <button mat-icon-button (click)="onClickAddLayer()">
      <mat-icon>add</mat-icon>
    </button>
  </div>

  <div>
    <button mat-icon-button (click)="layerMenuState = !layerMenuState">
      <mat-icon>layers</mat-icon>
    </button>
  </div>
</mat-card>
```

```typescript:map-layer-toolbar.component.ts
import { Component, OnInit, Input } from '@angular/core';
import { customAnimations } from 'src/app/animations';
import { MatDialog } from '@angular/material/dialog';
import { MapLayerAddDialogComponent } from '../map-layer-add-dialog/map-layer-add-dialog.component';
import { MapLayerService, MapLayerQuery, MapLayer } from 'src/app/stores/map-layer/state';
import { ID } from '@datorama/akita';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';

@Component({
  selector: 'app-map-layer-toolbar',
  templateUrl: './map-layer-toolbar.component.html',
  styleUrls: ['./map-layer-toolbar.component.scss'],
  animations: customAnimations
})
export class MapLayerToolbarComponent implements OnInit {

  layerMenuState = false;
  @Input() maps: MapLayer[] = [];

  constructor(
    private dialog: MatDialog,
    private mapLayerQuery: MapLayerQuery,
    private mapLayerService: MapLayerService
  ) { }

  ngOnInit() {
  }

  drop(event: CdkDragDrop<MapLayer[]>) {
    moveItemInArray(this.maps, event.previousIndex, event.currentIndex);
    console.log('ドラッグアンドドロップ', this.maps);
    this.mapLayerService.set(this.maps);
  }

  onClickAddLayer() {
    console.log('レイヤー追加ダイアログを開く');
    const dialogRef = this.dialog.open(MapLayerAddDialogComponent, {
      width: '800px',
      data: {}
    });

    dialogRef.afterClosed().subscribe((result: Partial<MapLayer> | undefined) => {
      console.log('レイヤー追加ダイアログを閉じる', result);
      if (result) {
        // ストアへ編集を反映させる
        this.mapLayerService.set(this.maps);
        this.mapLayerService.add({
          ...result,
          opacity: 1,
          visible: true
        });
        this.maps = this.mapLayerQuery.getAll();
      }
    });
  }

  onClickDeleteLayer(id: ID) {
    console.log('レイヤー削除', id);
    // ストアへ編集を反映させる
    this.mapLayerService.set(this.maps);
    this.mapLayerService.delete(id);
    this.maps = this.mapLayerQuery.getAll();
  }

}
```

ごちゃごちゃといろいろ書かれていますので細かく説明します。

```html
<div fxLayout="row" fxLayoutAlign="start center" fxLayoutGap="8px" style="overflow: visible">
    <mat-checkbox [(ngModel)]="map.visible"></mat-checkbox>
    <mat-slider min="0" max="1" step="0.01" [(ngModel)]="map.opacity" fxFlex></mat-slider>
</div>
```

レイヤの透過度や表示／非表示をこの部分で操作しています。
やっていることは非常に単純で、``<mat-checkbox>``と``<mat-slider>``に``[(ngModel)]``でオブジェクトプロパティを割り当てているだけ。ここをいじっただけだとストア内のレイヤ情報には反映されないので、レイヤの追加／削除時に適宜ストアを明示的に上書きしてやります。

```html:map-layer-add-dialog.component.html
<div class="map-layer-add-dialog-container" fxLayout="column" fxLayoutGap="24px">
  <h2>レイヤーを追加する</h2>
  <div fxLayout="column" fxLayoutGap="12px" [formGroup]="formData">
    <mat-form-field>
      <mat-select (selectionChange)="selectPreset($event)" placeholder="プリセットを選択できます">
        <mat-optgroup *ngFor="let preset of presets" [label]="preset.groupName">
            <mat-option *ngFor="let map of preset.children" [value]="map">{{map.name}}</mat-option>
        </mat-optgroup>
      </mat-select>
    </mat-form-field>
    <mat-form-field>
      <input matInput placeholder="名前" formControlName="name">
      <mat-error *ngIf="formData.get('name').hasError('required')">
        名前は<strong>必須項目</strong>です
      </mat-error>
    </mat-form-field>
    <mat-form-field>
      <input matInput placeholder="URL" formControlName="url">
      <mat-error *ngIf="formData.get('url').hasError('required')">
        URLは<strong>必須項目</strong>です
      </mat-error>
    </mat-form-field>
  </div>
  <div fxLayout="row" fxLayoutAlign="end center" fxLayoutGap="12px">
    <button mat-button (click)="onCancel()">キャンセル</button>
    <button mat-raised-button color="primary" (click)="onSubmit()" [disabled]="formData.invalid">追加する</button>
  </div>
</div>
```

```typescript:map-layer-add-dialog.component.ts
import { Component, OnInit, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { FormBuilder, Validators } from '@angular/forms';
import { MatSelectChange } from '@angular/material/select';

@Component({
  selector: 'app-map-layer-add-dialog',
  templateUrl: './map-layer-add-dialog.component.html',
  styleUrls: ['./map-layer-add-dialog.component.scss']
})
export class MapLayerAddDialogComponent implements OnInit {

  formData = this.fb.group({
    name: ['', Validators.required],
    url: ['', Validators.required]
  });

  presets = defaultPresets;

  constructor(
    public dialogRef: MatDialogRef<MapLayerAddDialogComponent>,
    @Inject(MAT_DIALOG_DATA) data: any,
    private fb: FormBuilder
  ) { }

  ngOnInit() {
  }

  selectPreset(event: MatSelectChange) {
    console.log('プリセット選択', event);
    this.formData.setValue(event.value);
  }

  onCancel() {
    this.dialogRef.close();
  }

  onSubmit() {
    this.dialogRef.close(this.formData.value);
  }

}

const defaultPresets: { groupName: string, children: { name: string, url: string }[] }[] = [
  {
    groupName: '国土地理院',
    children: [
      {
        name: '国土地理院 標準地図',
        url: 'https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png',
      },
      //...その他のマップタイルサーバ情報が続く
    ]
  }
];
```

レイヤー追加ダイアログです。ごく簡単なFormGroupのみで構成されています。
名前と、マップタイル取得に必要な``url``の入力を受け付けて、先ほどの``map-layer-add-dialog.component.ts``に返します。
国土地理院データ限定ですが、プリセット機能も作ってみました。

#まとめ
地図がリアクティブに変わっていくのは見てて楽しいですが、これ、割と修羅の道なのでは？