From b3b3d9d9c407551ff6b9e865d2cd129838d24bed Mon Sep 17 00:00:00 2001 From: Mondei1 Date: Wed, 28 Oct 2020 15:06:21 +0100 Subject: [PATCH] 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) --- android/app/src/main/AndroidManifest.xml | 6 ++ .../de/nicolasklier/livebeat/BootReceiver.kt | 16 +++ .../de/nicolasklier/livebeat/MainActivity.kt | 3 + frontend/angular.json | 16 ++- frontend/ngsw-config.json | 30 ++++++ frontend/package-lock.json | 8 ++ frontend/package.json | 1 + frontend/src/app/admin/admin.component.html | 1 + frontend/src/app/admin/admin.component.scss | 0 .../src/app/admin/admin.component.spec.ts | 25 +++++ frontend/src/app/admin/admin.component.ts | 22 ++++ frontend/src/app/api.service.ts | 54 ++++++---- frontend/src/app/app-routing.module.ts | 5 + frontend/src/app/app.component.html | 2 +- frontend/src/app/app.module.ts | 9 +- frontend/src/app/map.worker.ts | 46 +++++++++ frontend/src/app/map/map.component.html | 13 +++ frontend/src/app/map/map.component.ts | 97 +++++++++--------- frontend/src/assets/icons/icon-128x128.png | Bin 0 -> 1253 bytes frontend/src/assets/icons/icon-144x144.png | Bin 0 -> 1394 bytes frontend/src/assets/icons/icon-152x152.png | Bin 0 -> 1427 bytes frontend/src/assets/icons/icon-192x192.png | Bin 0 -> 1790 bytes frontend/src/assets/icons/icon-384x384.png | Bin 0 -> 3557 bytes frontend/src/assets/icons/icon-512x512.png | Bin 0 -> 5008 bytes frontend/src/assets/icons/icon-72x72.png | Bin 0 -> 792 bytes frontend/src/assets/icons/icon-96x96.png | Bin 0 -> 958 bytes frontend/src/index.html | 3 + frontend/src/manifest.webmanifest | 59 +++++++++++ frontend/src/styles.scss | 4 + frontend/tsconfig.worker.json | 15 +++ 30 files changed, 357 insertions(+), 78 deletions(-) create mode 100644 android/app/src/main/java/de/nicolasklier/livebeat/BootReceiver.kt create mode 100644 frontend/ngsw-config.json create mode 100644 frontend/src/app/admin/admin.component.html create mode 100644 frontend/src/app/admin/admin.component.scss create mode 100644 frontend/src/app/admin/admin.component.spec.ts create mode 100644 frontend/src/app/admin/admin.component.ts create mode 100644 frontend/src/app/map.worker.ts create mode 100644 frontend/src/assets/icons/icon-128x128.png create mode 100644 frontend/src/assets/icons/icon-144x144.png create mode 100644 frontend/src/assets/icons/icon-152x152.png create mode 100644 frontend/src/assets/icons/icon-192x192.png create mode 100644 frontend/src/assets/icons/icon-384x384.png create mode 100644 frontend/src/assets/icons/icon-512x512.png create mode 100644 frontend/src/assets/icons/icon-72x72.png create mode 100644 frontend/src/assets/icons/icon-96x96.png create mode 100644 frontend/src/manifest.webmanifest create mode 100644 frontend/tsconfig.worker.json 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 0000000000000000000000000000000000000000..9f9241f0be40661db1eed29384231e76d33b6e7c GIT binary patch literal 1253 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 A1ONa4 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/icons/icon-152x152.png b/frontend/src/assets/icons/icon-152x152.png new file mode 100644 index 0000000000000000000000000000000000000000..34a1a8d645872c776c9425de45c3fcfba12c271e GIT binary patch literal 1427 zcmV;E1#J3>P)0{{R3FC5Sl00004XF*Lt006O% z3;baP00001b5ch_0Itp)=>Px#S5Qn;MNDaN)BrNc058)3FxCJt%K$IW1~u3KFxCJt z*8nrs05IMFFxCJu+yF7w05QV=FWmq!|NsBlCs6RIx%%hq)l6;YNo@Dg+Uk;~=yZ

    X{-?>fGCXfYNSFwSa_5FdK zVWbmbiyjU>K0ZD^K0ZD^KL2TsQY8DMN~18(vn<;gTO#sXvhT>_UZ`aLlk6vfOgE>x z$T-hm!ES8PrsW=;^Kr6C!eqm8&oyA1v_kq*?gp^0oQ|86yBTb~R+yAK1#Fd8crAA# znB!KMDR(-UvsRcdcL>ZyD=d{e3TC$zlI3!r%+{YP;V#uJdT7g#Xqm;#0N{kF{1!}pE)7`+j3=Yr=A6bs)`+*fjoG~J)&x^s8 z_jj;s0P7~D{|#2dlPq?GcR}Ua z{+_|MsARG8yA+k{=P%eNDp~OG4wS2UoLa{Ulq`6lc*f+~J&)5nP_iJHz$Vx7307j0 zYyg5u$Q8r*0%Cw98-idFB-dsPRzs2%AXtJtFg3lhQHW%vZLn_oGdTw95XqufBuC^} zpqzrm$Z^33dMyITwR*jMs{qNumm@L#fi)2U)kti#;?W8&*b#JvZYHF1{36S zl>T3^PqJhK7)+ASQD$y?}F88 z9_@nlYJR5FA|6=i{)<^n7pz4J@Fer8w` zTCgqu^~JOcHmrGl7ucESt>yX3@;t&!4|~X?3%0KL#V*(z)}ssdQ1i3H+8(jKyI?0Z h@8jd+Hf^L(!7eV+I8=gXo5oJ1=hR0aT`iN0Qe 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 literal 0 HcmV?d00001 diff --git a/frontend/src/assets/icons/icon-512x512.png b/frontend/src/assets/icons/icon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..51ee297df1cbeb0354814ffe95afa6e4bc86ea23 GIT binary patch literal 5008 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 g0#)SEkt6>uKVW + + + 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" + ] +}