feat(historygraphs): adding history graph in hourly and daily

This commit is contained in:
Desy Ayurianti 2024-11-06 05:42:46 +07:00
parent 2864560c56
commit 4bb2c107d3
9 changed files with 465 additions and 135 deletions

View File

@ -4,6 +4,7 @@ import { LayoutsComponent } from './pages/dashboard/layouts/layouts.component';
import { AuthComponent } from './pages/auth/auth.component'; import { AuthComponent } from './pages/auth/auth.component';
import { AuthGuard } from './cores/guards/auth.guard'; import { AuthGuard } from './cores/guards/auth.guard';
import { RegisterComponent } from './pages/register/register.component'; import { RegisterComponent } from './pages/register/register.component';
import { HistorygraphComponent } from './pages/dashboard/page/historygraph/historygraph.component';
export const routes: Routes = [ export const routes: Routes = [
{ {
@ -28,6 +29,11 @@ export const routes: Routes = [
component: DashboardComponent, component: DashboardComponent,
canActivate: [AuthGuard] canActivate: [AuthGuard]
}, },
{
path: 'historygraph',
component: HistorygraphComponent,
canActivate: [AuthGuard],
}
] ]
} }
]; ];

View File

@ -1,5 +1,6 @@
export interface ParameterSensor { export interface ParameterSensor {
hour: number; hour: number;
day: number;
//for DHT sensor //for DHT sensor
vicitemperature?: number; vicitemperature?: number;

View File

@ -7,10 +7,10 @@
<a routerLink='/dashboard' data-bs-toggle="collapse" class="nav-link px-0 align-middle"> <a routerLink='/dashboard' data-bs-toggle="collapse" class="nav-link px-0 align-middle">
<i class="bi bi-graph-up title"></i> <span class="ms-1 d-none d-sm-inline menu">Dashboard</span> </a> <i class="bi bi-graph-up title"></i> <span class="ms-1 d-none d-sm-inline menu">Dashboard</span> </a>
</li> </li>
<!-- <li> <li>
<a routerLink='/graph' data-bs-toggle="collapse" class="nav-link px-0 align-middle"> <a routerLink='/historygraph' data-bs-toggle="collapse" class="nav-link px-0 align-middle">
<i class="bi bi-graph-up title"></i> <span class="ms-1 d-none d-sm-inline menu">Graph</span> </a> <i class="bi bi-file-earmark-text title"></i> <span class="ms-1 d-none d-sm-inline menu">History Graph</span> </a>
</li> --> </li>
</ul> </ul>
<hr> <hr>

View File

@ -1,11 +1,13 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { AuthService } from '../../../../cores/services/auth.service'; import { AuthService } from '../../../../cores/services/auth.service';
import { Router } from '@angular/router'; import { Router, RouterModule } from '@angular/router';
import { ToastrService } from 'ngx-toastr'; import { ToastrService } from 'ngx-toastr';
@Component({ @Component({
selector: 'app-sidebar', selector: 'app-sidebar',
standalone: true, standalone: true,
imports: [RouterModule],
templateUrl: './sidebar.component.html', templateUrl: './sidebar.component.html',
styleUrls: ['./sidebar.component.scss'] styleUrls: ['./sidebar.component.scss']
}) })

View File

@ -1,4 +1,4 @@
import { Component, OnInit, ElementRef, ViewChild, AfterViewInit, OnDestroy } from '@angular/core'; import { Component, OnInit, ElementRef, ViewChild, AfterViewInit, OnDestroy, OnChanges, Input, SimpleChanges, ChangeDetectorRef } from '@angular/core';
import { Chart, registerables } from 'chart.js'; import { Chart, registerables } from 'chart.js';
import { SensorService } from '../../../../cores/services/sensor.service'; import { SensorService } from '../../../../cores/services/sensor.service';
import { ApiResponse, ParameterSensor } from '../../../../cores/interface/sensor-data'; import { ApiResponse, ParameterSensor } from '../../../../cores/interface/sensor-data';
@ -27,10 +27,12 @@ const parameterColors: { [key: string]: string } = {
templateUrl: './graph.component.html', templateUrl: './graph.component.html',
styleUrls: ['./graph.component.scss'] styleUrls: ['./graph.component.scss']
}) })
export class GraphComponent implements OnInit, AfterViewInit, OnDestroy { export class GraphComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges{
@ViewChild('myChartDHT', { static: false }) dhtChartElement!: ElementRef<HTMLCanvasElement>; @ViewChild('myChartDHT', { static: false }) dhtChartElement!: ElementRef<HTMLCanvasElement>;
@ViewChild('myChartNPK1', { static: false }) npk1ChartElement!: ElementRef<HTMLCanvasElement>; @ViewChild('myChartNPK1', { static: false }) npk1ChartElement!: ElementRef<HTMLCanvasElement>;
@ViewChild('myChartNPK2', { static: false }) npk2ChartElement!: ElementRef<HTMLCanvasElement>; @ViewChild('myChartNPK2', { static: false }) npk2ChartElement!: ElementRef<HTMLCanvasElement>;
@Input() interval: string = 'hourly';
selectedInterval: string = 'HOURLY';
isLoadingDHT: boolean = true; isLoadingDHT: boolean = true;
isLoadingNPK1: boolean = true; isLoadingNPK1: boolean = true;
@ -72,14 +74,23 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy {
private resizeListener!: () => void; private resizeListener!: () => void;
constructor(private sensorService: SensorService) {} constructor(private sensorService: SensorService, private cdr: ChangeDetectorRef) {}
ngOnInit(): void { ngOnInit(): void {
this.resizeListener = this.onResize.bind(this); this.resizeListener = this.onResize.bind(this);
window.addEventListener('resize', this.resizeListener); window.addEventListener('resize', this.resizeListener);
this.updateCharts(); this.updateCharts();
} }
ngOnChanges(changes: SimpleChanges): void {
if (changes['interval'] && !changes['interval'].firstChange) {
this.selectedInterval = changes['interval'].currentValue;
this.updateCharts();
}
}
ngAfterViewInit(): void { ngAfterViewInit(): void {
this.onResize(); this.onResize();
} }
@ -97,45 +108,37 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy {
this.updateCharts(); this.updateCharts();
} }
updateCharts(): void { getDate(): string {
this.isLoadingDHT = this.isLoadingNPK1 = this.isLoadingNPK2 = true;
const today = new Date(); const today = new Date();
const year = today.getFullYear(); const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0'); const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0');
const startEnd = `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
const timeRange = 'HOURLY'; }
Object.keys(this.charts).forEach(key => { fetchDHTData(timeRange: string): void {
if (this.charts[key]) { const startEnd = this.getDate();
this.charts[key]?.destroy(); this.sensorService.getSensorData('dht', 'npk', startEnd, timeRange).subscribe({
this.charts[key] = undefined;
}
});
// Fetch data for DHT
this.sensorService.getSensorData('dht', '', startEnd, timeRange).subscribe({
next: (response) => { next: (response) => {
this.isLoadingDHT = false; this.isLoadingDHT = false;
if (response.statusCode === 200 && response.data.dht?.length > 0) { if (response.statusCode === 200 && response.data.dht?.length > 0) {
this.createChart(this.dhtChartElement.nativeElement, response, 'dht', 'npk'); this.createChart(this.dhtChartElement.nativeElement, response, 'dht', 'npk');
this.isNoDataDHT = false; this.isNoDataDHT = false;
} else { } else {
this.isNoDataDHT = true; this.isNoDataDHT = true;
} }
}, },
error: () => { error: () => {
this.isLoadingDHT = false; this.isLoadingDHT = false;
this.isNoDataDHT = true; this.isNoDataDHT = true;
} }
}); });
}
fetchNPK1Data(timeRange: string): void {
// Fetch data for NPK1 const startEnd = this.getDate();
this.sensorService.getSensorData('npk1', '', startEnd, timeRange).subscribe({ this.sensorService.getSensorData('npk1', this.selectedNPK1, startEnd, timeRange).subscribe({
next: (response) => { next: (response) => {
this.isLoadingNPK1 = false; this.isLoadingNPK1 = false;
if (response.statusCode === 200 && response.data.npk1?.length > 0) { if (response.statusCode === 200 && response.data.npk1?.length > 0) {
@ -150,10 +153,14 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy {
this.isNoDataNPK1 = true; this.isNoDataNPK1 = true;
} }
}); });
}
// Fetch data for NPK2 fetchNPK2Data(savedTimeRange: string): void {
this.sensorService.getSensorData('npk2', '', startEnd, timeRange).subscribe({ const startEnd = this.getDate();
const timeRange = this.interval;
this.sensorService.getSensorData('npk2', this.selectedNPK2, startEnd, savedTimeRange).subscribe({
next: (response) => { next: (response) => {
console.log(savedTimeRange);
this.isLoadingNPK2 = false; this.isLoadingNPK2 = false;
if (response.statusCode === 200 && response.data.npk2?.length > 0) { if (response.statusCode === 200 && response.data.npk2?.length > 0) {
this.createChart(this.npk2ChartElement.nativeElement, response, 'npk2', this.selectedNPK2); this.createChart(this.npk2ChartElement.nativeElement, response, 'npk2', this.selectedNPK2);
@ -169,123 +176,155 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy {
}); });
} }
updateCharts(): void {
const interval = this.selectedInterval;
Object.keys(this.charts).forEach(key => {
if (this.charts[key]) {
this.charts[key]?.destroy();
this.charts[key] = undefined;
}
});
this.fetchDHTData(interval);
this.fetchNPK1Data(interval);
this.fetchNPK2Data(interval);
}
createChart(canvas: HTMLCanvasElement, response: ApiResponse, sensor: string, selectedOption: string): void { createChart(canvas: HTMLCanvasElement, response: ApiResponse, sensor: string, selectedOption: string): void {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
const parameters = this.sensorParameters[sensor]; const parameters = this.sensorParameters[sensor];
if (!ctx) { if (!ctx) {
console.error('Failed to get canvas context for sensor:', sensor); console.error('Failed to get canvas context for sensor:', sensor);
return; return;
} }
let datasets: any[] = []; let datasets: any[] = [];
if (sensor === 'dht') { if (sensor === 'dht') {
datasets = ['vicitemperature', 'viciluminosity', 'vicihumidity'].map(parameter => { datasets = ['vicitemperature', 'viciluminosity', 'vicihumidity'].map(parameter => {
const { data, labels } = this.getDataFromResponse(response, sensor, parameter); const { data, labels } = this.getDataFromResponse(response, sensor, parameter);
if (data.length === 0) { if (data.length === 0) {
console.warn(`No data found for parameter: ${parameter}`); console.warn(`No data found for parameter: ${parameter}`);
return null; return null;
} }
const displayName = this.parameterDisplayNames[parameter] || parameter; const displayName = this.parameterDisplayNames[parameter] || parameter;
const borderColor = parameterColors[parameter] || '#000000'; const borderColor = parameterColors[parameter] || '#000000';
const backgroundColor = `${borderColor}4D`; const backgroundColor = `${borderColor}4D`;
return { const pointRadius = data.length === 1 ? 5 : 0;
label: displayName, const pointHoverRadius = data.length === 1 ? 7 : 0;
data,
borderColor, return {
borderWidth: 1.5, label: displayName,
fill: true, data,
backgroundColor, borderColor,
tension: 0.5, borderWidth: 1.5,
pointRadius: 0, fill: true,
pointHoverRadius: 0, backgroundColor,
}; tension: 0.5,
}).filter(dataset => dataset !== null); pointRadius,
pointHoverRadius,
};
}).filter(dataset => dataset !== null);
} else { } else {
datasets = parameters datasets = parameters
.filter(parameter => { .filter(parameter => {
if (selectedOption === 'npk') { if (selectedOption === 'npk') {
return ['soilphosphorus', 'soilnitrogen', 'soilpotassium'].includes(parameter); return ['soilphosphorus', 'soilnitrogen', 'soilpotassium'].includes(parameter);
} else if (selectedOption === 'others') { } else if (selectedOption === 'others') {
return !['soilphosphorus', 'soilnitrogen', 'soilpotassium'].includes(parameter); return !['soilphosphorus', 'soilnitrogen', 'soilpotassium'].includes(parameter);
} }
return true; return true;
}) })
.map(parameter => { .map(parameter => {
const { data, labels } = this.getDataFromResponse(response, sensor, parameter); const { data, labels } = this.getDataFromResponse(response, sensor, parameter);
if (data.length === 0) { if (data.length === 0) {
console.warn(`No data found for parameter: ${parameter}`); console.warn(`No data found for parameter: ${parameter}`);
return null; return null;
} }
const displayName = this.parameterDisplayNames[parameter] || parameter; const displayName = this.parameterDisplayNames[parameter] || parameter;
const borderColor = parameterColors[parameter] || '#000000'; const borderColor = parameterColors[parameter] || '#000000';
const backgroundColor = `${borderColor}4D`; const backgroundColor = `${borderColor}4D`;
return { const pointRadius = data.length === 1 ? 5 : 0;
label: displayName, const pointHoverRadius = data.length === 1 ? 7 : 0;
data,
borderColor, return {
borderWidth: 1.5, label: displayName,
fill: true, data,
backgroundColor, borderColor,
tension: 0.5, borderWidth: 1.5,
pointRadius: 0, fill: true,
pointHoverRadius: 0, backgroundColor,
}; tension: 0.5,
}) pointRadius,
.filter(dataset => dataset !== null); pointHoverRadius,
};
})
.filter(dataset => dataset !== null);
} }
if (datasets.length === 0) { if (datasets.length === 0) {
console.warn('No valid datasets to render for sensor:', sensor); console.warn('No valid datasets to render for sensor:', sensor);
return; return;
} }
if (this.charts[sensor]) { if (this.charts[sensor]) {
this.charts[sensor]?.destroy(); this.charts[sensor]?.destroy();
} }
this.charts[sensor] = new Chart(ctx, { this.charts[sensor] = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
labels: this.getLabels(response, sensor), labels: this.getLabels(response, sensor),
datasets, datasets,
}, },
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
aspectRatio: 2, aspectRatio: 2,
plugins: { plugins: {
tooltip: { tooltip: {
enabled: true, enabled: true,
mode: 'nearest', mode: 'nearest',
intersect: false, intersect: false,
callbacks: { callbacks: {
label: (tooltipItem) => { label: (tooltipItem) => {
const paramLabel = tooltipItem.dataset.label; const paramLabel = tooltipItem.dataset.label;
const value = tooltipItem.formattedValue; const value = tooltipItem.formattedValue;
return `${paramLabel}: ${value}`; return `${paramLabel}: ${value}`;
} }
} }
},
legend: { display: true }
}, },
legend: { display: true } scales: {
}, x: {
scales: { grid: { display: false },
x: { grid: { display: false }, beginAtZero: true }, beginAtZero: true,
y: { grid: { display: false }, beginAtZero: true } ticks: {
} callback: (value, index, values) => {
const labels = this.getLabels(response, sensor);
return labels[index] || value;
}
}
},
y: { grid: { display: false }, beginAtZero: true }
}
} }
}); });
}
}
getDataFromResponse(response: ApiResponse, sensor: string, parameter: string): { data: number[], labels: string[] } { getDataFromResponse(response: ApiResponse, sensor: string, parameter: string): { data: number[], labels: string[] } {
const sensorData = response.data[sensor as keyof typeof response.data]; const sensorData = response.data[sensor as keyof typeof response.data];
@ -295,7 +334,12 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy {
if (sensorData) { if (sensorData) {
sensorData.forEach(item => { sensorData.forEach(item => {
data.push(item[parameter as keyof ParameterSensor] ?? 0); data.push(item[parameter as keyof ParameterSensor] ?? 0);
labels.push(`${item.hour}.00`); if(this.interval === 'HOURLY') {
labels.push(`${item.hour}.00`);
}
else if (this.interval === 'DAILY') {
labels.push(item.day);
}
}); });
} else { } else {
console.error('No data found for sensor:', sensor); console.error('No data found for sensor:', sensor);
@ -306,6 +350,23 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy {
getLabels(response: ApiResponse, sensor: string): string[] { getLabels(response: ApiResponse, sensor: string): string[] {
const sensorData = response.data[sensor as keyof typeof response.data]; const sensorData = response.data[sensor as keyof typeof response.data];
return sensorData ? sensorData.map(item => `${item.hour}.00`) : [];
return sensorData.map(item => {
if (this.interval === 'HOURLY' || this.selectedInterval === 'HOURLY') {
return `${item.hour}.00`;
} else if (this.interval === 'DAILY') {
const day = item.day;
return this.convertDateToDay(day);
} else {
return '';
}
});
} }
convertDateToDay(dateString: string): string {
const date = new Date(dateString);
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thrusday', 'Friday', 'Saturday'];
return days[date.getDay()];
}
} }

View File

@ -0,0 +1,19 @@
<div class="container">
<div>
<h1 class="title">{{ greeting }}</h1>
<h3 class="description">View your historical data with customizable time ranges</h3>
</div>
<div>
<h2 class="update">Latest Update: {{ latestUpdate }}</h2>
</div>
<div>
<button (click)="updateInterval('HOURLY')" [class.active-button]="selectedButton === 'hourly'">Hourly</button>
<button (click)="updateInterval('DAILY')" [class.active-button]="selectedButton === 'daily'">Daily</button>
</div>
<div class="graph">
<app-graph [interval]="selectedInterval"></app-graph>
</div>
</div>

View File

@ -0,0 +1,137 @@
.container {
font-family: "Onest", sans-serif;
}
.title {
color: #49473C;
font-size: 30px;
margin-top: 10px;
}
.description {
color: #49473C;
font-size: 15px;
margin-top: 10px;
}
.update{
color: #49473C;
font-size: 15px;
margin-top: 18px;
}
.card-container {
display: flex;
flex-wrap: wrap;
gap: 30px;
margin-top: 20px;
justify-content: flex-start;
}
.card-parameter{
border: 1px solid #16423C;
color: #16423C;
padding: 20px 0px 20px 0px;
border-radius: 8px;
text-align: center;
flex: 1 1 30%;
max-width: 30%;
min-width: 200px;
}
.card-parameter:hover{
background-color: #16423C;
color: white;
}
.card-content{
text-align: center;
margin:auto;
}
.title-graph{
color: #49473C;
font-size: 23px;
font-weight: 400;
margin-top: 45px;
}
.graph{
margin-top: 22px;
}
.relay-container {
padding: 20px 0px 20px 0px;
display: flex;
flex-wrap: wrap;
gap: 30px;
justify-content: flex-start;
}
.relay-card {
border-radius: 8px;
flex: 1 1 30%;
max-width: 30%;
min-width: 200px;
}
@media (max-width: 768px) {
.card-parameter{
flex: 1 1 45%;
}
.card-container{
justify-content: center;
}
}
@media (max-width: 576px) {
.card-parameter{
flex: 1 1 100%;
}
.card-container{
justify-content: center;
}
}
button {
border: none;
border-radius: 10px;
padding: 5px 10px;
font-size: 15px;
cursor: pointer;
margin-right: 20px;
}
button {
border: none;
border-radius: 10px;
padding: 5px 10px;
font-size: 15px;
cursor: pointer;
margin-right: 20px;
}
.active-button {
background-color: #cad849;
color: white;
}
.loading{
font-size: 18px;
text-align: center;
}
.status-on {
color: #16423C;
}
.status-off {
color: rgb(144, 6, 6);
}
.spinner {
color: #16423C
}

View File

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

View File

@ -0,0 +1,81 @@
import { Component, ViewChild, OnInit, OnDestroy } from '@angular/core';
import { GraphComponent } from '../graph/graph.component';
import { CommonModule } from '@angular/common';
import { ChangeDetectorRef } from '@angular/core';
@Component({
selector: 'app-historygraph',
standalone: true,
imports: [GraphComponent, CommonModule],
templateUrl: './historygraph.component.html',
styleUrls: ['./historygraph.component.scss']
})
export class HistorygraphComponent implements OnInit, OnDestroy {
selectedButton: string = '';
selectedInterval: string = '';
latestUpdate: string = '';
intervalId: any;
greeting: string = '';
constructor(private cdr: ChangeDetectorRef) {}
@ViewChild(GraphComponent) graphComponent!: GraphComponent;
ngOnInit(): void {
this.startClock();
this.updateGreeting();
this.selectedButton = 'hourly';
this.selectedInterval = 'HOURLY';
}
private debounceTimeout: any;
updateInterval(interval: string): void {
clearTimeout(this.debounceTimeout);
this.debounceTimeout = setTimeout(() => {
if (this.selectedInterval !== interval) {
this.selectedInterval = interval;
this.selectedButton = interval.toLowerCase();
this.cdr.detectChanges();
this.graphComponent.updateCharts();
}
}, 300);
}
ngOnDestroy(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
}
}
startClock(): void {
this.updateLatestTime();
this.intervalId = setInterval(() => this.updateLatestTime(), 1000);
}
updateLatestTime(): void {
const now = new Date();
this.latestUpdate = now.toLocaleString('en-GB', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
updateGreeting(): void {
const hour = new Date().getHours();
if (hour < 12) {
this.greeting = 'Good Morning';
} else if (hour < 18) {
this.greeting = 'Good Afternoon';
} else {
this.greeting = 'Good Evening';
}
}
}