diff --git a/django_project/frontend/api_views/analysis.py b/django_project/frontend/api_views/analysis.py index 806f869f..c549a848 100644 --- a/django_project/frontend/api_views/analysis.py +++ b/django_project/frontend/api_views/analysis.py @@ -5,6 +5,7 @@ .. note:: Analysis APIs """ import uuid +from copy import deepcopy from concurrent.futures import ThreadPoolExecutor from rest_framework import status from rest_framework.permissions import IsAuthenticated @@ -54,13 +55,76 @@ def run_baseline_analysis(self, data): analysis_dict=analysis_dict ) + def _combine_temporal_analysis_results(self, years, input_results): + def merge_and_sort(arrays): + unique_dict = {} + + for array in arrays: + for item in array['features']: + key = ( + f"{item['properties']['Name']}-" + f"{item['properties']['date']}" + ) + # Overwrites duplicates, ensuring uniqueness + unique_dict[key] = item + return list(unique_dict.values()) + + def add_empty_records(existing_records): + new_records = {} + for year in years: + has_record = len( + list( + filter( + lambda x: x['properties']['year'] == year, + existing_records + ) + ) + ) > 0 + if not has_record: + for record in existing_records: + key = f'{record["properties"]["Name"]}-{year}' + if key not in new_records: + new_record = deepcopy(record) + new_record['properties']['year'] = year + new_record['properties']['Bare ground'] = None + new_record['properties']['EVI'] = None + new_record['properties']['NDVI'] = None + new_records[key] = new_record + return new_records.values() + + output_results = [] + output_results.append(input_results[0][0]) + output_results.append(input_results[0][1]) + output_results[0]['features'] = merge_and_sort( + [ir[0] for ir in input_results] + ) + + output_results[0]['features'].extend( + add_empty_records(output_results[1]['features']) + ) + # add empty result if no data exist for certain year + output_results[1]['features'] = merge_and_sort( + [ir[1] for ir in input_results] + ) + + output_results[0]['features'] = sorted( + output_results[0]['features'], + key=lambda x: x['properties']['date'] + ) + output_results[1]['features'] = sorted( + output_results[1]['features'], + key=lambda x: x['properties']['date'] + ) + + return output_results + def run_temporal_analysis(self, data): """Run the temporal analysis.""" analysis_dict_list = [] - comp_years = data['comparisonPeriod']['years'].split(',') - comp_quarters = data['comparisonPeriod'].get('quarters', '').split(',') + comp_years = data['comparisonPeriod']['year'] + comp_quarters = data['comparisonPeriod'].get('quarter', []) if len(comp_years) == 0: - comp_quarters = [''] * len(comp_years) + comp_quarters = [None] * len(comp_years) analysis_dict_list = [] for idx, comp_year in enumerate(comp_years): @@ -98,15 +162,15 @@ def run_temporal_analysis(self, data): futures = [ executor.submit( run_analysis, - analysis_dict, + data['latitude'], data['longitude'], - data['latitude'] + analysis_dict ) for analysis_dict in analysis_dict_list ] - # Collect results as they complete results = [future.result() for future in futures] + results = self._combine_temporal_analysis_results(comp_years, results) return results def run_spatial_analysis(self, data): diff --git a/django_project/frontend/src/components/Map/DataTypes.tsx b/django_project/frontend/src/components/Map/DataTypes.tsx index aaaf84da..81bb6d58 100644 --- a/django_project/frontend/src/components/Map/DataTypes.tsx +++ b/django_project/frontend/src/components/Map/DataTypes.tsx @@ -3,8 +3,8 @@ */ export interface AnalysisDataPeriod { - year?: number; - quarter?: number; + year?: number | number[]; + quarter?: number | number[]; } export interface AnalysisData { diff --git a/django_project/frontend/src/components/Map/LeftSide/Analysis/AnalysisReferencePeriod.tsx b/django_project/frontend/src/components/Map/LeftSide/Analysis/AnalysisReferencePeriod.tsx index bede59c7..436754bc 100644 --- a/django_project/frontend/src/components/Map/LeftSide/Analysis/AnalysisReferencePeriod.tsx +++ b/django_project/frontend/src/components/Map/LeftSide/Analysis/AnalysisReferencePeriod.tsx @@ -1,33 +1,83 @@ -import React from 'react'; +import React, { useState } from 'react'; import { AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box, - Select + Select, + Button, } from "@chakra-ui/react"; import { AnalysisDataPeriod } from "../../DataTypes"; - interface Props { title: string; value: AnalysisDataPeriod; isQuarter: boolean; - onSelectedYear: (year: number) => void; - onSelectedQuarter: (quarter: number) => void; + multiple?: boolean; + onSelectedYear: (year: number | number[]) => void; + onSelectedQuarter: (quarter: number | number[]) => void; +} + +type DefaultPanel = { + id: number, + year: number, + quarter: number | null } /** Reference period. */ export default function AnalysisReferencePeriod( - { title, value, isQuarter, onSelectedYear, onSelectedQuarter }: Props + { title, value, isQuarter, multiple=false, onSelectedYear, onSelectedQuarter }: Props ) { - const nowYear = new Date().getFullYear() - const years: number[] = [] + const nowYear = new Date().getFullYear(); + const years: number[] = []; for (let i = 0; i <= 10; i++) { - years.push(nowYear - i) + years.push(nowYear - i); + } + years.reverse(); + + // State to manage multiple AccordionPanels + + let defaultPanels: DefaultPanel[] = []; + if (multiple && Array.isArray(value.year) && Array.isArray(value.quarter)) { + for (let i = 0; i < value.year.length; i++) { + defaultPanels.push({ id: i, year: value.year[i], quarter: value.quarter[i] }); + } + } else if (!Array.isArray(value.year) && !Array.isArray(value.quarter)){ + defaultPanels = [{ id: 0, year: value.year, quarter: value.quarter }] } - years.reverse() + const [panels, setPanels] = useState(defaultPanels); + + // Add a new AccordionPanel + const handleAddPanel = () => { + setPanels([...panels, { id: panels.length + 1, year: null, quarter: null }]); + }; + + // Remove an AccordionPanel + const handleDeletePanel = (id: number) => { + const updatedPanels = panels.filter(panel => panel.id !== id); + setPanels(updatedPanels); + onSelectedYear(updatedPanels.map(panel => panel.year)); // Update the parent component + onSelectedQuarter(updatedPanels.map(panel => panel.quarter)); // Update the parent component + }; + + // Handle year selection + const handleYearChange = (id: number, year: number) => { + const updatedPanels = panels.map(panel => + panel.id === id ? { ...panel, year } : panel + ); + setPanels(updatedPanels); + onSelectedYear(multiple ? updatedPanels.map(panel => panel.year) : updatedPanels[0].year); // Update the parent component + }; + + // Handle quarter selection + const handleQuarterChange = (id: number, quarter: number) => { + const updatedPanels = panels.map(panel => + panel.id === id ? { ...panel, quarter } : panel + ); + setPanels(updatedPanels); + onSelectedQuarter(multiple ? updatedPanels.map(panel => panel.quarter) : updatedPanels[0].quarter); // Update the parent component + }; return ( @@ -36,49 +86,55 @@ export default function AnalysisReferencePeriod( {title} - + - - - { - isQuarter && + {panels.map((panel) => ( + - } - + {isQuarter && ( + + )} + { + panels.length > 1 && } + + ))} + {multiple && + + } - ) -} - + ); +} \ No newline at end of file diff --git a/django_project/frontend/src/components/Map/LeftSide/Analysis/index.tsx b/django_project/frontend/src/components/Map/LeftSide/Analysis/index.tsx index 09916695..3c3746d6 100644 --- a/django_project/frontend/src/components/Map/LeftSide/Analysis/index.tsx +++ b/django_project/frontend/src/components/Map/LeftSide/Analysis/index.tsx @@ -134,7 +134,14 @@ export default function Analysis({ landscapes, layers, onLayerChecked, onLayerUn // remove polygon for reference layer diff geometrySelectorRef?.current?.removeLayer(); } - dispatch(doAnalysis(data)) + const newData = { + ...data, + comparisonPeriod: { + year: data.comparisonPeriod?.year, + quarter: data.temporalResolution == 'Quarterly' ? data.comparisonPeriod?.quarter : [] + }, + } + dispatch(doAnalysis(newData)) } useEffect(() => { @@ -209,6 +216,7 @@ export default function Analysis({ landscapes, layers, onLayerChecked, onLayerUn disableSubmit = true; } + return ( @@ -419,6 +427,7 @@ export default function Analysis({ landscapes, layers, onLayerChecked, onLayerUn title='6) Select comparison period' value={data.comparisonPeriod} isQuarter={data.temporalResolution === TemporalResolution.QUARTERLY} + multiple={true} onSelectedYear={(value: number) => setData({ ...data, comparisonPeriod: { diff --git a/django_project/frontend/src/components/Map/RightSide/AnalysisResult.tsx b/django_project/frontend/src/components/Map/RightSide/AnalysisResult.tsx index 4e71e5ab..442cd60e 100644 --- a/django_project/frontend/src/components/Map/RightSide/AnalysisResult.tsx +++ b/django_project/frontend/src/components/Map/RightSide/AnalysisResult.tsx @@ -11,6 +11,9 @@ import {FeatureCollection} from "geojson"; import 'chartjs-adapter-date-fns'; import './style.css'; +import { features } from 'process'; +import { rmSync } from 'fs'; +import { colors } from '../../../theme/foundations'; Chart.register(CategoryScale); @@ -18,6 +21,20 @@ interface Props { analysis: Analysis; } +const COLORS = [ + "#FF0000", // Red + "#0000FF", // Blue + "#008000", // Green + "#FFA500", // Orange + "#800080", // Purple + "#00FFFF", // Cyan + "#FF00FF", // Magenta + "#FFFF00", // Yellow + "#00FF00", // Lime + "#008080" // Teal +]; + + export function BarChart({ analysis }: Props) { // Extracting data for the chart const jsonData = analysis.results[0]; @@ -26,40 +43,32 @@ export function BarChart({ analysis }: Props) { return } - let labels: number[] = [jsonData.features[0].properties.year]; - if (jsonData.features.length > 1) { - labels.push(jsonData.features[jsonData.features.length -1].properties.year); - } - const name1 = jsonData.features[0].properties.Name; - const name2 = jsonData.features.length > 1 ? jsonData.features[1].properties.Name : null; + let labels: number[] = jsonData.features.map((feature:any) => feature.properties.year); + labels = labels.filter((item, index) => labels.indexOf(item) === index) - const dataBar1 = jsonData.features - .filter((feature:any) => feature.properties.Name === name1) + let datasets: { [key: string]: any } = {} + for (let i = 0; i < jsonData.features.length; i++) { + const key: string = `${jsonData.features[i].properties.Name}`; + if (datasets[key as string]) { + continue; + } + const data = jsonData.features + .filter((feature:any) => feature.properties.Name === jsonData.features[i].properties.Name) .map((feature:any) => feature.properties[analysis.data.variable]); + const label = jsonData.features[i].properties.Name + + datasets[key] = { + label: label, + data: data, + backgroundColor: COLORS[i % COLORS.length], + }; + } let chartData:any = { labels, - datasets: [ - { - label: name1, - data: dataBar1, - backgroundColor: "blue" - } - ], + datasets: Object.values(datasets), }; - if (name2 !== null && name1 != name2) { - const dataBar2 = jsonData.features - .filter((feature:any) => feature.properties.Name === name2) - .map((feature:any) => feature.properties[analysis.data.variable]); - - chartData.datasets.push({ - label: name2, - data: dataBar2, - backgroundColor: "red" - }); - } - const options:any = { responsive: true, plugins: { diff --git a/django_project/frontend/tests/api_views/test_analysis.py b/django_project/frontend/tests/api_views/test_analysis.py index 0c6a0e0f..d1e4aa58 100644 --- a/django_project/frontend/tests/api_views/test_analysis.py +++ b/django_project/frontend/tests/api_views/test_analysis.py @@ -6,6 +6,7 @@ """ from django.urls import reverse +from django.utils import timezone from unittest.mock import patch from analysis.models import Landscape @@ -27,9 +28,63 @@ def test_temporal_analysis(self, mock_init_gee, mock_analysis): def side_effect_func(*args, **kwargs): """Side effect function.""" if args: + year = args[2]['Temporal']['Annual']['test'] + timestamp = timezone.now().replace(year=year).timestamp() return [ - {'year': args[0]['Temporal']['Annual']['test']}, - {'quarter': args[0]['Temporal']['Quarterly']['test']} + { + "type": "FeatureCollection", + "columns": { + "Bare ground": "Float", + "EVI": "Float", + "NDVI": "Float", + "Name": "String", + "date": "Long", + "system:index": "String", + "year": "Integer" + }, + "features": [ + { + "type": "Feature", + "geometry": None, + "id": "4669", + "properties": { + "Bare ground": 66.98364803153024, + "EVI": 0.25931378422899043, + "NDVI": 0.18172535940724382, + "Name": "BNP western polygon", + "date": timestamp, + "year": year + } + } + ] + }, + { + "type": "FeatureCollection", + "columns": { + "Bare ground": "Float", + "EVI": "Float", + "NDVI": "Float", + "Name": "String", + "date": "Long", + "system:index": "String", + "year": "Integer" + }, + "features": [ + { + "type": "Feature", + "geometry": None, + "id": "4669", + "properties": { + "Bare ground": 66.98364803153024, + "EVI": 0.25931378422899043, + "NDVI": 0.18172535940724382, + "Name": "BNP western polygon", + "date": timestamp, + "year": year + } + } + ] + } ] mock_analysis.side_effect = side_effect_func mock_init_gee.return_value = None @@ -48,8 +103,8 @@ def side_effect_func(*args, **kwargs): 'quarter': '1' }, 'comparisonPeriod': { - 'years': '2017,2019,2020', - 'quarters': '1,2,3' + 'year': [2019,2017,2020], + 'quarter': [2,1,3] } } request = self.factory.post( @@ -59,17 +114,22 @@ def side_effect_func(*args, **kwargs): ) request.user = self.superuser response = view(request) + self.assertEqual(response.status_code, 200) results = response.data['results'] self.assertEqual( len(results), - 3 + 2 + ) + self.assertEqual( + len(results[0]['features']), + 5 + ) + self.assertEqual( + results[0]['features'][0]['properties']['year'], + 2017 ) self.assertEqual( - results, - [ - [{'year': '2017'}, {'quarter': '1'}], - [{'year': '2019'}, {'quarter': '2'}], - [{'year': '2020'}, {'quarter': '3'}] - ] + results[0]['features'][-1]['properties']['year'], + 2020 )