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:
Magnus Smari Smarason
2025-03-27 11:14:35 +00:00
parent bfcdd8cd5b
commit bb9e68c262
5 changed files with 977 additions and 6 deletions

View File

@ -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

View 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>
);
}

View 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>
);
}

View 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
};
}

View 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
};
}