diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 471906a..70b3e7b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + + + + + + (R.id.username).text diff --git a/frontend/angular.json b/frontend/angular.json index c1d4385..18c7fdb 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -25,7 +25,8 @@ "aot": true, "assets": [ "src/favicon.ico", - "src/assets" + "src/assets", + "src/manifest.webmanifest" ], "styles": [ "src/styles.scss", @@ -34,7 +35,8 @@ ], "scripts": [ "./node_modules/chart.js/dist/Chart.min.js" - ] + ], + "webWorkerTsConfig": "tsconfig.worker.json" }, "configurations": { "production": { @@ -63,7 +65,9 @@ "maximumWarning": "6kb", "maximumError": "10kb" } - ] + ], + "serviceWorker": true, + "ngswConfigPath": "ngsw-config.json" } } }, @@ -93,7 +97,8 @@ "karmaConfig": "karma.conf.js", "assets": [ "src/favicon.ico", - "src/assets" + "src/assets", + "src/manifest.webmanifest" ], "styles": [ "src/styles.scss" @@ -107,7 +112,8 @@ "tsConfig": [ "tsconfig.app.json", "tsconfig.spec.json", - "e2e/tsconfig.json" + "e2e/tsconfig.json", + "tsconfig.worker.json" ], "exclude": [ "**/node_modules/**" diff --git a/frontend/ngsw-config.json b/frontend/ngsw-config.json new file mode 100644 index 0000000..607ab33 --- /dev/null +++ b/frontend/ngsw-config.json @@ -0,0 +1,30 @@ +{ + "$schema": "./node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": [ + "/favicon.ico", + "/index.html", + "/manifest.webmanifest", + "/*.css", + "/*.js" + ] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/**", + "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" + ] + } + } + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b605560..047c803 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -554,6 +554,14 @@ "tslib": "^2.0.0" } }, + "@angular/service-worker": { + "version": "10.1.6", + "resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-10.1.6.tgz", + "integrity": "sha512-wGF2ZVByYonNpQNjyLn4zK0O2au1ZJQv6JLZj5zHnVnaiz/xJXvY9TPCU3dLmuRFt6UmKStLlclJkG3s3FYiZg==", + "requires": { + "tslib": "^2.0.0" + } + }, "@babel/code-frame": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index fd78b94..13fc2d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@angular/platform-browser": "~10.1.5", "@angular/platform-browser-dynamic": "~10.1.5", "@angular/router": "~10.1.5", + "@angular/service-worker": "~10.1.5", "@types/chart.js": "^2.9.27", "@types/mapbox-gl": "^1.12.5", "@types/moment": "^2.13.0", diff --git a/frontend/src/app/admin/admin.component.html b/frontend/src/app/admin/admin.component.html new file mode 100644 index 0000000..49659db --- /dev/null +++ b/frontend/src/app/admin/admin.component.html @@ -0,0 +1 @@ +

admin works!

diff --git a/frontend/src/app/admin/admin.component.scss b/frontend/src/app/admin/admin.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/admin/admin.component.spec.ts b/frontend/src/app/admin/admin.component.spec.ts new file mode 100644 index 0000000..eb28e42 --- /dev/null +++ b/frontend/src/app/admin/admin.component.spec.ts @@ -0,0 +1,25 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { AdminComponent } from './admin.component'; + +describe('AdminComponent', () => { + let component: AdminComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ AdminComponent ] + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(AdminComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/admin/admin.component.ts b/frontend/src/app/admin/admin.component.ts new file mode 100644 index 0000000..38af3f2 --- /dev/null +++ b/frontend/src/app/admin/admin.component.ts @@ -0,0 +1,22 @@ +import { AfterContentInit, Component, OnDestroy, OnInit } from '@angular/core'; +import { APIService } from '../api.service'; + +@Component({ + selector: 'app-admin', + templateUrl: './admin.component.html', + styleUrls: ['./admin.component.scss'] +}) +export class AdminComponent implements AfterContentInit, OnDestroy { + + constructor(public api: APIService) { } + + ngAfterContentInit(): void { + this.api.showFilter = false; + console.log(this.api.showFilter); + } + + ngOnDestroy(): void { + this.api.showFilter = true; + } + +} diff --git a/frontend/src/app/api.service.ts b/frontend/src/app/api.service.ts index df85d2b..218c714 100644 --- a/frontend/src/app/api.service.ts +++ b/frontend/src/app/api.service.ts @@ -103,7 +103,28 @@ export class APIService { API_ENDPOINT = 'http://192.168.178.26:8040'; - constructor(private httpClient: HttpClient, private mqtt: MqttService) {} + constructor(private httpClient: HttpClient, private mqtt: MqttService) { } + + private mqttInit(): void { + // Connect with RabbitMQ after we received our user information + this.mqtt.connect({ + hostname: '192.168.178.26', + port: 15675, + protocol: 'ws', + path: '/ws', + username: this.user.name, + password: this.user.brokerToken + }); + + this.mqtt.observe('/').subscribe(message => { + if (this.beats !== undefined) { + const obj = JSON.parse(message.payload.toString()) as IBeat; + this.beats.push(obj); + this.beatsEvent.next([obj]); // We just push one, so all the map doesn't has to rebuild the entire map. + this.beatStats.totalBeats++; + } + }); + } async login(username: string, password: string): Promise { return new Promise(async (resolve, reject) => { @@ -116,23 +137,7 @@ export class APIService { await this.getPhones(); await this.getUserInfo(); - // Connect with RabbitMQ after we received our user information - this.mqtt.connect({ - hostname: '192.168.178.26', - port: 15675, - protocol: 'ws', - path: '/ws', - username: this.user.name, - password: this.user.brokerToken - }); - - this.mqtt.observe('/').subscribe(message => { - if (this.beats !== undefined) { - this.beats.push(JSON.parse(message.payload.toString()) as IBeat); - this.beatsEvent.next(this.beats); - this.beatStats.totalBeats++; - } - }); + this.mqttInit(); await this.getBeats(); await this.getBeatStats(); @@ -233,7 +238,7 @@ export class APIService { this.httpClient.get(this.API_ENDPOINT + '/phone/' + phoneId, { responseType: 'json', headers }) .subscribe(phones => { this.fetchingDataEvent.next(false); - resolve(phones as {IPhone, IBeat}); + resolve(phones as { IPhone, IBeat }); }); }); } @@ -257,12 +262,21 @@ export class APIService { lat2 = this.degreesToRadians(lat2); const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + - Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); + Math.sin(dLon / 2) * Math.sin(dLon / 2) * Math.cos(lat1) * Math.cos(lat2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return earthRadiusKm * c; } + /** + * Short form for `this.api.beats[this.api.beats.length - 1]` + * + * **Notice:** This does not fetch new beats, instead use cached `this.beats` + */ + getLastBeat(): IBeat { + return this.beats[this.beats.length - 1]; + } + hasSession(): boolean { return this.token !== undefined; } diff --git a/frontend/src/app/app-routing.module.ts b/frontend/src/app/app-routing.module.ts index 1502e34..9c78870 100644 --- a/frontend/src/app/app-routing.module.ts +++ b/frontend/src/app/app-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; +import { AdminComponent } from './admin/admin.component'; import { AppComponent } from './app.component'; import { DashboardComponent } from './dashboard/dashboard.component'; import { LoginComponent } from './login/login.component'; @@ -26,6 +27,10 @@ const routes: Routes = [ { path: 'user', component: UserComponent + }, + { + path: 'admin', + component: AdminComponent } ]; diff --git a/frontend/src/app/app.component.html b/frontend/src/app/app.component.html index 5d4fb64..0d21483 100644 --- a/frontend/src/app/app.component.html +++ b/frontend/src/app/app.component.html @@ -3,7 +3,7 @@
  • Dashboard
  • Map
  • - +
    diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 39be7ba..093c753 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -15,6 +15,9 @@ import { MapComponent } from './map/map.component'; import { UserComponent } from './user/user.component'; import { DashboardWidgetComponent } from './dashboard-widget/dashboard-widget.component'; import { IMqttServiceOptions, MqttModule } from 'ngx-mqtt'; +import { ServiceWorkerModule } from '@angular/service-worker'; +import { environment } from '../environments/environment'; +import { AdminComponent } from './admin/admin.component'; @NgModule({ declarations: [ @@ -24,7 +27,8 @@ import { IMqttServiceOptions, MqttModule } from 'ngx-mqtt'; MapComponent, FilterComponent, UserComponent, - DashboardWidgetComponent + DashboardWidgetComponent, + AdminComponent ], imports: [ BrowserModule, @@ -36,7 +40,8 @@ import { IMqttServiceOptions, MqttModule } from 'ngx-mqtt'; NgxMapboxGLModule.withConfig({ accessToken: 'pk.eyJ1IjoibW9uZGVpMSIsImEiOiJja2dsY2ZtaG0xZ2o5MnR0ZWs0Mm82OTBpIn0.NzDWN3P6jJLmci_v3MM1tA' }), - ChartsModule + ChartsModule, + ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production }) ], providers: [], bootstrap: [AppComponent] diff --git a/frontend/src/app/map.worker.ts b/frontend/src/app/map.worker.ts new file mode 100644 index 0000000..2acf2b6 --- /dev/null +++ b/frontend/src/app/map.worker.ts @@ -0,0 +1,46 @@ +/// + +/* Function to find out if a provided point is in the area from + https://stackoverflow.com/questions/24680247/check-if-a-latitude-and-longitude-is-within-a-circle-google-maps + */ +function isPointInRadius(checkPoint: { lat: number, lng: number }, centerPoint: { lat: number, lng: number }, km: number): boolean { + const ky = 40000 / 360; + const kx = Math.cos(Math.PI * centerPoint.lat / 180.0) * ky; + const dx = Math.abs(centerPoint.lng - checkPoint.lng) * kx; + const dy = Math.abs(centerPoint.lat - checkPoint.lat) * ky; + return Math.sqrt(dx * dx + dy * dy) <= km; +} + +/** + * This web worker computes the heatmap. Since this process takes a while for thousends of objects + * we compute them in there own thread. + * @param data Is an array of all beats + */ +addEventListener('message', ({ data }) => { + const mostVisit = new Map(); + + // Get most visit points + data.forEach(beat => { + // Only if accuracy is low enough + if (beat.accuracy < 35) { + data.forEach(beat2 => { + const isNearPoint = isPointInRadius( + { lat: beat2.coordinate[0], lng: beat2.coordinate[1] }, + { lat: beat.coordinate[0], lng: beat.coordinate[1] }, + 0.025 + ); + + if (isNearPoint) { + if (mostVisit.has(beat2._id)) { + mostVisit.set(beat2._id, mostVisit.get(beat2._id) + 1); + } else { + mostVisit.set(beat2._id, 1); + } + } + }); + } + }); + + console.log(`worker response to`, mostVisit); + postMessage(mostVisit); +}); diff --git a/frontend/src/app/map/map.component.html b/frontend/src/app/map/map.component.html index 83ca540..e4138a6 100644 --- a/frontend/src/app/map/map.component.html +++ b/frontend/src/app/map/map.component.html @@ -23,4 +23,17 @@ source="lastLoc" [paint]="lastLocationPaint" > +
    + + Compute heat map, please wait ... + + + +
    \ No newline at end of file diff --git a/frontend/src/app/map/map.component.ts b/frontend/src/app/map/map.component.ts index a496f2b..6029673 100644 --- a/frontend/src/app/map/map.component.ts +++ b/frontend/src/app/map/map.component.ts @@ -10,6 +10,7 @@ export class MapComponent { lastLocation: number[] = [0, 0]; showMap = false; + heatmapPending = false; data: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features: [ @@ -126,15 +127,16 @@ export class MapComponent { if (beats.length === 0) { return; } this.update(); + this.buildMap(beats.length === 1); this.lastLocationPaint['circle-radius'].stops[1][1] = this.metersToPixelsAtMaxZoom( beats[beats.length - 1].accuracy, this.lastLocation[0] ); this.lastLocationPaint = { ...this.lastLocationPaint }; + }); - this.api.maxAccuracy.subscribe(val => { - this.buildMap(val); - }); + this.api.maxAccuracy.subscribe(val => { + this.buildMap(false, val); }); } @@ -145,22 +147,7 @@ export class MapComponent { return meters / 0.075 / Math.cos(latitude * Math.PI / 180); } - /* Function to find out if a provided point is in the area from - https://stackoverflow.com/questions/24680247/check-if-a-latitude-and-longitude-is-within-a-circle-google-maps - */ - private isPointInRadius(checkPoint: { lat: number, lng: number }, centerPoint: { lat: number, lng: number }, km: number): boolean { - const ky = 40000 / 360; - const kx = Math.cos(Math.PI * centerPoint.lat / 180.0) * ky; - const dx = Math.abs(centerPoint.lng - checkPoint.lng) * kx; - const dy = Math.abs(centerPoint.lat - checkPoint.lat) * ky; - return Math.sqrt(dx * dx + dy * dy) <= km; - } - async update(): Promise { - this.data.features[0].geometry.coordinates = []; - - this.buildMap(); - this.lastLocation = [this.api.beats[this.api.beats.length - 1].coordinate[1], this.api.beats[this.api.beats.length - 1].coordinate[0]]; @@ -170,46 +157,56 @@ export class MapComponent { this.showMap = true; } - buildMap(maxAccuracy: number = 30): void { - const mostVisit = new Map(); - this.data.features[0].geometry.coordinates = []; - this.mostVisitData.features[0].geometry.coordinates = []; + buildMap(isUpdate: boolean, maxAccuracy: number = 30): void { + console.log(isUpdate); - for (let i = 0; i < this.api.beats.length; i++) { - const beat = this.api.beats[i]; - if (beat.accuracy > maxAccuracy) { continue; } - this.data.features[0].geometry.coordinates.push([beat.coordinate[1], beat.coordinate[0]]); + // If this is an update don't rebuild entire map. + if (!isUpdate) { + console.log('Clear'); - // Get most visit points - for (let b = 0; b < this.api.beats.length; b++) { - const beat2 = this.api.beats[b]; - const isNearPoint = this.isPointInRadius( - { lat: beat2.coordinate[0], lng: beat2.coordinate[1] }, - { lat: beat.coordinate[0], lng: beat.coordinate[1] }, - 0.02 - ); + this.data.features[0].geometry.coordinates = []; + this.mostVisitData.features[0].geometry.coordinates = []; - if (isNearPoint) { - if (mostVisit.has(beat2._id)) { - mostVisit.set(beat2._id, mostVisit.get(beat2._id) + 1); - } else { - mostVisit.set(beat2._id, 1); - } + this.api.beats.forEach(beat => { + if (beat.accuracy < maxAccuracy) { + this.data.features[0].geometry.coordinates.push([beat.coordinate[1], beat.coordinate[0]]); } + }); + } else { + console.log('Just push'); + + if (this.api.getLastBeat().accuracy < maxAccuracy) { + this.data.features[0].geometry.coordinates.push([this.api.getLastBeat().coordinate[1], this.api.getLastBeat().coordinate[0]]); } } - for (const [key, value] of mostVisit) { - if (value < 2) { continue; } - this.api.beats.forEach(beat => { - if (beat._id === key) { - this.mostVisitData.features[0].geometry.coordinates.push([beat.coordinate[1], beat.coordinate[0]]); - } - }); - } - this.data = { ... this.data }; - this.mostVisitData = { ... this.mostVisitData }; + console.log('After update / clear:', this.data); + + // Let worker compute heatmap + if (typeof Worker !== 'undefined') { + const worker = new Worker('../map.worker', { type: 'module' }); + this.heatmapPending = true; + worker.onmessage = ({ data }) => { + for (const [key, value] of data) { + if (value < 3) { continue; } + + // Find beat with id + this.api.beats.forEach(beat => { + if (beat._id === key) { + this.mostVisitData.features[0].geometry.coordinates.push([beat.coordinate[1], beat.coordinate[0]]); + } + }); + } + worker.terminate(); + this.heatmapPending = false; + this.mostVisitData = { ... this.mostVisitData }; + }; + + worker.postMessage(isUpdate ? [this.api.getLastBeat()] : this.api.beats); + } else { + // TODO: Support older browsers + } } } diff --git a/frontend/src/assets/icons/icon-128x128.png b/frontend/src/assets/icons/icon-128x128.png new file mode 100644 index 0000000..9f9241f Binary files /dev/null and b/frontend/src/assets/icons/icon-128x128.png differ diff --git a/frontend/src/assets/icons/icon-144x144.png b/frontend/src/assets/icons/icon-144x144.png new file mode 100644 index 0000000..4a5f8c1 Binary files /dev/null and b/frontend/src/assets/icons/icon-144x144.png differ diff --git a/frontend/src/assets/icons/icon-152x152.png b/frontend/src/assets/icons/icon-152x152.png new file mode 100644 index 0000000..34a1a8d Binary files /dev/null and b/frontend/src/assets/icons/icon-152x152.png differ diff --git a/frontend/src/assets/icons/icon-192x192.png b/frontend/src/assets/icons/icon-192x192.png new file mode 100644 index 0000000..9172e5d Binary files /dev/null and b/frontend/src/assets/icons/icon-192x192.png differ diff --git a/frontend/src/assets/icons/icon-384x384.png b/frontend/src/assets/icons/icon-384x384.png new file mode 100644 index 0000000..e54e8d3 Binary files /dev/null and b/frontend/src/assets/icons/icon-384x384.png differ diff --git a/frontend/src/assets/icons/icon-512x512.png b/frontend/src/assets/icons/icon-512x512.png new file mode 100644 index 0000000..51ee297 Binary files /dev/null and b/frontend/src/assets/icons/icon-512x512.png differ diff --git a/frontend/src/assets/icons/icon-72x72.png b/frontend/src/assets/icons/icon-72x72.png new file mode 100644 index 0000000..2814a3f Binary files /dev/null and b/frontend/src/assets/icons/icon-72x72.png differ diff --git a/frontend/src/assets/icons/icon-96x96.png b/frontend/src/assets/icons/icon-96x96.png new file mode 100644 index 0000000..d271025 Binary files /dev/null and b/frontend/src/assets/icons/icon-96x96.png differ diff --git a/frontend/src/index.html b/frontend/src/index.html index b9e2ee2..4a56e41 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -6,8 +6,11 @@ + + + diff --git a/frontend/src/manifest.webmanifest b/frontend/src/manifest.webmanifest new file mode 100644 index 0000000..1b57983 --- /dev/null +++ b/frontend/src/manifest.webmanifest @@ -0,0 +1,59 @@ +{ + "name": "Livebeat", + "short_name": "Livebeat", + "theme_color": "#1976d2", + "background_color": "#fafafa", + "display": "standalone", + "scope": "./", + "start_url": "./", + "icons": [ + { + "src": "assets/icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "assets/icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "assets/icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "assets/icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "assets/icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "assets/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "assets/icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "assets/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ] +} diff --git a/frontend/src/styles.scss b/frontend/src/styles.scss index 2a81d0e..27fda58 100644 --- a/frontend/src/styles.scss +++ b/frontend/src/styles.scss @@ -19,3 +19,7 @@ body { padding: 0; } /* You can add global styles to this file, and also import other style files */ + +.mapboxgl-ctrl-top-right { + margin-top: 3rem; +} \ No newline at end of file diff --git a/frontend/tsconfig.worker.json b/frontend/tsconfig.worker.json new file mode 100644 index 0000000..22dc454 --- /dev/null +++ b/frontend/tsconfig.worker.json @@ -0,0 +1,15 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/worker", + "lib": [ + "es2018", + "webworker" + ], + "types": [] + }, + "include": [ + "src/**/*.worker.ts" + ] +}