diff --git a/.kiro/specs/beach-litter-mapping/tasks.md b/.kiro/specs/beach-litter-mapping/tasks.md index c4f85c6..854b5d6 100644 --- a/.kiro/specs/beach-litter-mapping/tasks.md +++ b/.kiro/specs/beach-litter-mapping/tasks.md @@ -83,7 +83,7 @@ - Create export to CSV functionality - _Requirements: 5.1, 5.5_ -- [ ] 11. Implement data visualization charts +- [x] 11. Implement data visualization charts - Create time series chart for report trends - Build pie chart for litter type distribution - Implement comparison metrics with previous periods diff --git a/src/components/analytics/LitterTypeChart.tsx b/src/components/analytics/LitterTypeChart.tsx new file mode 100644 index 0000000..83a2c42 --- /dev/null +++ b/src/components/analytics/LitterTypeChart.tsx @@ -0,0 +1,107 @@ +import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from 'recharts'; +import type { LitterTypeDistribution } from '@/types'; + +interface LitterTypeChartProps { + data: LitterTypeDistribution[]; + loading?: boolean; +} + +const COLORS: Record = { + plastic: '#3b82f6', // blue + metal: '#6b7280', // gray + glass: '#10b981', // green + organic: '#84cc16', // lime + hazardous: '#ef4444', // red + other: '#8b5cf6', // purple +}; + +const LITTER_TYPE_LABELS: Record = { + plastic: 'Plastic', + metal: 'Metal', + glass: 'Glass', + organic: 'Organic', + hazardous: 'Hazardous', + other: 'Other', +}; + +export function LitterTypeChart({ data, loading }: LitterTypeChartProps) { + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!data || data.length === 0) { + return ( +
+

Litter Type Distribution

+
+ No data available for the selected period +
+
+ ); + } + + // Format data for the chart + const chartData = data.map(item => ({ + name: LITTER_TYPE_LABELS[item.type] || item.type, + value: item.count, + percentage: item.percentage, + })); + + const renderCustomLabel = (entry: { name: string; percentage: number }) => { + return `${entry.name}: ${entry.percentage.toFixed(1)}%`; + }; + + return ( +
+

Litter Type Distribution

+ + + + {chartData.map((entry, index) => { + const litterType = data[index].type; + return ( + + ); + })} + + [ + `${value} reports (${props.payload.percentage.toFixed(1)}%)`, + name + ]} + contentStyle={{ + backgroundColor: '#fff', + border: '1px solid #e5e7eb', + borderRadius: '0.5rem', + padding: '0.75rem' + }} + /> + + + +
+ ); +} diff --git a/src/components/analytics/QuantityChart.tsx b/src/components/analytics/QuantityChart.tsx new file mode 100644 index 0000000..6f0f4b5 --- /dev/null +++ b/src/components/analytics/QuantityChart.tsx @@ -0,0 +1,93 @@ +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts'; +import type { QuantityAggregation } from '@/types'; + +interface QuantityChartProps { + data: QuantityAggregation[]; + loading?: boolean; +} + +const QUANTITY_COLORS: Record = { + minimal: '#10b981', // green + moderate: '#f59e0b', // amber + significant: '#f97316', // orange + severe: '#ef4444', // red +}; + +const QUANTITY_LABELS: Record = { + minimal: 'Minimal', + moderate: 'Moderate', + significant: 'Significant', + severe: 'Severe', +}; + +export function QuantityChart({ data, loading }: QuantityChartProps) { + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!data || data.length === 0) { + return ( +
+

Quantity Estimation

+
+ No data available for the selected period +
+
+ ); + } + + // Format data for the chart + const chartData = data.map(item => ({ + name: QUANTITY_LABELS[item.quantity] || item.quantity, + count: item.count, + percentage: item.percentage, + quantity: item.quantity, + })); + + return ( +
+

Litter Quantity Distribution

+ + + + + + [ + `${value} reports (${props.payload.percentage.toFixed(1)}%)`, + 'Count' + ]} + contentStyle={{ + backgroundColor: '#fff', + border: '1px solid #e5e7eb', + borderRadius: '0.5rem', + padding: '0.75rem' + }} + /> + + {chartData.map((entry, index) => ( + + ))} + + + +
+ ); +} diff --git a/src/components/analytics/TimeSeriesChart.tsx b/src/components/analytics/TimeSeriesChart.tsx new file mode 100644 index 0000000..ed1704a --- /dev/null +++ b/src/components/analytics/TimeSeriesChart.tsx @@ -0,0 +1,82 @@ +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; +import type { TimeSeriesDataPoint } from '@/types'; + +interface TimeSeriesChartProps { + data: TimeSeriesDataPoint[]; + loading?: boolean; +} + +export function TimeSeriesChart({ data, loading }: TimeSeriesChartProps) { + if (loading) { + return ( +
+
+
+
+
+
+ ); + } + + if (!data || data.length === 0) { + return ( +
+

Report Trends

+
+ No data available for the selected period +
+
+ ); + } + + return ( +
+

Report Trends Over Time

+ + + + + + + + + {data.some(d => d.events !== undefined) && ( + + )} + + +
+ ); +} diff --git a/src/components/analytics/index.ts b/src/components/analytics/index.ts index 18d1c2b..a195964 100644 --- a/src/components/analytics/index.ts +++ b/src/components/analytics/index.ts @@ -1,2 +1,5 @@ export { DateRangeSelector } from './DateRangeSelector'; export { StatCard } from './StatCard'; +export { TimeSeriesChart } from './TimeSeriesChart'; +export { LitterTypeChart } from './LitterTypeChart'; +export { QuantityChart } from './QuantityChart'; diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index 3773f2f..efb32bd 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from 'react'; -import { DateRangeSelector, StatCard } from '@/components/analytics'; +import { DateRangeSelector, StatCard, TimeSeriesChart, LitterTypeChart, QuantityChart } from '@/components/analytics'; import { analyticsService } from '@/services'; -import type { DateRange, AnalyticsSummary } from '@/types'; +import type { DateRange, AnalyticsSummary, TimeSeriesDataPoint, LitterTypeDistribution, QuantityAggregation } from '@/types'; /** * Analytics Dashboard Page @@ -16,12 +16,17 @@ export function AnalyticsPage() { }); const [summary, setSummary] = useState(null); + const [timeSeriesData, setTimeSeriesData] = useState([]); + const [litterTypeData, setLitterTypeData] = useState([]); + const [quantityData, setQuantityData] = useState([]); const [loading, setLoading] = useState(true); + const [chartsLoading, setChartsLoading] = useState(true); const [error, setError] = useState(null); const [exporting, setExporting] = useState(false); useEffect(() => { loadSummary(); + loadChartData(); }, [dateRange]); const loadSummary = async () => { @@ -48,6 +53,68 @@ export function AnalyticsPage() { } }; + const loadChartData = async () => { + try { + setChartsLoading(true); + const dateRangeParam = { + start: dateRange.start.toISOString(), + end: dateRange.end.toISOString(), + }; + + // Load all chart data in parallel + const [timeSeries, litterTypes, quantities] = await Promise.all([ + analyticsService.getTimeSeries(dateRangeParam), + analyticsService.getLitterTypeDistribution(dateRangeParam), + analyticsService.getQuantityAggregation(dateRangeParam), + ]); + + setTimeSeriesData(timeSeries); + setLitterTypeData(litterTypes); + setQuantityData(quantities); + } catch (err) { + // If API is not available yet, show mock data for development + console.warn('Chart data API not available, using mock data:', err); + + // Generate mock time series data + const mockTimeSeries: TimeSeriesDataPoint[] = []; + const daysDiff = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); + const dataPoints = Math.min(daysDiff, 30); // Max 30 data points + + for (let i = 0; i < dataPoints; i++) { + const date = new Date(dateRange.start); + date.setDate(date.getDate() + Math.floor(i * daysDiff / dataPoints)); + mockTimeSeries.push({ + date: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + reports: Math.floor(Math.random() * 20) + 5, + events: Math.floor(Math.random() * 5), + }); + } + setTimeSeriesData(mockTimeSeries); + + // Generate mock litter type distribution + const mockLitterTypes: LitterTypeDistribution[] = [ + { type: 'plastic', count: 45, percentage: 45 }, + { type: 'metal', count: 15, percentage: 15 }, + { type: 'glass', count: 12, percentage: 12 }, + { type: 'organic', count: 10, percentage: 10 }, + { type: 'hazardous', count: 8, percentage: 8 }, + { type: 'other', count: 10, percentage: 10 }, + ]; + setLitterTypeData(mockLitterTypes); + + // Generate mock quantity aggregation + const mockQuantities: QuantityAggregation[] = [ + { quantity: 'minimal', count: 30, percentage: 30 }, + { quantity: 'moderate', count: 40, percentage: 40 }, + { quantity: 'significant', count: 20, percentage: 20 }, + { quantity: 'severe', count: 10, percentage: 10 }, + ]; + setQuantityData(mockQuantities); + } finally { + setChartsLoading(false); + } + }; + const handleExportCSV = async () => { try { setExporting(true); @@ -210,17 +277,17 @@ export function AnalyticsPage() {
)} - {/* Placeholder for future charts (tasks 11-12) */} - {!loading && summary && ( -
-

- Data Visualizations -

-

- Charts and visualizations will be implemented in tasks 11-12 -

+ {/* Data Visualizations */} +
+ {/* Time Series Chart */} + + + {/* Litter Type and Quantity Charts - Side by Side on Desktop */} +
+ +
- )} +
); } diff --git a/src/services/analyticsService.ts b/src/services/analyticsService.ts index f6f1c83..c230f40 100644 --- a/src/services/analyticsService.ts +++ b/src/services/analyticsService.ts @@ -1,6 +1,6 @@ // Analytics service for API interactions import { apiClient } from './api'; -import type { AnalyticsSummary } from '@/types'; +import type { AnalyticsSummary, TimeSeriesDataPoint, LitterTypeDistribution, QuantityAggregation } from '@/types'; class AnalyticsService { /** @@ -18,6 +18,51 @@ class AnalyticsService { return apiClient.get(`/analytics/summary${queryString ? `?${queryString}` : ''}`); } + /** + * Get time series data for report trends + */ + async getTimeSeries(dateRange?: { start: string; end: string }): Promise { + const params = new URLSearchParams(); + + if (dateRange) { + params.append('startDate', dateRange.start); + params.append('endDate', dateRange.end); + } + + const queryString = params.toString(); + return apiClient.get(`/analytics/trends${queryString ? `?${queryString}` : ''}`); + } + + /** + * Get litter type distribution data + */ + async getLitterTypeDistribution(dateRange?: { start: string; end: string }): Promise { + const params = new URLSearchParams(); + + if (dateRange) { + params.append('startDate', dateRange.start); + params.append('endDate', dateRange.end); + } + + const queryString = params.toString(); + return apiClient.get(`/analytics/litter-types${queryString ? `?${queryString}` : ''}`); + } + + /** + * Get quantity estimation aggregation data + */ + async getQuantityAggregation(dateRange?: { start: string; end: string }): Promise { + const params = new URLSearchParams(); + + if (dateRange) { + params.append('startDate', dateRange.start); + params.append('endDate', dateRange.end); + } + + const queryString = params.toString(); + return apiClient.get(`/analytics/quantities${queryString ? `?${queryString}` : ''}`); + } + /** * Export analytics data to CSV */ diff --git a/src/types/index.ts b/src/types/index.ts index d688546..7cca628 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -137,3 +137,21 @@ export interface DateRange { start: Date; end: Date; } + +export interface TimeSeriesDataPoint { + date: string; + reports: number; + events?: number; +} + +export interface LitterTypeDistribution { + type: LitterType; + count: number; + percentage: number; +} + +export interface QuantityAggregation { + quantity: QuantityLevel; + count: number; + percentage: number; +}