Application 따라하기

1. 준비 하기

  • NodeJS >= 6.9.0
  • NPM >= 3.0.0

2. Angular CLI 설치

본 애플리케이션 예제는 Angular 프레임워크를 사용한다. Angular 프레임워크를 사용하고 위해서 Angular CLI를 먼저 설치한다.

$ npm install -g @angular/cli

3. Angular 프로젝트 생성

Angular 프로젝트를 생성하기 위해 Angular CLI 명령어인 ng 를 사용한다.

$ ng new CrowdSensorCloudWeb

프로젝트는 다음과 같은 구조가 생성된다.

  • CrowdSensorCloudWeb/
    • .git/
    • e2e/
    • node_modules/
    • src/
    • .editorconfig
    • .gitignore
    • angular.json
    • package.json
    • package-lock.json
    • tsconfig.json
    • tslint.json
    • README.md

4. 프로젝트 실행

Angular 프로젝트 개발 결과를 확인할 수 있도록 프로젝트 디렉토리로 이동한 후

$ cd CrowdSensorCloudWeb/

다음과 같이 개발 서버를 실행한다.

$ npm run start

혹은 다음과 같이 Angular CLI 명령어로 실행해도 결과는 같다.

$ ng serve


다음과 같이 TypeScript를 JavaScript로 빌드하는 과정을 거칠 것이다.

** Angular Live Development Server is listening on localhost:4200, open your browser on http://localhost:4200/ **

Date: 2019-05-26T10:39:01.626Z
Hash: bc2f413cf1b936806099
Time: 11093ms
chunk {main} main.js, main.js.map (main) 24.8 kB [initial] [rendered]
chunk {polyfills} polyfills.js, polyfills.js.map (polyfills) 237 kB [initial] [rendered]
chunk {runtime} runtime.js, runtime.js.map (runtime) 6.08 kB [entry] [rendered]
chunk {styles} styles.js, styles.js.map (styles) 226 kB [initial] [rendered]
chunk {vendor} vendor.js, vendor.js.map (vendor) 7.6 MB [initial] [rendered]
i 「wdm」: Compiled successfully.

이제 웹브라우저를 열고 localhost:4200 을 입력해서 확인한다.

5. 머트리얼 디자인 적용하기

Angular 의 Material 을 적용한다.


1. NPM 을 이용하여 @angular/material 관련 패키지를 설치한다.

$ npm install -s @angular/material @angular/cdk @angular/animations


프로젝트 파일에서 angular.json 파일을 수정하여 Material 디자인의 스타일시트를 적용한다.

{
  ...,
  "projects": {
    "crowd-sensor-cloud-web": {
      ...,
      "architect": {
        "build": {
          ...,
          "options": {
            ...,
            "styles": [
              "./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
              "src/styles.css",
            ],
            ...

위는 Material 디자인의 색상 테마중 딥퍼플-앰버 조합을 기본으로 정하였다.

다른 테마를 원하는 경우 찾아서 적용하거나, 직접 스타일을 만들어서 사용해도 된다.


2. Material 디자인에서 애니메이션을 사용하기 위해 몇 가지 수정을 해야한다.

현재 프로젝트의 src 디렉토리는 다음과 같다.

  • src/
    • app/
      • app.component.html
      • app.component.css
      • app.component.spec.ts
      • app.component.ts
      • app.module.ts


app.module.ts 파일을 열어 다음과 같이 수정한다.

import {BrowserAnimationsModule} from '@angular/platform-browser/animations';

@NgModule({
  ...
  imports: [BrowserAnimationsModule],
  ...
})
export class AppModule {}

위 예에서 보는 것 처럼 BrowserAnimationsModule 을 import 하고, @NgModule 에 imports 속성의 배열에 BrowserAnimationsModule 모듈을 추가한다.


3. Material 디자인의 툴바 모듈을 사용할 수 있게 import 한다.

app.module.ts 파일에 Material의 MatToolbarModuleMatButtonModule을 imports에 추가한다. MatToolbarModule은 머트리얼 디자인의 툴바를 사용할 수 있게 하고, MatButtonModule은 머트리얼 디자인의 버튼을 사용할 수 있게 해준다.

...
import {MatButtonModule, MatToolbarModule} from '@angular/material';
@NgModule({
  ...
  imports: [
      ...,
      MatButtonModule, 
      MatToolbarModule
  ],
  ...
})
export class AppModule {}


4. 화면에 상단 툴바와 메뉴를 구성한다.

app.component.html 파일을 열어 다음과 같이 편집한다.

<mat-toolbar class="mat-elevation-z8" color="primary">
  <span>국민대학교 크라우드 센서 클라우드:Air@Home&Mobile</span>
  <span class="spacer"></span>
  <a mat-button routerLink="/collect/sheet">데이터시트</a>
  <a mat-button routerLink="/register/my-device">내 장치 등록</a>
  <a mat-button href="https://air.cs.kookmin.ac.kr">KMU Air</a>
</mat-toolbar>

app.component.css 파일을 열어 다음과 같이 편집한다.

// 메뉴 중앙 띄우기
.spacer {
  flex: 1 1 auto;
}

6. 지도 표현하기

지도 엔진 중에 OpenSource 엔진인 leaflet 을 사용해보자.

$ npm install -s leaflet

NPM 을 이용하여 leaflet 을 설치한다.


1. leaflet 지도 엔진의 스타일시트 및 애셋 파일 위치를 지정한다.

프로젝트 디렉토리에서 angular.json 파일을 열어 다음의 위치를 찾아 추가한다.

{
  ...,
  "projects": {
    "crowd-sensor-cloud-web": {
      ...,
      "architect": {
        "build": {
          ...,
          "options": {
            ...,
            "assets": [
              "src/favicon.ico",
              "src/assets",
              {
                "glob": "**/*",
                "input": "./node_modules/leaflet/dist/images",
                "output": "leaflet/"
              }
            ],
            "styles": [
              "./node_modules/@angular/material/prebuilt-themes/deeppurple-amber.css",
              "src/styles.css",
              "./node_modules/leaflet/dist/leaflet.css"
            ],
            ...

위의 굵은 글씨로 표시한 부분의 위치를 찾아 추가해준다.


적용하려면 개발 서버를 재시작해야 할 수 있다.


2. 화면 상에 지도를 표시할 위치를 정한다.

app.component.html 파일을 열고 다음과 같이 수정한다.

<mat-toolbar class="mat-elevation-z8" color="primary">
...
</mat-toolbar>
<div class="map-container">
  <div class="map" #map></div>
</div>


보다 편한 방법으로 스타일을 적용하기 위해서 css 문법 대신 scss 문법을 사용하기 위해 app.component.css 를 app.component.scss로 변경한다.

프로젝트의 src 디렉토리는 다음과 같이 변경되어야 한다.

  • src/
    • app/
      • app.component.html
      • app.component.scss
      • app.component.spec.ts
      • app.component.ts
      • app.module.ts

변경된 app.component.scss 를 반영하기 위해 app.component.ts 파일을 다음과 같이 수정한다.

...

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent {
  title = 'crowd-sensor-cloud-web';
}

app.component.scss 파일을 수정하여 .map-container와 .map에 대한 스타일을 정한다.

...

// 지도 부분 스타일을 추가
.map-container {
  width: 100%;
  height: calc(100% - 64px);
  position: relative;

  .map {
    width: 100%;
    min-height: 100%;
  }
}


3. LeafLet 지도를 화면에 나타내도록 코드를 작성한다.

app.component.ts 파일을 다음과 같이 수정한다.

import {AfterViewInit, Component, ElementRef, OnInit, ViewChild} from '@angular/core';

import * as L from 'leaflet';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit, AfterViewInit {
  title = 'crowd-sensor-cloud-web';
  @ViewChild('map')
  mapElement: ElementRef;
  map: L.Map;
  
  ngOnInit(): void {
    this.map = L.map(this.mapElement.nativeElement, {
      layers: [
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
          attribution: '&copy; OpenStreetMap contributors'
        })
      ]
    });
  }

  ngAfterViewInit(): void {
  }
}

ngOnInit 라이프사이클 함수에 지도 생성 함수를 추가한다. 지도는 OpenStreetMap을 사용하였다. 다른 지도를 사용하고자 하는 경우 leaflet 사이트를 참고하여 수정한다.


4. 시작시 현재 접속 위치로 이동하기

ngOnInit(): void {
  ...
  this.map.on('locationfound', (e) => this.onLocationFound(e));
}

ngAfterViewInit(): void {
  this.map.locate({setView: true, maxZoom: 17});
}

onLocationFound(event) {
  const radius = event.accuracy / 2;
  L.marker(event.latlng).addTo(this.map)
    .bindPopup('이 곳에서' + radius + ' 미터 이내에 있습니다.').openPopup();
  L.circle(event.latlng, radius).addTo(this.map);
  this.map.setView(event.latlng, 15);
}

위와 같이 locationfound 이벤트 핸들러를 등록한다.


5. 화면 이동시 마다 등록된 장치를 조회하여 표시하기

...
import {HttpClient, HttpErrorResponse, HttpParams} from '@angular/common/http';
import {BehaviorSubject, of} from 'rxjs';
import {catchError, delay, filter, flatMap, groupBy, map, mergeMap, switchMap, tap, toArray} from 'rxjs/operators';

interface Device {
  lat: number;
  long: number;
  device: string;
  timestamp: Date;
  histories?: Device[];
  marker?: L.Marker;
  chart?: Highcharts.Chart;
}

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit, AfterViewInit {
  ...
  bounds = new BehaviorSubject<L.LatLngBounds>(undefined);

  ngOnInit(): void {
    ...
    this.bounds.pipe(
      filter(bound => !!bound),
      map(bound => new HttpParams().set(
        'ne', `${bound._northEast.lat},${bound._northEast.lng}`
      ).set(
        'sw', `${bound._southWest.lat},${bound._southWest.lng}`
      ).set(
        // 5분 단위로 캐쉬하도록 계산
        't', `${Math.floor(new Date().getTime() / (5 * 60 * 1000)) * 5 * 60 * 1000}`)
      ),
      switchMap(param => this.http.get('/device', {
        params: param,
        headers: {
          'x-api-key': environment.x_api_key
        }
      }).pipe(
        catchError((err) => {
          if (err instanceof HttpErrorResponse) {
            return of(err.error);
          }
          return of({
            status: 'error',
            error: '에러',
          });
        }),
      )),
      filter(resp => resp.status !== 'error'),
      flatMap(resp => resp.results),
      filter((item: Device) => {
        if (!this.devices[item.device]) {
          this.devices[item.device] = item;
        } else if (item.timestamp > this.devices[item.device].timestamp) {
          if (this.devices[item.device].marker) {
            this.devices[item.device].marker.remove();
          }
          this.devices[item.device] = item;
        } else {
          return false;
        }
        return true;
      }),
      tap((item: Device) => this.onAddMarker(item))
    ).subscribe();
  }

  ngAfterViewInit(): void {
    ...
    this.map.on('moveend', (e) => this.onMoveEnd(e));
  }
  
  onMoveEnd(event) {
    this.bounds.next(this.map.getBounds());
  }

  onAddMarker(device: Device) {
    item.marker = L.marker([item.lat, item.long]).addTo(this.map);
  }
}


6. HTTP 요청 활성화하기

app.module.ts 를 편집하여 HttpClientModule을 추가한다.

...
import {HttpClientModule} from '@angular/common/http';

@NgModule({
  ...,
  imports: [
    ...,
    HttpClientModule,
  ],
  ...
})
export class AppModule {}

7. 차트 표현하기

차트 엔진 중에 highcharts를 사용해보자.

$ npm install -s highcharts

NPM 을 이용하여 highcharts을 설치한다.


1. Highchart 모듈 사용 선언

app.component.ts 파일을 편집하여 다음과 같이 highcharts를 import 한다.

...
import * as Highcharts from 'highcharts';

import * as Exporting from 'highcharts/modules/exporting';

// @ts-ignore
Exporting(Highcharts);


2. Leaflet 지도에서 Popup시 차트 보여주기

app.component.ts 에서 onAddMarker 함수를 다음과 같이 고친다.

...

interface PopupEvent {
  source: L.popup;
  target: Device;
}

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

  onAddMarker(item: Device) {
    const chartElement = document.createElement('div');
    chartElement.style.width = '100%';
    chartElement.style.maxWidth = '340px';
    chartElement.style.minWidth = '340px';
    chartElement.style.minHeight = '320px';
    chartElement.style.maxHeight = '320px';
    item.marker = L.marker([item.lat, item.long]).addTo(this.map).bindPopup(chartElement);
    item.marker.on('popupopen', (e) => this.onShowChart({
      source: e,
      target: item,
    }, chartElement));
  }
}

그리고 차트 그리기 함수를 다음과 같이 작성한다. 차트의 형식 및 예제는 Highcharts 를 참고한다.

onShowChart(event: PopupEvent, container) {
  const chart = event.target.chart = Highcharts.chart(container, {
    chart: {
      type: 'spline',
      width: 300,
      scrollablePlotArea: {
        minWidth: 300,
      },
      events: {
        load: () => of(undefined).pipe(delay(10), tap(() => this.onLoadSensorData(chart, event.target))).subscribe()
      }
    },

    title: {
      text: `${event.target.device}`
    },

    subtitle: {
      text: '최근 500개의 측정자료'
    },

    xAxis: {
      type: 'datetime',
      title: {
        text: '시간',
      },
    },
    yAxis: [{ // left y axis
      title: {
        text: '온도'
      },
      labels: {
        format: '{value:.,0f}°C',
        enabled: false,
      },
    }, {
      gridLineWidth: 0,
      title: {
        text: '습도'
      },
      labels: {
        format: '{value:.,0f}%',
        enabled: false,
      },
    }, { // right y axis
      gridLineWidth: 0,
      opposite: true,
      title: {
        text: 'PM 10'
      },
      labels: {
        format: '{value:.,0f}㎍/m³',
        enabled: false,
      },
    }, {
      gridLineWidth: 0,
      opposite: true,
      title: {
        text: 'PM 2.5'
      },
      labels: {
        format: '{value:.,0f}㎍/m³',
        enabled: false,
      },
    }],

    legend: {
      align: 'left',
      verticalAlign: 'top',
      borderWidth: 0
    },

    tooltip: {
      shared: true,
      crosshairs: true
    },

    plotOptions: {
      series: {
        cursor: 'pointer',
        point: {
          events: {}
        },
        marker: {
          lineWidth: 1
        }
      }
    },

    series: [{
      name: 'Temperature',
      type: 'spline',
      yAxis: 0,
      tooltip: {
        valueSuffix: '°C',
      },
      data: []
    }, {
      name: 'Humidity',
      type: 'spline',
      yAxis: 1,
      tooltip: {
        valueSuffix: '%',
      },
      data: []
    }, {
      name: 'PM 10',
      type: 'spline',
      yAxis: 2,
      tooltip: {
        valueSuffix: '㎍/m³',
      },
      data: []
    }, {
      name: 'PM 25',
      type: 'spline',
      yAxis: 3,
      tooltip: {
        valueSuffix: '㎍/m³',
      },
      data: []
    }]
  });
}

onLoadSensorData(chart: Highcharts.Chart, target: Device) {
  const param = new HttpParams().set(
    'device', target.device,
  ).set(
    'count', '500'
  ).set(
    // 5분 단위로 캐쉬하도록 계산
    't', `${Math.floor(new Date().getTime() / (5 * 60 * 1000)) * 5 * 60 * 1000}`
  );
  this.http.get('/air', {
    params: param,
    headers: {
      'x-api-key': environment.x_api_key
    }
  }).pipe(
    flatMap((data: any) => data.results),
    flatMap((item: any) => [
      {key: 0, value: item.temperature, time: item.timestamp + 9 * 60 * 60 * 1000},
      {key: 1, value: item.humidity, time: item.timestamp + 9 * 60 * 60 * 1000},
      {key: 2, value: item.pm10, time: item.timestamp + 9 * 60 * 60 * 1000},
      {key: 3, value: item.pm25, time: item.timestamp + 9 * 60 * 60 * 1000},
    ]),
    groupBy(item => item.key),
    mergeMap(group => group.pipe(
      map(item => [item.time, item.value]),
      toArray(),
      tap(data => chart.series[group.key].setData(data)),
    )),
  ).subscribe();
}