본 애플리케이션 예제는 Angular 프레임워크를 사용한다. Angular 프레임워크를 사용하고 위해서 Angular CLI를 먼저 설치한다.
$ npm install -g @angular/cliAngular 프로젝트를 생성하기 위해 Angular CLI 명령어인 ng 를 사용한다.
$ ng new CrowdSensorCloudWeb프로젝트는 다음과 같은 구조가 생성된다.
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.626ZHash: bc2f413cf1b936806099Time: 11093mschunk {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 을 입력해서 확인한다.
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 디렉토리는 다음과 같다.
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의 MatToolbarModule과 MatButtonModule을 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;}지도 엔진 중에 OpenSource 엔진인 leaflet 을 사용해보자.
$ npm install -s leafletNPM 을 이용하여 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 디렉토리는 다음과 같이 변경되어야 한다.
변경된 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: '© 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 {}차트 엔진 중에 highcharts를 사용해보자.
$ npm install -s highchartsNPM 을 이용하여 highcharts을 설치한다.
1. Highchart 모듈 사용 선언
app.component.ts 파일을 편집하여 다음과 같이 highcharts를 import 한다.
...import * as Highcharts from 'highcharts';import * as Exporting from 'highcharts/modules/exporting';// @ts-ignoreExporting(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();}