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)
This commit is contained in:
2020-10-28 15:06:21 +01:00
parent 056195a188
commit b3b3d9d9c4
30 changed files with 357 additions and 78 deletions

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
View 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)"
]
}
}
]
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1 @@
<p>admin works!</p>

View 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();
});
});

View 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;
}
}

View File

@@ -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;
} }

View File

@@ -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
} }
]; ];

View File

@@ -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>

View File

@@ -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]

View 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);
});

View File

@@ -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>

View File

@@ -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
}
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

View File

@@ -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>

View 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"
}
]
}

View File

@@ -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;
}

View 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"
]
}