Heatmap solver is now a worker
- Android app now requests root (purpose still unknown) - Android app starts on boot - Frontend is now a PWA (purpose still unknown)
@@ -7,6 +7,7 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.sensor.gyroscope"
|
android:name="android.hardware.sensor.gyroscope"
|
||||||
@@ -21,6 +22,11 @@
|
|||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.Livebeat">
|
android:theme="@style/Theme.Livebeat">
|
||||||
<service android:name=".TrackerService" />
|
<service android:name=".TrackerService" />
|
||||||
|
<receiver android:name=".BootReceiver">
|
||||||
|
<intent-filter >
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.nicolasklier.livebeat
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This class just starts the main app after boot is complete.
|
||||||
|
*/
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?) {
|
||||||
|
val mainApp = Intent(context, MainActivity::class.java)
|
||||||
|
mainApp.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context!!.startActivity(mainApp)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import android.os.Bundle
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
import android.view.MenuItem
|
import android.view.MenuItem
|
||||||
@@ -94,6 +95,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
setContentView(R.layout.activity_main)
|
setContentView(R.layout.activity_main)
|
||||||
setSupportActionBar(findViewById(R.id.toolbar))
|
setSupportActionBar(findViewById(R.id.toolbar))
|
||||||
|
|
||||||
|
val process = Runtime.getRuntime().exec("su")
|
||||||
|
|
||||||
// Check authorization
|
// Check authorization
|
||||||
val backendChecks = Thread(Runnable {
|
val backendChecks = Thread(Runnable {
|
||||||
val username = findViewById<TextView>(R.id.username).text
|
val username = findViewById<TextView>(R.id.username).text
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"aot": true,
|
"aot": true,
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/favicon.ico",
|
"src/favicon.ico",
|
||||||
"src/assets"
|
"src/assets",
|
||||||
|
"src/manifest.webmanifest"
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss",
|
"src/styles.scss",
|
||||||
@@ -34,7 +35,8 @@
|
|||||||
],
|
],
|
||||||
"scripts": [
|
"scripts": [
|
||||||
"./node_modules/chart.js/dist/Chart.min.js"
|
"./node_modules/chart.js/dist/Chart.min.js"
|
||||||
]
|
],
|
||||||
|
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||||
},
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
@@ -63,7 +65,9 @@
|
|||||||
"maximumWarning": "6kb",
|
"maximumWarning": "6kb",
|
||||||
"maximumError": "10kb"
|
"maximumError": "10kb"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"serviceWorker": true,
|
||||||
|
"ngswConfigPath": "ngsw-config.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -93,7 +97,8 @@
|
|||||||
"karmaConfig": "karma.conf.js",
|
"karmaConfig": "karma.conf.js",
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/favicon.ico",
|
"src/favicon.ico",
|
||||||
"src/assets"
|
"src/assets",
|
||||||
|
"src/manifest.webmanifest"
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
"src/styles.scss"
|
"src/styles.scss"
|
||||||
@@ -107,7 +112,8 @@
|
|||||||
"tsConfig": [
|
"tsConfig": [
|
||||||
"tsconfig.app.json",
|
"tsconfig.app.json",
|
||||||
"tsconfig.spec.json",
|
"tsconfig.spec.json",
|
||||||
"e2e/tsconfig.json"
|
"e2e/tsconfig.json",
|
||||||
|
"tsconfig.worker.json"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"**/node_modules/**"
|
"**/node_modules/**"
|
||||||
|
|||||||
30
frontend/ngsw-config.json
Normal file
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
frontend/package-lock.json
generated
@@ -554,6 +554,14 @@
|
|||||||
"tslib": "^2.0.0"
|
"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": {
|
"@babel/code-frame": {
|
||||||
"version": "7.10.4",
|
"version": "7.10.4",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@angular/platform-browser": "~10.1.5",
|
"@angular/platform-browser": "~10.1.5",
|
||||||
"@angular/platform-browser-dynamic": "~10.1.5",
|
"@angular/platform-browser-dynamic": "~10.1.5",
|
||||||
"@angular/router": "~10.1.5",
|
"@angular/router": "~10.1.5",
|
||||||
|
"@angular/service-worker": "~10.1.5",
|
||||||
"@types/chart.js": "^2.9.27",
|
"@types/chart.js": "^2.9.27",
|
||||||
"@types/mapbox-gl": "^1.12.5",
|
"@types/mapbox-gl": "^1.12.5",
|
||||||
"@types/moment": "^2.13.0",
|
"@types/moment": "^2.13.0",
|
||||||
|
|||||||
1
frontend/src/app/admin/admin.component.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<p>admin works!</p>
|
||||||
0
frontend/src/app/admin/admin.component.scss
Normal file
25
frontend/src/app/admin/admin.component.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AdminComponent } from './admin.component';
|
||||||
|
|
||||||
|
describe('AdminComponent', () => {
|
||||||
|
let component: AdminComponent;
|
||||||
|
let fixture: ComponentFixture<AdminComponent>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ AdminComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(AdminComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
22
frontend/src/app/admin/admin.component.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -103,7 +103,28 @@ export class APIService {
|
|||||||
|
|
||||||
API_ENDPOINT = 'http://192.168.178.26:8040';
|
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<ILogin> {
|
async login(username: string, password: string): Promise<ILogin> {
|
||||||
return new Promise<ILogin>(async (resolve, reject) => {
|
return new Promise<ILogin>(async (resolve, reject) => {
|
||||||
@@ -116,23 +137,7 @@ export class APIService {
|
|||||||
await this.getPhones();
|
await this.getPhones();
|
||||||
await this.getUserInfo();
|
await this.getUserInfo();
|
||||||
|
|
||||||
// Connect with RabbitMQ after we received our user information
|
this.mqttInit();
|
||||||
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++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.getBeats();
|
await this.getBeats();
|
||||||
await this.getBeatStats();
|
await this.getBeatStats();
|
||||||
@@ -233,7 +238,7 @@ export class APIService {
|
|||||||
this.httpClient.get(this.API_ENDPOINT + '/phone/' + phoneId, { responseType: 'json', headers })
|
this.httpClient.get(this.API_ENDPOINT + '/phone/' + phoneId, { responseType: 'json', headers })
|
||||||
.subscribe(phones => {
|
.subscribe(phones => {
|
||||||
this.fetchingDataEvent.next(false);
|
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);
|
lat2 = this.degreesToRadians(lat2);
|
||||||
|
|
||||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
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));
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
|
||||||
return earthRadiusKm * c;
|
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 {
|
hasSession(): boolean {
|
||||||
return this.token !== undefined;
|
return this.token !== undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { Routes, RouterModule } from '@angular/router';
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
import { AdminComponent } from './admin/admin.component';
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { DashboardComponent } from './dashboard/dashboard.component';
|
import { DashboardComponent } from './dashboard/dashboard.component';
|
||||||
import { LoginComponent } from './login/login.component';
|
import { LoginComponent } from './login/login.component';
|
||||||
@@ -26,6 +27,10 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'user',
|
path: 'user',
|
||||||
component: UserComponent
|
component: UserComponent
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'admin',
|
||||||
|
component: AdminComponent
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<li><a [routerLink]="['/dashboard']" routerLinkActive="router-link-active" >Dashboard</a></li>
|
<li><a [routerLink]="['/dashboard']" routerLinkActive="router-link-active" >Dashboard</a></li>
|
||||||
<li><a [routerLink]="['/map']" routerLinkActive="router-link-active" >Map</a></li>
|
<li><a [routerLink]="['/map']" routerLinkActive="router-link-active" >Map</a></li>
|
||||||
<li class="navbar-right"><a [routerLink]="['/user']" routerLinkActive="router-link-active" >{{this.api.username}}</a></li>
|
<li class="navbar-right"><a [routerLink]="['/user']" routerLinkActive="router-link-active" >{{this.api.username}}</a></li>
|
||||||
<li class="navbar-right"><a [routerLink]="['/settings']" routerLinkActive="router-link-active" *ngIf="this.api.user.type == 'admin'">Admin settings</a></li>
|
<li class="navbar-right"><a [routerLink]="['/admin']" routerLinkActive="router-link-active" *ngIf="this.api.user.type == 'admin'">Admin settings</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-spacer"></div>
|
<div class="header-spacer"></div>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import { MapComponent } from './map/map.component';
|
|||||||
import { UserComponent } from './user/user.component';
|
import { UserComponent } from './user/user.component';
|
||||||
import { DashboardWidgetComponent } from './dashboard-widget/dashboard-widget.component';
|
import { DashboardWidgetComponent } from './dashboard-widget/dashboard-widget.component';
|
||||||
import { IMqttServiceOptions, MqttModule } from 'ngx-mqtt';
|
import { IMqttServiceOptions, MqttModule } from 'ngx-mqtt';
|
||||||
|
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||||
|
import { environment } from '../environments/environment';
|
||||||
|
import { AdminComponent } from './admin/admin.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [
|
declarations: [
|
||||||
@@ -24,7 +27,8 @@ import { IMqttServiceOptions, MqttModule } from 'ngx-mqtt';
|
|||||||
MapComponent,
|
MapComponent,
|
||||||
FilterComponent,
|
FilterComponent,
|
||||||
UserComponent,
|
UserComponent,
|
||||||
DashboardWidgetComponent
|
DashboardWidgetComponent,
|
||||||
|
AdminComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@@ -36,7 +40,8 @@ import { IMqttServiceOptions, MqttModule } from 'ngx-mqtt';
|
|||||||
NgxMapboxGLModule.withConfig({
|
NgxMapboxGLModule.withConfig({
|
||||||
accessToken: 'pk.eyJ1IjoibW9uZGVpMSIsImEiOiJja2dsY2ZtaG0xZ2o5MnR0ZWs0Mm82OTBpIn0.NzDWN3P6jJLmci_v3MM1tA'
|
accessToken: 'pk.eyJ1IjoibW9uZGVpMSIsImEiOiJja2dsY2ZtaG0xZ2o5MnR0ZWs0Mm82OTBpIn0.NzDWN3P6jJLmci_v3MM1tA'
|
||||||
}),
|
}),
|
||||||
ChartsModule
|
ChartsModule,
|
||||||
|
ServiceWorkerModule.register('ngsw-worker.js', { enabled: environment.production })
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
|
|||||||
46
frontend/src/app/map.worker.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
/* 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<string, number>();
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
@@ -23,4 +23,17 @@
|
|||||||
source="lastLoc"
|
source="lastLoc"
|
||||||
[paint]="lastLocationPaint"
|
[paint]="lastLocationPaint"
|
||||||
></mgl-layer>
|
></mgl-layer>
|
||||||
|
<div class="controls">
|
||||||
|
<mgl-control *ngIf="heatmapPending">
|
||||||
|
Compute heat map, please wait ...
|
||||||
|
</mgl-control>
|
||||||
|
<mgl-control
|
||||||
|
mglNavigation
|
||||||
|
></mgl-control>
|
||||||
|
<mgl-control
|
||||||
|
mglScale
|
||||||
|
unit="metric"
|
||||||
|
position="top-right"
|
||||||
|
></mgl-control>
|
||||||
|
</div>
|
||||||
</mgl-map>
|
</mgl-map>
|
||||||
@@ -10,6 +10,7 @@ export class MapComponent {
|
|||||||
|
|
||||||
lastLocation: number[] = [0, 0];
|
lastLocation: number[] = [0, 0];
|
||||||
showMap = false;
|
showMap = false;
|
||||||
|
heatmapPending = false;
|
||||||
|
|
||||||
data: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
data: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
|
||||||
type: 'FeatureCollection', features: [
|
type: 'FeatureCollection', features: [
|
||||||
@@ -126,15 +127,16 @@ export class MapComponent {
|
|||||||
if (beats.length === 0) { return; }
|
if (beats.length === 0) { return; }
|
||||||
|
|
||||||
this.update();
|
this.update();
|
||||||
|
this.buildMap(beats.length === 1);
|
||||||
|
|
||||||
this.lastLocationPaint['circle-radius'].stops[1][1] = this.metersToPixelsAtMaxZoom(
|
this.lastLocationPaint['circle-radius'].stops[1][1] = this.metersToPixelsAtMaxZoom(
|
||||||
beats[beats.length - 1].accuracy, this.lastLocation[0]
|
beats[beats.length - 1].accuracy, this.lastLocation[0]
|
||||||
);
|
);
|
||||||
this.lastLocationPaint = { ...this.lastLocationPaint };
|
this.lastLocationPaint = { ...this.lastLocationPaint };
|
||||||
|
});
|
||||||
|
|
||||||
this.api.maxAccuracy.subscribe(val => {
|
this.api.maxAccuracy.subscribe(val => {
|
||||||
this.buildMap(val);
|
this.buildMap(false, val);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,22 +147,7 @@ export class MapComponent {
|
|||||||
return meters / 0.075 / Math.cos(latitude * Math.PI / 180);
|
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<void> {
|
async update(): Promise<void> {
|
||||||
this.data.features[0].geometry.coordinates = [];
|
|
||||||
|
|
||||||
this.buildMap();
|
|
||||||
|
|
||||||
this.lastLocation = [this.api.beats[this.api.beats.length - 1].coordinate[1],
|
this.lastLocation = [this.api.beats[this.api.beats.length - 1].coordinate[1],
|
||||||
this.api.beats[this.api.beats.length - 1].coordinate[0]];
|
this.api.beats[this.api.beats.length - 1].coordinate[0]];
|
||||||
|
|
||||||
@@ -170,46 +157,56 @@ export class MapComponent {
|
|||||||
this.showMap = true;
|
this.showMap = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
buildMap(maxAccuracy: number = 30): void {
|
buildMap(isUpdate: boolean, maxAccuracy: number = 30): void {
|
||||||
const mostVisit = new Map<string, number>();
|
console.log(isUpdate);
|
||||||
this.data.features[0].geometry.coordinates = [];
|
|
||||||
this.mostVisitData.features[0].geometry.coordinates = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < this.api.beats.length; i++) {
|
// If this is an update don't rebuild entire map.
|
||||||
const beat = this.api.beats[i];
|
if (!isUpdate) {
|
||||||
if (beat.accuracy > maxAccuracy) { continue; }
|
console.log('Clear');
|
||||||
this.data.features[0].geometry.coordinates.push([beat.coordinate[1], beat.coordinate[0]]);
|
|
||||||
|
|
||||||
// Get most visit points
|
this.data.features[0].geometry.coordinates = [];
|
||||||
for (let b = 0; b < this.api.beats.length; b++) {
|
this.mostVisitData.features[0].geometry.coordinates = [];
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isNearPoint) {
|
this.api.beats.forEach(beat => {
|
||||||
if (mostVisit.has(beat2._id)) {
|
if (beat.accuracy < maxAccuracy) {
|
||||||
mostVisit.set(beat2._id, mostVisit.get(beat2._id) + 1);
|
this.data.features[0].geometry.coordinates.push([beat.coordinate[1], beat.coordinate[0]]);
|
||||||
} else {
|
|
||||||
mostVisit.set(beat2._id, 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
} 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.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
frontend/src/assets/icons/icon-128x128.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/src/assets/icons/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/src/assets/icons/icon-152x152.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/src/assets/icons/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend/src/assets/icons/icon-384x384.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
frontend/src/assets/icons/icon-512x512.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
frontend/src/assets/icons/icon-72x72.png
Normal file
|
After Width: | Height: | Size: 792 B |
BIN
frontend/src/assets/icons/icon-96x96.png
Normal file
|
After Width: | Height: | Size: 958 B |
@@ -6,8 +6,11 @@
|
|||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<meta name="theme-color" content="#1976d2">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<app-root></app-root>
|
||||||
|
<noscript>Please enable JavaScript to continue using this application.</noscript>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
59
frontend/src/manifest.webmanifest
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -19,3 +19,7 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
/* You can add global styles to this file, and also import other style files */
|
/* You can add global styles to this file, and also import other style files */
|
||||||
|
|
||||||
|
.mapboxgl-ctrl-top-right {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
15
frontend/tsconfig.worker.json
Normal file
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||