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/
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;
}
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/
변경된 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 {}
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();
}