feat: implement data visualization charts for analytics dashboard
- Add TimeSeriesChart component for report trends over time - Add LitterTypeChart component with pie chart for type distribution - Add QuantityChart component with bar chart for quantity aggregation - Update analytics service with new API methods for chart data - Add new types for chart data structures (TimeSeriesDataPoint, LitterTypeDistribution, QuantityAggregation) - Integrate charts into AnalyticsPage with responsive grid layout - Add mock data fallback for development when API is not available - Configure charts for mobile responsiveness - Implement comparison metrics display in time series - Add loading states and empty states for all charts Implements task 11: Data visualization charts Requirements: 5.2, 5.3, 5.4
This commit is contained in:
107
src/components/analytics/LitterTypeChart.tsx
Normal file
107
src/components/analytics/LitterTypeChart.tsx
Normal file
@@ -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<string, string> = {
|
||||
plastic: '#3b82f6', // blue
|
||||
metal: '#6b7280', // gray
|
||||
glass: '#10b981', // green
|
||||
organic: '#84cc16', // lime
|
||||
hazardous: '#ef4444', // red
|
||||
other: '#8b5cf6', // purple
|
||||
};
|
||||
|
||||
const LITTER_TYPE_LABELS: Record<string, string> = {
|
||||
plastic: 'Plastic',
|
||||
metal: 'Metal',
|
||||
glass: 'Glass',
|
||||
organic: 'Organic',
|
||||
hazardous: 'Hazardous',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
export function LitterTypeChart({ data, loading }: LitterTypeChartProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4" />
|
||||
<div className="h-64 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Litter Type Distribution</h3>
|
||||
<div className="h-64 flex items-center justify-center text-gray-500">
|
||||
No data available for the selected period
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Litter Type Distribution</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderCustomLabel}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.map((entry, index) => {
|
||||
const litterType = data[index].type;
|
||||
return (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={COLORS[litterType] || '#8b5cf6'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string, props: { payload: { percentage: number } }) => [
|
||||
`${value} reports (${props.payload.percentage.toFixed(1)}%)`,
|
||||
name
|
||||
]}
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
verticalAlign="bottom"
|
||||
height={36}
|
||||
wrapperStyle={{ paddingTop: '1rem' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/components/analytics/QuantityChart.tsx
Normal file
93
src/components/analytics/QuantityChart.tsx
Normal file
@@ -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<string, string> = {
|
||||
minimal: '#10b981', // green
|
||||
moderate: '#f59e0b', // amber
|
||||
significant: '#f97316', // orange
|
||||
severe: '#ef4444', // red
|
||||
};
|
||||
|
||||
const QUANTITY_LABELS: Record<string, string> = {
|
||||
minimal: 'Minimal',
|
||||
moderate: 'Moderate',
|
||||
significant: 'Significant',
|
||||
severe: 'Severe',
|
||||
};
|
||||
|
||||
export function QuantityChart({ data, loading }: QuantityChartProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4" />
|
||||
<div className="h-64 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Quantity Estimation</h3>
|
||||
<div className="h-64 flex items-center justify-center text-gray-500">
|
||||
No data available for the selected period
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Litter Quantity Distribution</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#6b7280"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6b7280"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number, name: string, props: { payload: { percentage: number } }) => [
|
||||
`${value} reports (${props.payload.percentage.toFixed(1)}%)`,
|
||||
'Count'
|
||||
]}
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[8, 8, 0, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={QUANTITY_COLORS[entry.quantity] || '#8b5cf6'}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
src/components/analytics/TimeSeriesChart.tsx
Normal file
82
src/components/analytics/TimeSeriesChart.tsx
Normal file
@@ -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 (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-6 bg-gray-200 rounded w-1/3 mb-4" />
|
||||
<div className="h-64 bg-gray-200 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Report Trends</h3>
|
||||
<div className="h-64 flex items-center justify-center text-gray-500">
|
||||
No data available for the selected period
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Report Trends Over Time</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#6b7280"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#6b7280"
|
||||
style={{ fontSize: '12px' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.75rem'
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ paddingTop: '1rem' }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="reports"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
name="Reports"
|
||||
dot={{ fill: '#3b82f6', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
{data.some(d => d.events !== undefined) && (
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="events"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
name="Events"
|
||||
dot={{ fill: '#10b981', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
export { DateRangeSelector } from './DateRangeSelector';
|
||||
export { StatCard } from './StatCard';
|
||||
export { TimeSeriesChart } from './TimeSeriesChart';
|
||||
export { LitterTypeChart } from './LitterTypeChart';
|
||||
export { QuantityChart } from './QuantityChart';
|
||||
|
||||
Reference in New Issue
Block a user