New dashboard widgets

- Custom time range can now be picked
- Map now shows accuracy of latest beat
- Presets removed
This commit is contained in:
2020-10-25 00:10:25 +02:00
parent 8a4b0b1e13
commit fa60f58d3c
17 changed files with 360 additions and 74 deletions

View File

@@ -15,7 +15,7 @@ import { hashPassword, randomPepper, randomString } from './lib/crypto';
import { UserType } from './models/user/user.interface';
import { User } from './models/user/user.model';
import { GetPhone, PostPhone } from './endpoints/phone';
import { GetBeat } from './endpoints/beat';
import { GetBeat, GetBeatStats } from './endpoints/beat';
// Load .env
dconfig({ debug: true, encoding: 'UTF-8' });
@@ -154,6 +154,7 @@ async function run() {
app.post('/phone', MW_User, (req, res) => PostPhone(req, res));
app.get('/beat/', MW_User, (req, res) => GetBeat(req, res));
app.get('/beat/stats', MW_User, (req, res) => GetBeatStats(req, res));
app.listen(config.http.port, config.http.host, () => {
logger.info(`HTTP server is running at ${config.http.host}:${config.http.port}`);

View File

@@ -4,51 +4,38 @@ import { IBeat } from "../models/beat/beat.interface";
import { Beat } from "../models/beat/beat.model.";
import { Phone } from "../models/phone/phone.model";
export async function GetBeatStats(req: LivebeatRequest, res: Response) {
const phones = await Phone.find({ user: req.user?._id });
const perPhone: any = {};
let totalBeats = 0;
for (let i = 0; i < phones.length; i++) {
const beatCount = await Beat.countDocuments({ phone: phones[i] });
perPhone[phones[i]._id] = {};
perPhone[phones[i]._id] = beatCount;
totalBeats += beatCount;
}
res.status(200).send({ totalBeats, perPhone });
}
export async function GetBeat(req: LivebeatRequest, res: Response) {
const from: number = Number(req.query.from);
const to: number = Number(req.query.to);
const preset: string = req.query.preset as string;
const phoneId = req.query.phoneId;
const phone = req.query.phone === undefined ? await Phone.findOne({ user: req.user?._id }) : await Phone.findOne({ _id: phoneId, user: req.user?._id });
let beats: IBeat[] = []
if (phone !== null) {
// If the battery preset is chosen, we only return documents where the battery status changed.
if (preset === 'battery') {
// Group documents under the battery percentage.
const batteryBeats = await Beat.aggregate([
{ $group: { _id: {battery: "$battery"}, uniqueIds: { $addToSet: "$_id" } } }
]).sort({ '_id.battery': -1 }).sort({ '_id.id': -1 });
// Loop though array and grab the first document where the battery percentage changed.
for (let i = 0; i < batteryBeats.length; i++) {
const firstId = batteryBeats[i].uniqueIds[0];
const firstBeat = await Beat.findById(firstId);
if (firstBeat !== null) {
beats.push(firstBeat);
beats = await Beat.find(
{
phone: phone._id,
createdAt: {
$gte: new Date((from | 0) * 1000),
$lte: new Date((to | Date.now() /1000) * 1000)
}
}
} else {
// If no preset is chosen, get latest.
beats = await Beat.find({ phone: phone._id, createdAt: { $gte: new Date((from | 0) * 1000), $lte: new Date((to | Date.now() / 1000) * 1000) } }).sort({ _id: -1 });
}
// Check time
/*for (let i = 0; i < beats.length; i++) {
const beat = beats[i];
if (!isNaN(from) && !isNaN(to)) {
if (Math.floor(beat.createdAt!.getTime() / 1000) >= new Date(from).getTime() &&
Math.floor(beat.createdAt!.getTime() / 1000) <= new Date(to).getTime()) {}
else {
console.log(`${Math.floor(beat.createdAt!.getTime() / 1000)} is not in range`);
beats.splice(i, 1);
}
}
}*/
}).sort({ _id: -1 });
res.status(200).send(beats);
} else {
res.status(404).send({ message: 'Phone not found' });

View File

@@ -15,6 +15,18 @@ export interface IBeat {
createdAt?: Date;
}
export interface IBeatStats {
totalBeats: number;
/**
* The structure will always be like in this example:
* ```
* '5f91586a8f03d54db32a5eb5': 4269
* ```
*/
perPhone: any;
}
export enum UserType {
ADMIN = 'admin',
USER = 'user',
@@ -60,6 +72,10 @@ export class APIService {
// Cached data
beats: IBeat[];
beatStats: IBeatStats = {
totalBeats: 0,
perPhone: {}
};
phones: IPhone[];
user: IUser = {
name: '',
@@ -73,6 +89,7 @@ export class APIService {
// Events when new data got fetched
beatsEvent: BehaviorSubject<IBeat[]> = new BehaviorSubject([]);
phoneEvent: BehaviorSubject<IPhone[]> = new BehaviorSubject([]);
loginEvent: BehaviorSubject<boolean> = new BehaviorSubject(false);
fetchingDataEvent: BehaviorSubject<boolean> = new BehaviorSubject(false);
@@ -90,37 +107,59 @@ export class APIService {
this.username = username;
await this.getPhones();
await this.getUserInfo();
await this.getBeats();
await this.getBeatStats();
this.loginEvent.next(true);
resolve(token as ILogin);
});
});
}
async getBeats(preset?: 'battery'): Promise<IBeat[]> {
async getBeats(): Promise<IBeat[]> {
return new Promise<IBeat[]>((resolve, reject) => {
if (this.token === undefined) { reject([]); }
this.fetchingDataEvent.next(true);
const headers = new HttpHeaders({ token: this.token });
let params = new HttpParams()
.set('preset', preset);
let params = new HttpParams();
if (this.time !== undefined) {
params = params.set('from', this.time.from.toString());
if (this.time.to !== 0) {
params = params.set('to', this.time.to.toString());
}
}
this.httpClient.get(this.API_ENDPOINT + '/beat', { responseType: 'json', headers, params })
.subscribe(beats => {
this.beats = beats as IBeat[];
this.beatsEvent.next(beats as IBeat[]);
this.fetchingDataEvent.next(false);
console.debug('Return beats', beats);
resolve(beats as IBeat[]);
});
});
}
async getBeatStats(): Promise<IBeatStats> {
return new Promise<IBeatStats>((resolve, reject) => {
if (this.token === undefined) { reject([]); }
const headers = new HttpHeaders({ token: this.token });
this.fetchingDataEvent.next(true);
this.httpClient.get(this.API_ENDPOINT + '/beat/stats', { responseType: 'json', headers })
.subscribe(beatStats => {
this.beatStats = beatStats as IBeatStats;
this.fetchingDataEvent.next(false);
resolve(beatStats as IBeatStats);
});
});
}
async getUserInfo(): Promise<IUser> {
return new Promise<IUser>((resolve, reject) => {
if (this.token === undefined) { reject([]); }
@@ -149,6 +188,7 @@ export class APIService {
this.httpClient.get(this.API_ENDPOINT + '/phone', { responseType: 'json', headers })
.subscribe(phones => {
this.phones = phones as IPhone[];
this.phoneEvent.next(phones as IPhone[]);
this.fetchingDataEvent.next(false);
resolve(phones as IPhone[]);
});
@@ -171,6 +211,31 @@ export class APIService {
});
}
/* HELPER CLASSES */
degreesToRadians(degrees: number): number {
return degrees * Math.PI / 180;
}
/**
* Calculates distance between two gps points with some magic math.
* Taken from: https://stackoverflow.com/questions/365826/calculate-distance-between-2-gps-coordinates
*/
distanceInKmBetweenEarthCoordinates(lat1: number, lon1: number, lat2: number, lon2: number): number {
const earthRadiusKm = 6371;
const dLat = this.degreesToRadians(lat2 - lat1);
const dLon = this.degreesToRadians(lon2 - lon1);
lat1 = this.degreesToRadians(lat1);
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);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadiusKm * c;
}
hasSession(): boolean {
return this.token !== undefined;
}

View File

@@ -19,7 +19,7 @@ export class AppComponent implements OnInit{
}
async ngOnInit(): Promise<void> {
//await this.api.login('Nicolas', '$1KDaNCDlyXAOg');
await this.api.login('admin', '$1KDaNCDlyXAOg');
return;
}
}

View File

@@ -13,6 +13,7 @@ import { FilterComponent } from './filter/filter.component';
import { LoginComponent } from './login/login.component';
import { MapComponent } from './map/map.component';
import { UserComponent } from './user/user.component';
import { DashboardWidgetComponent } from './dashboard-widget/dashboard-widget.component';
@NgModule({
declarations: [
@@ -21,7 +22,8 @@ import { UserComponent } from './user/user.component';
DashboardComponent,
MapComponent,
FilterComponent,
UserComponent
UserComponent,
DashboardWidgetComponent
],
imports: [
BrowserModule,

View File

@@ -0,0 +1,7 @@
<div class="dwidgetwrapper">
<div class="dwidget" #dwidget [style.border-top-color]="this.color">
<p>{{title}}</p>
<h1>{{value}}</h1>
</div>
<div class="bgColor" #colorbg [style.background-color]="this.color" [style.box-shadow]="'5px 2.5rem 8px -2px ' + this.color"></div>
</div>

View File

@@ -0,0 +1,32 @@
@import '../../styles.scss';
.dwidgetwrapper {
display: flex;
height: 8.4rem;
}
.dwidget {
line-height: 0.5rem;
background-color: rgba(0, 0, 0, 0.25);
backdrop-filter: blur(30px);
width: fit-content;
padding: 1.5rem;
padding-right: 6rem !important;
border-top: 0.2rem solid $foreground-color;
border-radius: 10px;
transition: 0.1s ease-in-out;
}
.dwidget:hover {
border-top-width: 0.3rem;
background-color: rgba(0, 0, 0, 0.18);
line-height: 0.7rem;
}
.bgColor {
z-index: -1 !important;
position: absolute;
width: 8rem;
height: 1rem;
margin-left: 5px;
background-color: transparent !important;
}

View File

@@ -0,0 +1,25 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DashboardWidgetComponent } from './dashboard-widget.component';
describe('DashboardWidgetComponent', () => {
let component: DashboardWidgetComponent;
let fixture: ComponentFixture<DashboardWidgetComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ DashboardWidgetComponent ]
})
.compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(DashboardWidgetComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,23 @@
import { Component, OnInit, Input, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
@Component({
selector: 'app-dashboard-widget',
templateUrl: './dashboard-widget.component.html',
styleUrls: ['./dashboard-widget.component.scss']
})
export class DashboardWidgetComponent implements AfterViewInit {
@Input() color: string;
@Input() title: string;
@Input() value: string;
@ViewChild('dwidget') widget: ElementRef;
@ViewChild('colorbg') colorbg: ElementRef;
constructor() { }
ngAfterViewInit(): void {
this.colorbg.nativeElement.style.top = this.widget.nativeElement.getBoundingClientRect()[0];
}
}

View File

@@ -1,12 +1,18 @@
<div id="dashboard">
<h1>Dashboard</h1>
<div class="widgets">
<app-dashboard-widget [color]="'#F24B4B'" [title]="'Beats'" [value]="this.api.beatStats.totalBeats"></app-dashboard-widget>
<app-dashboard-widget [color]="'#51A3F0'" [title]="'Distance'" [value]="this.totalDistance + ' km'"></app-dashboard-widget>
<app-dashboard-widget [color]="'#CCCC61'" [title]="'Registered devices'" [value]="this.devices"></app-dashboard-widget>
</div>
<div class="chartjs-cointainer batteryChart">
<h1>Battery</h1>
<canvas baseChart
[chartType]="'line'"
[datasets]="lineChartData"
[labels]="lineChartLabels"
[options]="lineChartOptions"
[legend]="true"
[legend]="false"
></canvas>
</div>
</div>

View File

@@ -1,3 +1,5 @@
@import '../../styles.scss';
#dashboard {
margin-left: 3rem;
margin-right: 3rem;
@@ -7,3 +9,24 @@
max-height: 40rem;
max-width: 60rem;
}
.widgets {
display: inline-flex;
margin-bottom: 3rem;
& app-dashboard-widget {
margin-right: 4rem;
}
}
.chartjs-cointainer {
background-color: $darker;
border-radius: 16px;
padding: 2rem;
text-align: center;
line-height: 0.2rem;
& h1 {
margin-top: 0.4rem;
}
}

View File

@@ -10,6 +10,9 @@ import { APIService, IBeat } from '../api.service';
})
export class DashboardComponent implements AfterViewInit {
totalDistance = '0';
devices = 0;
// Array of different segments in chart
lineChartData: ChartDataSets[] = [
{ data: [], label: 'Battery' }
@@ -38,7 +41,11 @@ export class DashboardComponent implements AfterViewInit {
}
};
constructor(private api: APIService) {
constructor(public api: APIService) {
this.api.phoneEvent.subscribe(phones => {
this.devices = phones.length;
});
this.api.beatsEvent.subscribe(beats => {
this.lineChartData[0].data = [];
this.lineChartLabels = [];
@@ -58,11 +65,22 @@ export class DashboardComponent implements AfterViewInit {
this.lineChartData[0].data.push(beat.battery);
this.lineChartLabels.push(this.formatDateTime(new Date(beat.createdAt)));
});
});
let tDistance = 0;
// Calculate distance
for (let i = 0; i < beats.length; i++) {
if (i >= beats.length || (i + 1) >= beats.length) { break; }
const dist1 = beats[i].coordinate;
const dist2 = beats[i + 1].coordinate;
tDistance += this.api.distanceInKmBetweenEarthCoordinates(dist1[0], dist1[1], dist2[0], dist2[1]);
i++;
}
async fetchData(): Promise<void> {
await this.api.getBeats();
this.totalDistance = tDistance.toFixed(2);
});
}
private formatDateTime(date: Date): string {
@@ -70,7 +88,6 @@ export class DashboardComponent implements AfterViewInit {
}
ngAfterViewInit(): void {
this.fetchData();
}
}

View File

@@ -1,11 +1,20 @@
<div id="filter" [ngClass]="{hide: !this.api.showFilter}" *ngIf="this.api.loginEvent.value">
<h3>Filter</h3>
<select (change)="update($event.target.value)">
<h3 (click)="update()" style="cursor: pointer;">Refresh</h3>
<select (change)="update($event.target.value)" [(ngModel)]="this.presetHours">
<option value="-1">Today</option>
<option value="3">Last 3h</option>
<option value="12">Last 12h</option>
<option value="24">Last 24h</option>
<option value="48">Last 48h</option>
<option value="0">All time</option>
<option value="-2">Custom</option>
</select>
<input *ngIf="this.presetHours == -2" class="customRange" [(ngModel)]="customRange">
<select *ngIf="this.presetHours == -2" class="customDay" [(ngModel)]="customUnit">
<option value="minute">Minutes</option>
<option value="hour">Hours</option>
<option value="day">Days</option>
<option value="month">Months</option>
<option value="year">Years</option>
</select>
</div>

View File

@@ -20,6 +20,16 @@
transform: translateX(0);
}
.customRange {
background: transparent;
margin-left: 0.5rem;
margin-left: 0.5rem;
border: none;
color: $foreground-color;
border-bottom: 2px solid white;
width: 3rem;
}
.hide {
transform: translateY(5rem) !important;
}

View File

@@ -9,27 +9,42 @@ import * as moment from 'moment';
})
export class FilterComponent implements OnInit {
presetHours = -1;
customRange: any;
customUnit: moment.unitOfTime.DurationConstructor;
constructor(public api: APIService) { }
ngOnInit(): void {
}
update(value: number) {
update(value: number): void {
let result: ITimespan | undefined = { to: 0, from: 0 };
console.log(this.customRange, this.customUnit, this.presetHours);
if (value == -1) {
if (this.presetHours == -2) {
if (this.customRange !== undefined && this.customUnit !== undefined) {
result.from = moment().subtract(this.customRange, this.customUnit).unix();
console.log(result.from);
}
} else if (this.presetHours == -1) {
result.from = moment().startOf('day').unix();
result.to = Math.floor(moment.now() / 1000);
} else if (value == 0) {
} else if (this.presetHours == 0) {
result = undefined;
} else {
result.from = moment().subtract(value, 'hours').unix();
result.to = Math.floor(moment.now() / 1000);
result.from = moment().subtract(this.presetHours, 'hours').unix();
}
console.log(result);
this.api.time = result;
this.api.getBeats();
this.refresh();
}
async refresh(): Promise<void> {
await this.api.getBeats();
await this.api.getBeatStats();
await this.api.getPhones();
await this.api.getUserInfo();
}
}

View File

@@ -1,13 +1,19 @@
<mgl-map [style]="'mapbox://styles/mapbox/outdoors-v11'" [zoom]="[10]" [center]="[12.7, 50.8]">
<mgl-map [style]="'mapbox://styles/mapbox/dark-v10'" [zoom]="[15]" [center]="[this.lastLocation[0], this.lastLocation[1]]" *ngIf="showMap">
<mgl-geojson-source id="locHistory" [data]="data"></mgl-geojson-source>
<mgl-geojson-source id="lastLoc" [data]="lastLocationData"></mgl-geojson-source>
<mgl-layer
id="locHisotryLines"
id="locHistory"
type="line"
source="locHistory"
[paint]="{
'line-color': '#ff0000',
'line-width': 3
}"
>
</mgl-layer>
></mgl-layer>
<mgl-layer
id="lastLoc"
type="circle"
source="lastLoc"
[paint]="lastLocationPaint"
></mgl-layer>
</mgl-map>

View File

@@ -10,6 +10,8 @@ import { APIService } from '../api.service';
export class MapComponent implements AfterViewInit {
map: Map;
lastLocation: number[] = [0, 0];
showMap = false;
data: GeoJSON.FeatureCollection<GeoJSON.LineString> = {
type: 'FeatureCollection', features: [
@@ -20,14 +22,70 @@ export class MapComponent implements AfterViewInit {
}]
};
constructor(private api: APIService) {
lastLocationData: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: 'FeatureCollection', features: [
{
type: 'Feature',
properties: {
radius: 50
},
geometry: {
type: 'Point',
coordinates: [0, 0]
}
}
]
};
lastLocationPaint = {
'circle-radius': {
base: 2,
stops: [
[0, 0],
[20, 300]
]
},
'circle-color': 'Black',
'circle-opacity': 0.3,
'circle-stroke-opacity': 0.8,
'circle-stroke-width': 3
};
constructor(public api: APIService) {
this.api.beatsEvent.subscribe(beats => {
if (beats.length === 0) { return; }
this.lastLocationPaint['circle-radius'].stops[1][1] = this.metersToPixelsAtMaxZoom(
beats[0].accuracy, this.lastLocation[0]
);
this.update();
});
}
/* Function to draw circle with exact size by
https://stackoverflow.com/questions/37599561/drawing-a-circle-with-the-radius-in-miles-meters-with-mapbox-gl-js
*/
private metersToPixelsAtMaxZoom(meters: number, latitude: number): number {
return meters / 0.075 / Math.cos(latitude * Math.PI / 180);
}
async update(): Promise<void> {
this.data.features[0].geometry.coordinates = [];
beats.forEach((beat) => {
// Add lines to map backwards (because it looks cool)
for (let i = this.api.beats.length - 1; i >= 0; i--) {
const beat = this.api.beats[i];
this.data.features[0].geometry.coordinates.push([beat.coordinate[1], beat.coordinate[0]]);
this.data = { ... this.data };
});
});
}
console.log('Last', this.api.beats[0]);
this.lastLocation = [ this.api.beats[0].coordinate[1],
this.api.beats[0].coordinate[0] ];
this.lastLocationData.features[0].geometry.coordinates = this.lastLocation;
this.lastLocationData = { ...this.lastLocationData };
this.showMap = true;
}
async ngAfterViewInit(): Promise<void> {