Improve trade data filtering and visualization: Add year range filter, remove appendix filter, implement responsive visualizations, and optimize pie chart with smart category consolidation
This commit is contained in:
56
README.md
56
README.md
@ -25,11 +25,14 @@ A comprehensive web application for tracking and analyzing Arctic species data,
|
||||
- **CITES Information**
|
||||
- Complete CITES listing history
|
||||
- Current CITES status
|
||||
- Detailed trade records with filtering
|
||||
- Detailed trade records with advanced filtering:
|
||||
- Year range selection for temporal analysis
|
||||
- Term filtering to focus on specific specimen types
|
||||
- Historical trade data analysis
|
||||
- Arctic-specific trade patterns
|
||||
- Interactive trade visualizations
|
||||
- Interactive trade visualizations with smart consolidation of small categories
|
||||
- Filterable trade records table
|
||||
- Real-time visualization updates based on applied filters
|
||||
|
||||
- **IUCN Assessments**
|
||||
- Latest IUCN Red List status
|
||||
@ -46,11 +49,11 @@ A comprehensive web application for tracking and analyzing Arctic species data,
|
||||
- Arctic conservation milestones
|
||||
|
||||
- **Data Visualization**
|
||||
- Trade records over time
|
||||
- Trade records over time with year range filtering
|
||||
- Top importers and exporters
|
||||
- Distribution of traded terms
|
||||
- Distribution of traded terms with smart grouping of small categories
|
||||
- Trade purposes and sources
|
||||
- Interactive charts and graphs
|
||||
- Interactive charts and graphs that update with filters
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@ -116,6 +119,47 @@ The application uses the following main tables:
|
||||
- **IUCN Data**: IUCN Red List (https://www.iucnredlist.org/)
|
||||
- **Species Information**: Arctic Species Database
|
||||
|
||||
## Recent Improvements
|
||||
|
||||
### Trade Data Visualization
|
||||
- **Smart Category Consolidation**: Small categories in pie charts (less than 2% of total) are automatically grouped into an "Other" category for improved readability
|
||||
- **Filter-Aware Visualizations**: All charts and visualizations now update based on the current filter selections
|
||||
- **Year Range Filtering**: Replaced single year filter with start and end year selection for more flexible temporal analysis
|
||||
- **Visual Hierarchy**: Improved label spacing and formatting in visualizations for better readability
|
||||
|
||||
### Trade Data Filtering System
|
||||
- **Simplified Filter Interface**: Streamlined the filtering interface for better usability
|
||||
- **Year Range Selection**: Added ability to filter trade data by a specific range of years
|
||||
- **Real-time Updates**: Both the data table and visualizations update instantly as filters are applied
|
||||
- **Filter Indicators**: Clear visual indicators when filters are active with an easy reset option
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### CRUD Operations Improvements
|
||||
- **Enhanced IUCN Assessment Management**: Add full CRUD operations for IUCN assessments, allowing users to add, edit, and delete assessment records
|
||||
- **Subpopulation Management**: Implement CRUD operations for managing Arctic subpopulations and their specific conservation statuses
|
||||
- **Common Names Management**: Add interface for managing multiple common names across different languages and regions
|
||||
- **Bulk Operations**: Support for batch creation, updating, and deletion of records
|
||||
- **User Permissions**: Role-based access control for different CRUD operations
|
||||
|
||||
### Trade Data Improvements
|
||||
- **Advanced Filtering**: Further enhance trade data filtering with:
|
||||
- Multi-select filters
|
||||
- Combined filters with AND/OR logic
|
||||
- Filter presets and saved filters
|
||||
- **Quantity-Based Filtering**: Filter by trade volume thresholds
|
||||
- **Geographic Filtering**: Filter by specific regions, countries, or trade routes
|
||||
- **Export Filtered Data**: Allow exporting filtered trade data in various formats (CSV, Excel, JSON)
|
||||
- **Custom Visualizations**: User-configurable charts based on selected filters
|
||||
|
||||
### Timeline Enhancements
|
||||
- **Conservation Status Flags**: Add visual indicators/flags on the timeline when conservation status changes occur
|
||||
- **Trade Impact Analysis**: Correlation between conservation status changes and trade patterns
|
||||
- **Cause-Effect Visualization**: Visual connections between conservation events and subsequent trade activity
|
||||
- **Predictive Indicators**: Highlight potential conservation concerns based on trade pattern changes
|
||||
- **Custom Event Categories**: Allow users to create and filter by custom event types
|
||||
- **Comparative Timelines**: View multiple species timelines side-by-side for comparison
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
@ -134,4 +178,4 @@ This project is licensed under the MIT License - see the LICENSE file for detail
|
||||
- IUCN Red List for assessment data
|
||||
- iNaturalist for species images
|
||||
- Arctic Council for regional guidance
|
||||
- All contributors and maintainers
|
||||
- All contributors and maintainers
|
||||
|
284
src/components/species/tabs/TradeDataTab.tsx
Normal file
284
src/components/species/tabs/TradeDataTab.tsx
Normal file
@ -0,0 +1,284 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Filter, Loader2 } from "lucide-react";
|
||||
import { SpeciesDetails, CitesTradeRecord } from "@/lib/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getCitesTradeRecords } from "@/lib/api";
|
||||
import { useTradeDataVisualization } from "@/hooks/useTradeDataVisualization";
|
||||
import { useTradeRecordFilters } from "@/hooks/useTradeRecordFilters";
|
||||
import { TradeCharts } from "../visualizations/TradeCharts";
|
||||
|
||||
type TradeDataTabProps = {
|
||||
species: SpeciesDetails;
|
||||
};
|
||||
|
||||
export function TradeDataTab({ species }: TradeDataTabProps) {
|
||||
// Fetch trade records
|
||||
const { data: tradeRecords, isLoading: tradeLoading, error: tradeError } = useQuery({
|
||||
queryKey: ["tradeRecords", species.id],
|
||||
queryFn: () => getCitesTradeRecords(species.id),
|
||||
});
|
||||
|
||||
// Use hooks for filtering
|
||||
const {
|
||||
startYearFilter,
|
||||
setStartYearFilter,
|
||||
endYearFilter,
|
||||
setEndYearFilter,
|
||||
termFilter,
|
||||
setTermFilter,
|
||||
filteredRecords,
|
||||
resetFilters,
|
||||
yearRange
|
||||
} = useTradeRecordFilters(tradeRecords);
|
||||
|
||||
// Use hook for visualization with filtered records
|
||||
const {
|
||||
visualizationData,
|
||||
years,
|
||||
terms,
|
||||
PURPOSE_DESCRIPTIONS,
|
||||
SOURCE_DESCRIPTIONS
|
||||
} = useTradeDataVisualization(tradeRecords, filteredRecords);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CITES Trade Records</CardTitle>
|
||||
<CardDescription>International trade data reported to CITES</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{tradeLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : tradeError ? (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<p className="text-red-800">Error loading trade records. Please try again later.</p>
|
||||
<p className="mt-2 text-xs text-red-700">{tradeError.message || 'Unknown error'}</p>
|
||||
</div>
|
||||
) : tradeRecords && tradeRecords.length > 0 ? (
|
||||
<div className="space-y-6">
|
||||
{/* Trade Filters */}
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="mb-2 flex items-center">
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
<h3 className="text-sm font-medium">Filter Trade Records</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="start-year-filter">Start Year</Label>
|
||||
<Select
|
||||
value={startYearFilter}
|
||||
onValueChange={setStartYearFilter}
|
||||
>
|
||||
<SelectTrigger id="start-year-filter">
|
||||
<SelectValue placeholder="From Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Years</SelectItem>
|
||||
{years.map(year => (
|
||||
<SelectItem key={`start-${year}`} value={String(year)}>
|
||||
{String(year)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="end-year-filter">End Year</Label>
|
||||
<Select
|
||||
value={endYearFilter}
|
||||
onValueChange={setEndYearFilter}
|
||||
>
|
||||
<SelectTrigger id="end-year-filter">
|
||||
<SelectValue placeholder="To Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Years</SelectItem>
|
||||
{years.map(year => (
|
||||
<SelectItem key={`end-${year}`} value={String(year)}>
|
||||
{String(year)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="term-filter">Term</Label>
|
||||
<Select
|
||||
value={termFilter}
|
||||
onValueChange={setTermFilter}
|
||||
>
|
||||
<SelectTrigger id="term-filter">
|
||||
<SelectValue placeholder="All Terms" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Terms</SelectItem>
|
||||
{terms.map(term => (
|
||||
<SelectItem key={String(term)} value={String(term)}>
|
||||
{String(term)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Filters Button */}
|
||||
{(startYearFilter !== "all" || endYearFilter !== "all" || termFilter !== "all") && (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
Reset Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trade summary section */}
|
||||
<div className="rounded-md bg-muted p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">Trade Summary</h3>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-md bg-background p-3 shadow-sm">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Records</h4>
|
||||
<p className="text-2xl font-bold">{filteredRecords.length}</p>
|
||||
{filteredRecords.length !== tradeRecords.length && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
of {tradeRecords.length} total
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md bg-background p-3 shadow-sm">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Year Range</h4>
|
||||
<p className="text-2xl font-bold">
|
||||
{filteredRecords.length > 0
|
||||
? `${startYearFilter === "all" ? yearRange.min : startYearFilter} - ${endYearFilter === "all" ? yearRange.max : endYearFilter}`
|
||||
: 'N/A'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-background p-3 shadow-sm">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Total Quantity</h4>
|
||||
<p className="text-2xl font-bold">
|
||||
{filteredRecords
|
||||
.reduce((sum: number, record: CitesTradeRecord) => sum + (Number(record.quantity) || 0), 0)
|
||||
.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visualizations Section */}
|
||||
{visualizationData && (
|
||||
<TradeCharts
|
||||
visualizationData={visualizationData}
|
||||
PURPOSE_DESCRIPTIONS={PURPOSE_DESCRIPTIONS}
|
||||
SOURCE_DESCRIPTIONS={SOURCE_DESCRIPTIONS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Trade records table */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold">Trade Records</h3>
|
||||
{filteredRecords.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-8 text-center">
|
||||
<p className="text-muted-foreground">No records match the selected filters.</p>
|
||||
<Button
|
||||
variant="link"
|
||||
className="mt-2"
|
||||
onClick={resetFilters}
|
||||
>
|
||||
Reset Filters
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full table-auto">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-2 text-left">Year</th>
|
||||
<th className="py-2 text-left">Appendix</th>
|
||||
<th className="py-2 text-left">Term</th>
|
||||
<th className="py-2 text-left">Quantity</th>
|
||||
<th className="py-2 text-left">Unit</th>
|
||||
<th className="py-2 text-left">Importer</th>
|
||||
<th className="py-2 text-left">Exporter</th>
|
||||
<th className="py-2 text-left">Purpose</th>
|
||||
<th className="py-2 text-left">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRecords.slice(0, 20).map((record: CitesTradeRecord) => (
|
||||
<tr key={record.id} className="border-b">
|
||||
<td className="py-2">{record.year}</td>
|
||||
<td className="py-2">
|
||||
<Badge variant="outline">
|
||||
{record.appendix}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2">{record.term}</td>
|
||||
<td className="py-2 text-right">{record.quantity || 'N/A'}</td>
|
||||
<td className="py-2">{record.unit || '-'}</td>
|
||||
<td className="py-2">{record.importer || '-'}</td>
|
||||
<td className="py-2">{record.exporter || '-'}</td>
|
||||
<td className="py-2">{record.purpose || '-'}</td>
|
||||
<td className="py-2">{record.source || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{filteredRecords.length > 20 && (
|
||||
<div className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Showing 20 of {filteredRecords.length} records. Use the filters to narrow down results.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trade data info section */}
|
||||
<div className="rounded-md bg-blue-50 p-4 text-sm">
|
||||
<h3 className="mb-2 font-medium text-blue-700">About CITES Trade Data</h3>
|
||||
<p className="text-blue-700">
|
||||
The CITES Trade Database, managed by UNEP-WCMC on behalf of the CITES Secretariat, contains records of trade in wildlife
|
||||
listed in the CITES Appendices. Records include information on species, appendix, purpose, source, quantities, units,
|
||||
and countries involved in the trade.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">No trade records available for this species.</p>
|
||||
|
||||
{/* Add info about what trade records are */}
|
||||
<div className="rounded-md bg-blue-50 p-4 text-sm">
|
||||
<h3 className="mb-2 font-medium text-blue-700">About CITES Trade Records</h3>
|
||||
<p className="text-blue-700">
|
||||
CITES trade records document international trade in wildlife listed in the CITES Appendices.
|
||||
Not all species have recorded trade data, particularly if they haven't been traded internationally
|
||||
or if trade reports haven't been submitted to the CITES Trade Database.
|
||||
</p>
|
||||
<p className="mt-2 text-blue-700">
|
||||
If you believe this species should have trade records, please check the following:
|
||||
</p>
|
||||
<ul className="list-inside list-disc mt-1 text-blue-700">
|
||||
<li>Verify the species is listed in a CITES Appendix</li>
|
||||
<li>Check if the scientific name has been recorded differently</li>
|
||||
<li>Trade may be recorded at a higher taxonomic level (family or genus)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
360
src/components/species/visualizations/TradeCharts.tsx
Normal file
360
src/components/species/visualizations/TradeCharts.tsx
Normal file
@ -0,0 +1,360 @@
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { LineChart, BarChart3, PieChart } from "lucide-react";
|
||||
import {
|
||||
LineChart as RechartsLineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart as RechartsPieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import React from "react";
|
||||
|
||||
// Colors for charts
|
||||
const CHART_COLORS = [
|
||||
"#8884d8", "#83a6ed", "#8dd1e1", "#82ca9d", "#a4de6c",
|
||||
"#d0ed57", "#ffc658", "#ff8042", "#ff6361", "#bc5090"
|
||||
];
|
||||
|
||||
type TradeChartsProps = {
|
||||
visualizationData: {
|
||||
recordsByYear: { year: number; count: number }[];
|
||||
topImporters: { country: string; count: number }[];
|
||||
topExporters: { country: string; count: number }[];
|
||||
termsTraded: { term: string; count: number }[];
|
||||
tradePurposes: { purpose: string; count: number; description: string }[];
|
||||
tradeSources: { source: string; count: number; description: string }[];
|
||||
termQuantitiesByYear: { year: number; [term: string]: number }[];
|
||||
topTerms: string[];
|
||||
};
|
||||
PURPOSE_DESCRIPTIONS: Record<string, string>;
|
||||
SOURCE_DESCRIPTIONS: Record<string, string>;
|
||||
};
|
||||
|
||||
export function TradeCharts({ visualizationData, PURPOSE_DESCRIPTIONS, SOURCE_DESCRIPTIONS }: TradeChartsProps) {
|
||||
// Process terms data to combine small segments
|
||||
const processedTermsData = React.useMemo(() => {
|
||||
const threshold = 0.02; // 2% threshold
|
||||
let otherCount = 0;
|
||||
|
||||
// Sort by count in descending order
|
||||
const sortedTerms = [...visualizationData.termsTraded].sort((a, b) => b.count - a.count);
|
||||
|
||||
// Calculate total for percentage
|
||||
const total = sortedTerms.reduce((sum, item) => sum + item.count, 0);
|
||||
|
||||
// Filter and combine small segments
|
||||
const significantTerms = sortedTerms.filter(item => {
|
||||
const percentage = item.count / total;
|
||||
if (percentage < threshold) {
|
||||
otherCount += item.count;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Add "Other" category if there are small segments
|
||||
if (otherCount > 0) {
|
||||
significantTerms.push({ term: 'Other', count: otherCount });
|
||||
}
|
||||
|
||||
return significantTerms;
|
||||
}, [visualizationData.termsTraded]);
|
||||
|
||||
// Custom tooltip for pie chart
|
||||
const renderCustomizedLabel = ({ cx, cy, midAngle, outerRadius, percent, payload }: any) => {
|
||||
const RADIAN = Math.PI / 180;
|
||||
const radius = outerRadius * 1.15; // Increased radius for better spacing
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN);
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN);
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="#000000"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight="500"
|
||||
>
|
||||
{`${payload.term} (${(percent * 100).toFixed(0)}%)`}
|
||||
</text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h3 className="text-lg font-semibold">Trade Visualizations</h3>
|
||||
|
||||
{/* Records Over Time */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<LineChart className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Records Over Time</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Number of trade records by year</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsLineChart
|
||||
data={visualizationData.recordsByYear}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 25 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="year"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
name="Records"
|
||||
stroke="#8884d8"
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Importers and Exporters */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Top Importers */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Top Importers</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Countries importing the most specimens</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={visualizationData.topImporters}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 60 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="country"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" name="Records" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Top Exporters */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Top Exporters</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Countries exporting the most specimens</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={visualizationData.topExporters}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 60 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="country"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="count" name="Records" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Terms Traded */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<PieChart className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Terms Traded</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Distribution of specimen types in trade</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={processedTermsData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={true}
|
||||
label={renderCustomizedLabel}
|
||||
outerRadius={130}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey="term"
|
||||
>
|
||||
{processedTermsData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={CHART_COLORS[index % CHART_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value, _, props) => [`${value} records`, props.payload.term]} />
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trade Purposes and Sources */}
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Trade Purposes */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Trade Purposes</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Reasons for trade</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={visualizationData.tradePurposes}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 60 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="purpose"
|
||||
type="category"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value) => `${value} - ${PURPOSE_DESCRIPTIONS[value] || 'Unknown'}`}
|
||||
width={150}
|
||||
/>
|
||||
<Tooltip formatter={(value, _, props) => [
|
||||
`${value} records`,
|
||||
`${props.payload.purpose} - ${props.payload.description}`
|
||||
]} />
|
||||
<Bar dataKey="count" name="Records" fill="#8884d8" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Trade Sources */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<BarChart3 className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Trade Sources</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Origin of traded specimens</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={visualizationData.tradeSources}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 60 }}
|
||||
layout="vertical"
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="source"
|
||||
type="category"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value) => `${value} - ${SOURCE_DESCRIPTIONS[value] || 'Unknown'}`}
|
||||
width={150}
|
||||
/>
|
||||
<Tooltip formatter={(value, _, props) => [
|
||||
`${value} records`,
|
||||
`${props.payload.source} - ${props.payload.description}`
|
||||
]} />
|
||||
<Bar dataKey="count" name="Records" fill="#82ca9d" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quantity of Top Terms Over Time */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center">
|
||||
<LineChart className="mr-2 h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Quantity of Top Terms Over Time</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Trends in quantities of the most traded terms</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsLineChart
|
||||
data={visualizationData.termQuantitiesByYear}
|
||||
margin={{ top: 5, right: 30, left: 20, bottom: 25 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="year"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{visualizationData.topTerms.map((term, index) => (
|
||||
<Line
|
||||
key={term}
|
||||
type="monotone"
|
||||
dataKey={term}
|
||||
name={term}
|
||||
stroke={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
activeDot={{ r: 8 }}
|
||||
/>
|
||||
))}
|
||||
</RechartsLineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
228
src/hooks/useTradeDataVisualization.ts
Normal file
228
src/hooks/useTradeDataVisualization.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { useMemo } from "react";
|
||||
import { CitesTradeRecord } from "@/lib/api";
|
||||
|
||||
// Helper types for visualization data
|
||||
type YearlyData = {
|
||||
year: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type CountryData = {
|
||||
country: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type TermData = {
|
||||
term: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
type PurposeData = {
|
||||
purpose: string;
|
||||
count: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type SourceData = {
|
||||
source: string;
|
||||
count: number;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type TermYearData = {
|
||||
year: number;
|
||||
[term: string]: number;
|
||||
};
|
||||
|
||||
type VisualizationData = {
|
||||
recordsByYear: YearlyData[];
|
||||
topImporters: CountryData[];
|
||||
topExporters: CountryData[];
|
||||
termsTraded: TermData[];
|
||||
tradePurposes: PurposeData[];
|
||||
tradeSources: SourceData[];
|
||||
termQuantitiesByYear: TermYearData[];
|
||||
topTerms: string[];
|
||||
} | null;
|
||||
|
||||
// Purpose code descriptions
|
||||
const PURPOSE_DESCRIPTIONS: Record<string, string> = {
|
||||
'T': 'Commercial',
|
||||
'Z': 'Zoo',
|
||||
'G': 'Botanical garden',
|
||||
'Q': 'Circus or travelling exhibition',
|
||||
'S': 'Scientific',
|
||||
'H': 'Hunting trophy',
|
||||
'P': 'Personal',
|
||||
'M': 'Medical',
|
||||
'E': 'Educational',
|
||||
'N': 'Reintroduction',
|
||||
'B': 'Breeding',
|
||||
'L': 'Law enforcement'
|
||||
};
|
||||
|
||||
// Source code descriptions
|
||||
const SOURCE_DESCRIPTIONS: Record<string, string> = {
|
||||
'W': 'Wild',
|
||||
'R': 'Ranched',
|
||||
'D': 'Captive-bred (App. I)',
|
||||
'A': 'Artificially propagated (plants)',
|
||||
'C': 'Captive-bred (App. II/III)',
|
||||
'F': 'Born in captivity (F1)',
|
||||
'U': 'Unknown',
|
||||
'I': 'Confiscated/seized',
|
||||
'O': 'Pre-Convention'
|
||||
};
|
||||
|
||||
export function useTradeDataVisualization(
|
||||
tradeRecords: CitesTradeRecord[] | undefined,
|
||||
filteredRecords: CitesTradeRecord[] | undefined = undefined
|
||||
) {
|
||||
// Use filtered records for visualization if provided, otherwise use all records
|
||||
const recordsToVisualize = filteredRecords || tradeRecords;
|
||||
|
||||
// Compute visualization data
|
||||
const visualizationData = useMemo((): VisualizationData => {
|
||||
if (!recordsToVisualize || recordsToVisualize.length === 0) return null;
|
||||
|
||||
// Records by year
|
||||
const yearCounts: Record<number, number> = {};
|
||||
recordsToVisualize.forEach(record => {
|
||||
yearCounts[record.year] = (yearCounts[record.year] || 0) + 1;
|
||||
});
|
||||
const recordsByYear: YearlyData[] = Object.entries(yearCounts)
|
||||
.map(([year, count]) => ({ year: parseInt(year), count }))
|
||||
.sort((a, b) => a.year - b.year);
|
||||
|
||||
// Top importers
|
||||
const importerCounts: Record<string, number> = {};
|
||||
recordsToVisualize.forEach(record => {
|
||||
if (record.importer) {
|
||||
importerCounts[record.importer] = (importerCounts[record.importer] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const topImporters: CountryData[] = Object.entries(importerCounts)
|
||||
.map(([country, count]) => ({ country, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
// Top exporters
|
||||
const exporterCounts: Record<string, number> = {};
|
||||
recordsToVisualize.forEach(record => {
|
||||
if (record.exporter) {
|
||||
exporterCounts[record.exporter] = (exporterCounts[record.exporter] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const topExporters: CountryData[] = Object.entries(exporterCounts)
|
||||
.map(([country, count]) => ({ country, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
// Terms traded
|
||||
const termCounts: Record<string, number> = {};
|
||||
recordsToVisualize.forEach(record => {
|
||||
termCounts[record.term] = (termCounts[record.term] || 0) + 1;
|
||||
});
|
||||
const termsTraded: TermData[] = Object.entries(termCounts)
|
||||
.map(([term, count]) => ({ term, count }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Trade purposes
|
||||
const purposeCounts: Record<string, number> = {};
|
||||
recordsToVisualize.forEach(record => {
|
||||
if (record.purpose) {
|
||||
purposeCounts[record.purpose] = (purposeCounts[record.purpose] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const tradePurposes: PurposeData[] = Object.entries(purposeCounts)
|
||||
.map(([purpose, count]) => ({
|
||||
purpose,
|
||||
count,
|
||||
description: PURPOSE_DESCRIPTIONS[purpose] || 'Unknown'
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Trade sources
|
||||
const sourceCounts: Record<string, number> = {};
|
||||
recordsToVisualize.forEach(record => {
|
||||
if (record.source) {
|
||||
sourceCounts[record.source] = (sourceCounts[record.source] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const tradeSources: SourceData[] = Object.entries(sourceCounts)
|
||||
.map(([source, count]) => ({
|
||||
source,
|
||||
count,
|
||||
description: SOURCE_DESCRIPTIONS[source] || 'Unknown'
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// Top terms by year
|
||||
const topTerms = termsTraded.slice(0, 5).map(t => t.term);
|
||||
const termsByYear: Record<number, Record<string, number>> = {};
|
||||
|
||||
recordsToVisualize.forEach(record => {
|
||||
if (!termsByYear[record.year]) {
|
||||
termsByYear[record.year] = {};
|
||||
}
|
||||
if (topTerms.includes(record.term)) {
|
||||
termsByYear[record.year][record.term] = (termsByYear[record.year][record.term] || 0) + (record.quantity || 1);
|
||||
}
|
||||
});
|
||||
|
||||
const termQuantitiesByYear: TermYearData[] = Object.entries(termsByYear)
|
||||
.map(([year, terms]) => {
|
||||
const yearData: TermYearData = { year: parseInt(year) };
|
||||
topTerms.forEach(term => {
|
||||
yearData[term] = terms[term] || 0;
|
||||
});
|
||||
return yearData;
|
||||
})
|
||||
.sort((a, b) => a.year - b.year);
|
||||
|
||||
return {
|
||||
recordsByYear,
|
||||
topImporters,
|
||||
topExporters,
|
||||
termsTraded,
|
||||
tradePurposes,
|
||||
tradeSources,
|
||||
termQuantitiesByYear,
|
||||
topTerms
|
||||
};
|
||||
}, [recordsToVisualize]);
|
||||
|
||||
// Get unique years and terms (from all records, not just filtered ones)
|
||||
const years = useMemo(() => {
|
||||
if (!tradeRecords) return [];
|
||||
const uniqueYears = [...new Set(tradeRecords.map((r: CitesTradeRecord) => r.year))]
|
||||
.filter(year => year !== null && year !== undefined)
|
||||
.sort((a, b) => Number(a) - Number(b)); // Sort ascending for year ranges
|
||||
return uniqueYears;
|
||||
}, [tradeRecords]);
|
||||
|
||||
const terms = useMemo(() => {
|
||||
if (!tradeRecords) return [];
|
||||
const uniqueTerms = [...new Set(tradeRecords.map((r: CitesTradeRecord) => r.term))]
|
||||
.filter(term => term !== null && term !== undefined && term !== '')
|
||||
.sort();
|
||||
return uniqueTerms;
|
||||
}, [tradeRecords]);
|
||||
|
||||
const appendices = useMemo(() => {
|
||||
if (!tradeRecords) return [];
|
||||
const uniqueAppendices = [...new Set(tradeRecords.map((r: CitesTradeRecord) => r.appendix))]
|
||||
.filter(appendix => appendix !== null && appendix !== undefined && appendix !== '')
|
||||
.sort();
|
||||
return uniqueAppendices;
|
||||
}, [tradeRecords]);
|
||||
|
||||
return {
|
||||
visualizationData,
|
||||
years,
|
||||
terms,
|
||||
appendices,
|
||||
PURPOSE_DESCRIPTIONS,
|
||||
SOURCE_DESCRIPTIONS
|
||||
};
|
||||
}
|
55
src/hooks/useTradeRecordFilters.ts
Normal file
55
src/hooks/useTradeRecordFilters.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { CitesTradeRecord } from "@/lib/api";
|
||||
|
||||
export function useTradeRecordFilters(tradeRecords: CitesTradeRecord[] | undefined) {
|
||||
// State for trade record filters
|
||||
const [startYearFilter, setStartYearFilter] = useState<string>("all");
|
||||
const [endYearFilter, setEndYearFilter] = useState<string>("all");
|
||||
const [termFilter, setTermFilter] = useState<string>("all");
|
||||
|
||||
// Get min/max year range
|
||||
const yearRange = useMemo(() => {
|
||||
if (!tradeRecords || tradeRecords.length === 0) return { min: 0, max: 0 };
|
||||
|
||||
const years = tradeRecords.map(r => Number(r.year)).filter(y => !isNaN(y));
|
||||
return {
|
||||
min: Math.min(...years),
|
||||
max: Math.max(...years)
|
||||
};
|
||||
}, [tradeRecords]);
|
||||
|
||||
// Compute filtered records
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (!tradeRecords) return [];
|
||||
|
||||
return tradeRecords.filter((record: CitesTradeRecord) => {
|
||||
const recordYear = Number(record.year);
|
||||
const startYearNum = startYearFilter === "all" ? yearRange.min : Number(startYearFilter);
|
||||
const endYearNum = endYearFilter === "all" ? yearRange.max : Number(endYearFilter);
|
||||
|
||||
return (
|
||||
(recordYear >= startYearNum && recordYear <= endYearNum) &&
|
||||
(termFilter === "all" || !termFilter || record.term === termFilter)
|
||||
);
|
||||
});
|
||||
}, [tradeRecords, startYearFilter, endYearFilter, termFilter, yearRange]);
|
||||
|
||||
// Reset filters function
|
||||
const resetFilters = () => {
|
||||
setStartYearFilter("all");
|
||||
setEndYearFilter("all");
|
||||
setTermFilter("all");
|
||||
};
|
||||
|
||||
return {
|
||||
startYearFilter,
|
||||
setStartYearFilter,
|
||||
endYearFilter,
|
||||
setEndYearFilter,
|
||||
termFilter,
|
||||
setTermFilter,
|
||||
filteredRecords,
|
||||
resetFilters,
|
||||
yearRange
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user