diff --git a/README.md b/README.md index 3bc6a36..950814f 100644 --- a/README.md +++ b/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 \ No newline at end of file +- All contributors and maintainers diff --git a/src/components/species/tabs/TradeDataTab.tsx b/src/components/species/tabs/TradeDataTab.tsx new file mode 100644 index 0000000..d8a4c00 --- /dev/null +++ b/src/components/species/tabs/TradeDataTab.tsx @@ -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 ( + + + CITES Trade Records + International trade data reported to CITES + + + {tradeLoading ? ( +
+ +
+ ) : tradeError ? ( +
+

Error loading trade records. Please try again later.

+

{tradeError.message || 'Unknown error'}

+
+ ) : tradeRecords && tradeRecords.length > 0 ? ( +
+ {/* Trade Filters */} +
+
+ +

Filter Trade Records

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* Reset Filters Button */} + {(startYearFilter !== "all" || endYearFilter !== "all" || termFilter !== "all") && ( +
+ +
+ )} +
+ + {/* Trade summary section */} +
+

Trade Summary

+
+
+

Records

+

{filteredRecords.length}

+ {filteredRecords.length !== tradeRecords.length && ( +

+ of {tradeRecords.length} total +

+ )} +
+
+

Year Range

+

+ {filteredRecords.length > 0 + ? `${startYearFilter === "all" ? yearRange.min : startYearFilter} - ${endYearFilter === "all" ? yearRange.max : endYearFilter}` + : 'N/A' + } +

+
+
+

Total Quantity

+

+ {filteredRecords + .reduce((sum: number, record: CitesTradeRecord) => sum + (Number(record.quantity) || 0), 0) + .toLocaleString()} +

+
+
+
+ + {/* Visualizations Section */} + {visualizationData && ( + + )} + + {/* Trade records table */} +
+

Trade Records

+ {filteredRecords.length === 0 ? ( +
+

No records match the selected filters.

+ +
+ ) : ( + <> +
+ + + + + + + + + + + + + + + + {filteredRecords.slice(0, 20).map((record: CitesTradeRecord) => ( + + + + + + + + + + + + ))} + +
YearAppendixTermQuantityUnitImporterExporterPurposeSource
{record.year} + + {record.appendix} + + {record.term}{record.quantity || 'N/A'}{record.unit || '-'}{record.importer || '-'}{record.exporter || '-'}{record.purpose || '-'}{record.source || '-'}
+
+ {filteredRecords.length > 20 && ( +
+ Showing 20 of {filteredRecords.length} records. Use the filters to narrow down results. +
+ )} + + )} +
+ + {/* Trade data info section */} +
+

About CITES Trade Data

+

+ 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. +

+
+
+ ) : ( +
+

No trade records available for this species.

+ + {/* Add info about what trade records are */} +
+

About CITES Trade Records

+

+ 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. +

+

+ If you believe this species should have trade records, please check the following: +

+
    +
  • Verify the species is listed in a CITES Appendix
  • +
  • Check if the scientific name has been recorded differently
  • +
  • Trade may be recorded at a higher taxonomic level (family or genus)
  • +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/species/visualizations/TradeCharts.tsx b/src/components/species/visualizations/TradeCharts.tsx new file mode 100644 index 0000000..db3fd26 --- /dev/null +++ b/src/components/species/visualizations/TradeCharts.tsx @@ -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; + SOURCE_DESCRIPTIONS: Record; +}; + +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 ( + cx ? 'start' : 'end'} + dominantBaseline="central" + fontSize={12} + fontWeight="500" + > + {`${payload.term} (${(percent * 100).toFixed(0)}%)`} + + ); + }; + + return ( +
+

Trade Visualizations

+ + {/* Records Over Time */} + + +
+ + Records Over Time +
+ Number of trade records by year +
+ +
+ + + + + + + + + + +
+
+
+ + {/* Top Importers and Exporters */} +
+ {/* Top Importers */} + + +
+ + Top Importers +
+ Countries importing the most specimens +
+ +
+ + + + + + + + + +
+
+
+ + {/* Top Exporters */} + + +
+ + Top Exporters +
+ Countries exporting the most specimens +
+ +
+ + + + + + + + + +
+
+
+
+ + {/* Terms Traded */} + + +
+ + Terms Traded +
+ Distribution of specimen types in trade +
+ +
+ + + + {processedTermsData.map((_, index) => ( + + ))} + + [`${value} records`, props.payload.term]} /> + + +
+
+
+ + {/* Trade Purposes and Sources */} +
+ {/* Trade Purposes */} + + +
+ + Trade Purposes +
+ Reasons for trade +
+ +
+ + + + + `${value} - ${PURPOSE_DESCRIPTIONS[value] || 'Unknown'}`} + width={150} + /> + [ + `${value} records`, + `${props.payload.purpose} - ${props.payload.description}` + ]} /> + + + +
+
+
+ + {/* Trade Sources */} + + +
+ + Trade Sources +
+ Origin of traded specimens +
+ +
+ + + + + `${value} - ${SOURCE_DESCRIPTIONS[value] || 'Unknown'}`} + width={150} + /> + [ + `${value} records`, + `${props.payload.source} - ${props.payload.description}` + ]} /> + + + +
+
+
+
+ + {/* Quantity of Top Terms Over Time */} + + +
+ + Quantity of Top Terms Over Time +
+ Trends in quantities of the most traded terms +
+ +
+ + + + + + + + {visualizationData.topTerms.map((term, index) => ( + + ))} + + +
+
+
+
+ ); +} diff --git a/src/hooks/useTradeDataVisualization.ts b/src/hooks/useTradeDataVisualization.ts new file mode 100644 index 0000000..789000c --- /dev/null +++ b/src/hooks/useTradeDataVisualization.ts @@ -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 = { + '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 = { + '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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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 = {}; + 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> = {}; + + 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 + }; +} diff --git a/src/hooks/useTradeRecordFilters.ts b/src/hooks/useTradeRecordFilters.ts new file mode 100644 index 0000000..a38feff --- /dev/null +++ b/src/hooks/useTradeRecordFilters.ts @@ -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("all"); + const [endYearFilter, setEndYearFilter] = useState("all"); + const [termFilter, setTermFilter] = useState("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 + }; +}