Files
MOPC-Portal/src/components/charts/diversity-metrics.tsx
Matt 03c59c188e
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
Add observer project detail page with files, evaluations & reviews
New page at /observer/projects/[projectId] showing project info,
documents grouped by round requirements, and jury evaluations with
click-through to full review details. Dashboard table rows now link
to project detail. Also cleans up redundant programName prefixes
and fixes chart edge cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 18:39:53 +01:00

259 lines
8.7 KiB
TypeScript

'use client'
import { ResponsivePie } from '@nivo/pie'
import { ResponsiveBar } from '@nivo/bar'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { nivoTheme, BRAND_COLORS } from './chart-theme'
interface DiversityData {
total: number
byCountry: { country: string; count: number; percentage: number }[]
byCategory: { category: string; count: number; percentage: number }[]
byOceanIssue: { issue: string; count: number; percentage: number }[]
byTag: { tag: string; count: number; percentage: number }[]
}
interface DiversityMetricsProps {
data: DiversityData
}
/** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
function getCountryName(code: string): string {
if (code === 'Others') return 'Others'
try {
const displayNames = new Intl.DisplayNames(['en'], { type: 'region' })
return displayNames.of(code.toUpperCase()) || code
} catch {
return code
}
}
/** Convert SCREAMING_SNAKE_CASE to Title Case */
function formatLabel(value: string): string {
if (!value) return value
return value
.replace(/_/g, ' ')
.toLowerCase()
.replace(/\b\w/g, (c) => c.toUpperCase())
}
export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
if (!data || data.total === 0) {
return (
<Card>
<CardContent className="flex items-center justify-center py-12">
<p className="text-muted-foreground">No project data available</p>
</CardContent>
</Card>
)
}
// Top countries for pie chart (max 10, others grouped)
const topCountries = (data.byCountry || []).slice(0, 10)
const otherCountries = (data.byCountry || []).slice(10)
const countryPieData = otherCountries.length > 0
? [...topCountries, {
country: 'Others',
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
percentage: otherCountries.reduce((sum, c) => sum + c.percentage, 0),
}]
: topCountries
const nivoPieData = countryPieData.map((c) => ({
id: c.country === 'Others' ? 'Others' : c.country.toUpperCase(),
label: getCountryName(c.country),
value: c.count,
}))
// Pre-format category and ocean issue data for display
const formattedCategories = (data.byCategory || []).slice(0, 10).map((c) => ({
category: formatLabel(c.category),
count: c.count,
}))
const formattedOceanIssues = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
issue: formatLabel(o.issue),
count: o.count,
}))
return (
<div className="space-y-6">
{/* Summary */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{data.total}</div>
<p className="text-sm text-muted-foreground">Total Projects</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{(data.byCountry || []).length}</div>
<p className="text-sm text-muted-foreground">Countries Represented</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{(data.byCategory || []).length}</div>
<p className="text-sm text-muted-foreground">Categories</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{(data.byTag || []).length}</div>
<p className="text-sm text-muted-foreground">Unique Tags</p>
</CardContent>
</Card>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Country Distribution */}
<Card>
<CardHeader>
<CardTitle>Geographic Distribution</CardTitle>
</CardHeader>
<CardContent>
<div style={{ height: '400px' }}>
{nivoPieData.length > 0 ? <ResponsivePie
data={nivoPieData}
theme={nivoTheme}
colors={[...BRAND_COLORS]}
innerRadius={0.4}
padAngle={0.5}
cornerRadius={3}
activeOuterRadiusOffset={8}
margin={{ top: 40, right: 80, bottom: 80, left: 80 }}
enableArcLinkLabels={true}
arcLinkLabelsSkipAngle={10}
arcLinkLabelsTextColor="#374151"
arcLinkLabelsThickness={2}
arcLinkLabelsColor={{ from: 'color' }}
enableArcLabels={true}
arcLabelsSkipAngle={10}
arcLabelsTextColor={{ from: 'color', modifiers: [['darker', 2]] }}
legends={[
{
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 56,
itemsSpacing: 0,
itemWidth: 100,
itemHeight: 18,
itemTextColor: '#374151',
itemDirection: 'left-to-right',
itemOpacity: 1,
symbolSize: 12,
symbolShape: 'circle',
},
]}
/> : (
<p className="text-muted-foreground text-center py-8">No geographic data</p>
)}
</div>
</CardContent>
</Card>
{/* Category Distribution */}
<Card>
<CardHeader>
<CardTitle>Competition Categories</CardTitle>
</CardHeader>
<CardContent>
{formattedCategories.length > 0 ? (
<div style={{ height: '400px' }}>
<ResponsiveBar
data={formattedCategories}
theme={nivoTheme}
keys={['count']}
indexBy="category"
layout="horizontal"
colors={[BRAND_COLORS[0]]}
borderRadius={4}
margin={{ top: 10, right: 30, bottom: 10, left: 120 }}
padding={0.3}
enableLabel={true}
labelTextColor="#ffffff"
enableGridX={true}
enableGridY={false}
axisBottom={null}
axisLeft={{
tickSize: 0,
tickPadding: 8,
}}
/>
</div>
) : (
<p className="text-muted-foreground text-center py-8">No category data</p>
)}
</CardContent>
</Card>
</div>
{/* Ocean Issues */}
{formattedOceanIssues.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Ocean Issues Addressed</CardTitle>
</CardHeader>
<CardContent>
<div style={{ height: '400px' }}>
<ResponsiveBar
data={formattedOceanIssues}
theme={nivoTheme}
keys={['count']}
indexBy="issue"
layout="vertical"
colors={[BRAND_COLORS[2]]}
borderRadius={4}
margin={{ top: 20, right: 30, bottom: 80, left: 40 }}
padding={0.3}
enableLabel={true}
labelTextColor="#ffffff"
enableGridX={false}
enableGridY={true}
axisBottom={{
tickSize: 0,
tickPadding: 8,
tickRotation: -35,
}}
axisLeft={{
tickSize: 0,
tickPadding: 8,
}}
/>
</div>
</CardContent>
</Card>
)}
{/* Tags Cloud */}
{(data.byTag || []).length > 0 && (
<Card>
<CardHeader>
<CardTitle>Project Tags</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{(data.byTag || []).slice(0, 30).map((tag) => (
<Badge
key={tag.tag}
variant="secondary"
className="text-sm"
style={{
fontSize: `${Math.max(0.75, Math.min(1.4, 0.75 + tag.percentage / 20))}rem`,
}}
>
{tag.tag} ({tag.count})
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}