From 4bb2c107d34947600217a2fb45de6d9938e91f0d Mon Sep 17 00:00:00 2001 From: Desy Ayurianti Date: Wed, 6 Nov 2024 05:42:46 +0700 Subject: [PATCH] feat(historygraphs): adding history graph in hourly and daily --- agrilink_vocpro/src/app/app.routes.ts | 6 + .../src/app/cores/interface/sensor-data.ts | 1 + .../layouts/sidebar/sidebar.component.html | 8 +- .../layouts/sidebar/sidebar.component.ts | 4 +- .../dashboard/page/graph/graph.component.ts | 321 +++++++++++------- .../historygraph/historygraph.component.html | 19 ++ .../historygraph/historygraph.component.scss | 137 ++++++++ .../historygraph.component.spec.ts | 23 ++ .../historygraph/historygraph.component.ts | 81 +++++ 9 files changed, 465 insertions(+), 135 deletions(-) create mode 100644 agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.html create mode 100644 agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.scss create mode 100644 agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.spec.ts create mode 100644 agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.ts diff --git a/agrilink_vocpro/src/app/app.routes.ts b/agrilink_vocpro/src/app/app.routes.ts index 31b6cf6..b31b1b4 100644 --- a/agrilink_vocpro/src/app/app.routes.ts +++ b/agrilink_vocpro/src/app/app.routes.ts @@ -4,6 +4,7 @@ import { LayoutsComponent } from './pages/dashboard/layouts/layouts.component'; import { AuthComponent } from './pages/auth/auth.component'; import { AuthGuard } from './cores/guards/auth.guard'; import { RegisterComponent } from './pages/register/register.component'; +import { HistorygraphComponent } from './pages/dashboard/page/historygraph/historygraph.component'; export const routes: Routes = [ { @@ -28,6 +29,11 @@ export const routes: Routes = [ component: DashboardComponent, canActivate: [AuthGuard] }, + { + path: 'historygraph', + component: HistorygraphComponent, + canActivate: [AuthGuard], + } ] } ]; diff --git a/agrilink_vocpro/src/app/cores/interface/sensor-data.ts b/agrilink_vocpro/src/app/cores/interface/sensor-data.ts index 165a711..51a219c 100644 --- a/agrilink_vocpro/src/app/cores/interface/sensor-data.ts +++ b/agrilink_vocpro/src/app/cores/interface/sensor-data.ts @@ -1,5 +1,6 @@ export interface ParameterSensor { hour: number; + day: number; //for DHT sensor vicitemperature?: number; diff --git a/agrilink_vocpro/src/app/pages/dashboard/layouts/sidebar/sidebar.component.html b/agrilink_vocpro/src/app/pages/dashboard/layouts/sidebar/sidebar.component.html index eb16a82..879e7fa 100644 --- a/agrilink_vocpro/src/app/pages/dashboard/layouts/sidebar/sidebar.component.html +++ b/agrilink_vocpro/src/app/pages/dashboard/layouts/sidebar/sidebar.component.html @@ -7,10 +7,10 @@ Dashboard - +
  • + + History Graph +

  • diff --git a/agrilink_vocpro/src/app/pages/dashboard/layouts/sidebar/sidebar.component.ts b/agrilink_vocpro/src/app/pages/dashboard/layouts/sidebar/sidebar.component.ts index 898c5d0..5d6289c 100644 --- a/agrilink_vocpro/src/app/pages/dashboard/layouts/sidebar/sidebar.component.ts +++ b/agrilink_vocpro/src/app/pages/dashboard/layouts/sidebar/sidebar.component.ts @@ -1,11 +1,13 @@ import { Component } from '@angular/core'; import { AuthService } from '../../../../cores/services/auth.service'; -import { Router } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { ToastrService } from 'ngx-toastr'; + @Component({ selector: 'app-sidebar', standalone: true, + imports: [RouterModule], templateUrl: './sidebar.component.html', styleUrls: ['./sidebar.component.scss'] }) diff --git a/agrilink_vocpro/src/app/pages/dashboard/page/graph/graph.component.ts b/agrilink_vocpro/src/app/pages/dashboard/page/graph/graph.component.ts index dc13980..5cc5a37 100644 --- a/agrilink_vocpro/src/app/pages/dashboard/page/graph/graph.component.ts +++ b/agrilink_vocpro/src/app/pages/dashboard/page/graph/graph.component.ts @@ -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 { SensorService } from '../../../../cores/services/sensor.service'; import { ApiResponse, ParameterSensor } from '../../../../cores/interface/sensor-data'; @@ -27,10 +27,12 @@ const parameterColors: { [key: string]: string } = { templateUrl: './graph.component.html', 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; @ViewChild('myChartNPK1', { static: false }) npk1ChartElement!: ElementRef; @ViewChild('myChartNPK2', { static: false }) npk2ChartElement!: ElementRef; + @Input() interval: string = 'hourly'; + selectedInterval: string = 'HOURLY'; isLoadingDHT: boolean = true; isLoadingNPK1: boolean = true; @@ -72,14 +74,23 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy { private resizeListener!: () => void; - constructor(private sensorService: SensorService) {} + constructor(private sensorService: SensorService, private cdr: ChangeDetectorRef) {} ngOnInit(): void { this.resizeListener = this.onResize.bind(this); 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 { this.onResize(); } @@ -97,45 +108,37 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy { this.updateCharts(); } - updateCharts(): void { - this.isLoadingDHT = this.isLoadingNPK1 = this.isLoadingNPK2 = true; - + getDate(): string { const today = new Date(); const year = today.getFullYear(); const month = String(today.getMonth() + 1).padStart(2, '0'); const day = String(today.getDate()).padStart(2, '0'); - - const startEnd = `${year}-${month}-${day}`; - const timeRange = 'HOURLY'; + return `${year}-${month}-${day}`; + } - Object.keys(this.charts).forEach(key => { - if (this.charts[key]) { - this.charts[key]?.destroy(); - this.charts[key] = undefined; - } - }); - - // Fetch data for DHT - this.sensorService.getSensorData('dht', '', startEnd, timeRange).subscribe({ + fetchDHTData(timeRange: string): void { + const startEnd = this.getDate(); + this.sensorService.getSensorData('dht', 'npk', startEnd, timeRange).subscribe({ next: (response) => { - this.isLoadingDHT = false; - if (response.statusCode === 200 && response.data.dht?.length > 0) { - this.createChart(this.dhtChartElement.nativeElement, response, 'dht', 'npk'); - this.isNoDataDHT = false; - } else { - this.isNoDataDHT = true; - } + this.isLoadingDHT = false; + if (response.statusCode === 200 && response.data.dht?.length > 0) { + this.createChart(this.dhtChartElement.nativeElement, response, 'dht', 'npk'); + this.isNoDataDHT = false; + } else { + this.isNoDataDHT = true; + } }, error: () => { - this.isLoadingDHT = false; - this.isNoDataDHT = true; + this.isLoadingDHT = false; + this.isNoDataDHT = true; } - }); + }); + } - - // Fetch data for NPK1 - this.sensorService.getSensorData('npk1', '', startEnd, timeRange).subscribe({ + fetchNPK1Data(timeRange: string): void { + const startEnd = this.getDate(); + this.sensorService.getSensorData('npk1', this.selectedNPK1, startEnd, timeRange).subscribe({ next: (response) => { this.isLoadingNPK1 = false; if (response.statusCode === 200 && response.data.npk1?.length > 0) { @@ -150,10 +153,14 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy { this.isNoDataNPK1 = true; } }); + } - // Fetch data for NPK2 - this.sensorService.getSensorData('npk2', '', startEnd, timeRange).subscribe({ + fetchNPK2Data(savedTimeRange: string): void { + const startEnd = this.getDate(); + const timeRange = this.interval; + this.sensorService.getSensorData('npk2', this.selectedNPK2, startEnd, savedTimeRange).subscribe({ next: (response) => { + console.log(savedTimeRange); this.isLoadingNPK2 = false; if (response.statusCode === 200 && response.data.npk2?.length > 0) { 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 { const ctx = canvas.getContext('2d'); const parameters = this.sensorParameters[sensor]; - + if (!ctx) { - console.error('Failed to get canvas context for sensor:', sensor); - return; + console.error('Failed to get canvas context for sensor:', sensor); + return; } - + let datasets: any[] = []; - + if (sensor === 'dht') { - datasets = ['vicitemperature', 'viciluminosity', 'vicihumidity'].map(parameter => { - const { data, labels } = this.getDataFromResponse(response, sensor, parameter); - - if (data.length === 0) { - console.warn(`No data found for parameter: ${parameter}`); - return null; - } - - const displayName = this.parameterDisplayNames[parameter] || parameter; - const borderColor = parameterColors[parameter] || '#000000'; - const backgroundColor = `${borderColor}4D`; - - return { - label: displayName, - data, - borderColor, - borderWidth: 1.5, - fill: true, - backgroundColor, - tension: 0.5, - pointRadius: 0, - pointHoverRadius: 0, - }; - }).filter(dataset => dataset !== null); + datasets = ['vicitemperature', 'viciluminosity', 'vicihumidity'].map(parameter => { + const { data, labels } = this.getDataFromResponse(response, sensor, parameter); + + if (data.length === 0) { + console.warn(`No data found for parameter: ${parameter}`); + return null; + } + + const displayName = this.parameterDisplayNames[parameter] || parameter; + const borderColor = parameterColors[parameter] || '#000000'; + const backgroundColor = `${borderColor}4D`; + + const pointRadius = data.length === 1 ? 5 : 0; + const pointHoverRadius = data.length === 1 ? 7 : 0; + + return { + label: displayName, + data, + borderColor, + borderWidth: 1.5, + fill: true, + backgroundColor, + tension: 0.5, + pointRadius, + pointHoverRadius, + }; + }).filter(dataset => dataset !== null); } else { - datasets = parameters - .filter(parameter => { - if (selectedOption === 'npk') { - return ['soilphosphorus', 'soilnitrogen', 'soilpotassium'].includes(parameter); - } else if (selectedOption === 'others') { - return !['soilphosphorus', 'soilnitrogen', 'soilpotassium'].includes(parameter); - } - return true; - }) - .map(parameter => { - const { data, labels } = this.getDataFromResponse(response, sensor, parameter); - - if (data.length === 0) { - console.warn(`No data found for parameter: ${parameter}`); - return null; - } - - const displayName = this.parameterDisplayNames[parameter] || parameter; - const borderColor = parameterColors[parameter] || '#000000'; - const backgroundColor = `${borderColor}4D`; - - return { - label: displayName, - data, - borderColor, - borderWidth: 1.5, - fill: true, - backgroundColor, - tension: 0.5, - pointRadius: 0, - pointHoverRadius: 0, - }; - }) - .filter(dataset => dataset !== null); + datasets = parameters + .filter(parameter => { + if (selectedOption === 'npk') { + return ['soilphosphorus', 'soilnitrogen', 'soilpotassium'].includes(parameter); + } else if (selectedOption === 'others') { + return !['soilphosphorus', 'soilnitrogen', 'soilpotassium'].includes(parameter); + } + return true; + }) + .map(parameter => { + const { data, labels } = this.getDataFromResponse(response, sensor, parameter); + + if (data.length === 0) { + console.warn(`No data found for parameter: ${parameter}`); + return null; + } + + const displayName = this.parameterDisplayNames[parameter] || parameter; + const borderColor = parameterColors[parameter] || '#000000'; + const backgroundColor = `${borderColor}4D`; + + const pointRadius = data.length === 1 ? 5 : 0; + const pointHoverRadius = data.length === 1 ? 7 : 0; + + return { + label: displayName, + data, + borderColor, + borderWidth: 1.5, + fill: true, + backgroundColor, + tension: 0.5, + pointRadius, + pointHoverRadius, + }; + }) + .filter(dataset => dataset !== null); } - + if (datasets.length === 0) { - console.warn('No valid datasets to render for sensor:', sensor); - return; + console.warn('No valid datasets to render for sensor:', sensor); + return; } - + if (this.charts[sensor]) { - this.charts[sensor]?.destroy(); + this.charts[sensor]?.destroy(); } - + + this.charts[sensor] = new Chart(ctx, { type: 'line', data: { - labels: this.getLabels(response, sensor), - datasets, + labels: this.getLabels(response, sensor), + datasets, }, options: { - responsive: true, - maintainAspectRatio: false, - aspectRatio: 2, - plugins: { - tooltip: { - enabled: true, - mode: 'nearest', - intersect: false, - callbacks: { - label: (tooltipItem) => { - const paramLabel = tooltipItem.dataset.label; - const value = tooltipItem.formattedValue; - return `${paramLabel}: ${value}`; - } - } + responsive: true, + maintainAspectRatio: false, + aspectRatio: 2, + plugins: { + tooltip: { + enabled: true, + mode: 'nearest', + intersect: false, + callbacks: { + label: (tooltipItem) => { + const paramLabel = tooltipItem.dataset.label; + const value = tooltipItem.formattedValue; + return `${paramLabel}: ${value}`; + } + } + }, + legend: { display: true } }, - legend: { display: true } - }, - scales: { - x: { grid: { display: false }, beginAtZero: true }, - y: { grid: { display: false }, beginAtZero: true } - } + scales: { + x: { + 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[] } { const sensorData = response.data[sensor as keyof typeof response.data]; @@ -295,7 +334,12 @@ export class GraphComponent implements OnInit, AfterViewInit, OnDestroy { if (sensorData) { sensorData.forEach(item => { 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 { 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[] { 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()]; + } + } diff --git a/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.html b/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.html new file mode 100644 index 0000000..05ad280 --- /dev/null +++ b/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.html @@ -0,0 +1,19 @@ +
    +
    +

    {{ greeting }}

    +

    View your historical data with customizable time ranges

    +
    +
    +

    Latest Update: {{ latestUpdate }}

    +
    + +
    + + +
    + + +
    + +
    +
    diff --git a/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.scss b/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.scss new file mode 100644 index 0000000..5f1aca4 --- /dev/null +++ b/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.scss @@ -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 +} \ No newline at end of file diff --git a/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.spec.ts b/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.spec.ts new file mode 100644 index 0000000..c80b804 --- /dev/null +++ b/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HistorygraphComponent } from './historygraph.component'; + +describe('HistorygraphComponent', () => { + let component: HistorygraphComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HistorygraphComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(HistorygraphComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.ts b/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.ts new file mode 100644 index 0000000..ba07afd --- /dev/null +++ b/agrilink_vocpro/src/app/pages/dashboard/page/historygraph/historygraph.component.ts @@ -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'; + } + } +} +