Observer platform overhaul: Nivo charts, round-type stats, UX improvements
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s
All checks were successful
Build and Push Docker Image / build (push) Successful in 12m29s
Phase 1: Fix 6 backend data bugs in analytics.ts (roundName filtering, unscored projects, criteria scores, activeRoundCount scoping, email privacy leaks in juror consistency + workload) Phase 2-3: Migrate all 9 chart components from Recharts to Nivo (@nivo/bar, @nivo/line, @nivo/pie, @nivo/scatterplot) with shared brand theme, scoreGradient colors, and STATUS_COLORS map. Fixes scatter plot outlier coloring and pie chart label visibility bugs. Phase 4: Add round-type-aware stats (getRoundTypeStats backend + RoundTypeStatsCards component) showing appropriate metrics per round type (intake/filtering/evaluation/submission/mentoring/live/deliberation). Phase 5: UX improvements — Stage→Round terminology, clickable dashboard round links, URL-based round selection (?round=), round type indicators in selectors, accessible Toggle-based cross-round comparison, sortable project table columns (title/score/evaluations), brand score colors on dashboard bar chart with aria labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
133
src/components/charts/chart-theme.ts
Normal file
133
src/components/charts/chart-theme.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import type { PartialTheme } from '@nivo/theming'
|
||||
|
||||
// Brand colors from CLAUDE.md
|
||||
export const BRAND_DARK_BLUE = '#053d57'
|
||||
export const BRAND_RED = '#de0f1e'
|
||||
export const BRAND_TEAL = '#557f8c'
|
||||
export const BRAND_WHITE = '#fefefe'
|
||||
|
||||
// Extended palette derived from brand
|
||||
export const BRAND_COLORS = [
|
||||
'#053d57', // Dark Blue
|
||||
'#de0f1e', // Red
|
||||
'#557f8c', // Teal
|
||||
'#1e7a8a', // Deep Teal
|
||||
'#c4453a', // Coral
|
||||
'#3a6f7f', // Mid Teal
|
||||
'#8b1a24', // Dark Red
|
||||
'#2d8659', // Sea Green
|
||||
'#7c9aa6', // Light Teal
|
||||
'#a83240', // Rose
|
||||
] as const
|
||||
|
||||
// Project status colors — mapped to actual ProjectStatus enum values
|
||||
export const STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: '#557f8c', // Teal
|
||||
ELIGIBLE: '#053d57', // Dark Blue
|
||||
ASSIGNED: '#1e7a8a', // Deep Teal
|
||||
SEMIFINALIST: '#c4453a', // Coral
|
||||
FINALIST: '#2d8659', // Sea Green
|
||||
REJECTED: '#de0f1e', // Red
|
||||
DRAFT: '#9ca3af', // Gray
|
||||
WITHDRAWN: '#6b7280', // Dark Gray
|
||||
}
|
||||
|
||||
// Human-readable status labels
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
SUBMITTED: 'Submitted',
|
||||
ELIGIBLE: 'Eligible',
|
||||
ASSIGNED: 'Assigned',
|
||||
SEMIFINALIST: 'Semi-finalist',
|
||||
FINALIST: 'Finalist',
|
||||
REJECTED: 'Rejected',
|
||||
DRAFT: 'Draft',
|
||||
WITHDRAWN: 'Withdrawn',
|
||||
}
|
||||
|
||||
/**
|
||||
* Score gradient: Red (low) → Teal (mid) → Dark Blue (high)
|
||||
* for scores on a 1-10 scale
|
||||
*/
|
||||
export function scoreGradient(score: number): string {
|
||||
const t = Math.max(0, Math.min(1, (score - 1) / 9))
|
||||
if (t < 0.5) {
|
||||
// Red → Teal (0 → 0.5)
|
||||
const p = t * 2
|
||||
return lerpColor(BRAND_RED, BRAND_TEAL, p)
|
||||
}
|
||||
// Teal → Dark Blue (0.5 → 1)
|
||||
const p = (t - 0.5) * 2
|
||||
return lerpColor(BRAND_TEAL, BRAND_DARK_BLUE, p)
|
||||
}
|
||||
|
||||
function lerpColor(a: string, b: string, t: number): string {
|
||||
const ar = parseInt(a.slice(1, 3), 16)
|
||||
const ag = parseInt(a.slice(3, 5), 16)
|
||||
const ab = parseInt(a.slice(5, 7), 16)
|
||||
const br = parseInt(b.slice(1, 3), 16)
|
||||
const bg = parseInt(b.slice(3, 5), 16)
|
||||
const bb = parseInt(b.slice(5, 7), 16)
|
||||
const r = Math.round(ar + (br - ar) * t)
|
||||
const g = Math.round(ag + (bg - ag) * t)
|
||||
const bl = Math.round(ab + (bb - ab) * t)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared Nivo theme — brand fonts, clean grid, shadcn-style tooltips
|
||||
*/
|
||||
export const nivoTheme: PartialTheme = {
|
||||
background: 'transparent',
|
||||
text: {
|
||||
fontSize: 12,
|
||||
fill: '#374151',
|
||||
fontFamily: 'Montserrat, system-ui, sans-serif',
|
||||
},
|
||||
axis: {
|
||||
domain: {
|
||||
line: { stroke: '#e5e7eb', strokeWidth: 1 },
|
||||
},
|
||||
ticks: {
|
||||
line: { stroke: '#e5e7eb', strokeWidth: 1 },
|
||||
text: { fontSize: 11, fill: '#6b7280' },
|
||||
},
|
||||
legend: {
|
||||
text: { fontSize: 13, fill: '#374151', fontWeight: 600 },
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
line: { stroke: '#f3f4f6', strokeWidth: 1 },
|
||||
},
|
||||
legends: {
|
||||
text: { fontSize: 12, fill: '#374151' },
|
||||
},
|
||||
labels: {
|
||||
text: { fontSize: 12, fill: '#374151', fontWeight: 500 },
|
||||
},
|
||||
tooltip: {
|
||||
container: {
|
||||
background: '#ffffff',
|
||||
color: '#1f2937',
|
||||
fontSize: 12,
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: get color for a status value from STATUS_COLORS
|
||||
* Falls back to a neutral gray
|
||||
*/
|
||||
export function getStatusColor(status: string): string {
|
||||
return STATUS_COLORS[status] || '#9ca3af'
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: format a status value for display
|
||||
*/
|
||||
export function formatStatus(status: string): string {
|
||||
return STATUS_LABELS[status] || status.charAt(0) + status.slice(1).toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
@@ -1,16 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
||||
|
||||
interface CriteriaScoreData {
|
||||
id: string
|
||||
@@ -23,27 +15,27 @@ interface CriteriaScoresProps {
|
||||
data: CriteriaScoreData[]
|
||||
}
|
||||
|
||||
// Color scale from red to green based on score
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return '#0bd90f' // Excellent - green
|
||||
if (score >= 6) return '#82ca9d' // Good - light green
|
||||
if (score >= 4) return '#ffc658' // Average - yellow
|
||||
if (score >= 2) return '#ff7300' // Poor - orange
|
||||
return '#de0f1e' // Very poor - red
|
||||
type CriterionBarDatum = {
|
||||
criterion: string
|
||||
averageScore: number
|
||||
fullName: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
displayName:
|
||||
d.name.length > 20 ? d.name.substring(0, 20) + '...' : d.name,
|
||||
}))
|
||||
|
||||
const overallAverage =
|
||||
data.length > 0
|
||||
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
|
||||
: 0
|
||||
|
||||
const chartData: CriterionBarDatum[] = data.map((d) => ({
|
||||
criterion:
|
||||
d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
||||
averageScore: d.averageScore,
|
||||
fullName: d.name,
|
||||
count: d.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -55,50 +47,54 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedData}
|
||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="displayName"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
interval={0}
|
||||
height={60}
|
||||
/>
|
||||
<YAxis domain={[0, 10]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
<div style={{ height: '300px' }}>
|
||||
<ResponsiveBar
|
||||
data={chartData}
|
||||
keys={['averageScore']}
|
||||
indexBy="criterion"
|
||||
theme={nivoTheme}
|
||||
colors={(bar) =>
|
||||
scoreGradient(bar.data.averageScore as number)
|
||||
}
|
||||
valueScale={{ type: 'linear', max: 10 }}
|
||||
borderRadius={4}
|
||||
enableLabel={true}
|
||||
label={(d) => {
|
||||
const v = d.value
|
||||
return v != null ? Number(v).toFixed(1) : ''
|
||||
}}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#ffffff"
|
||||
axisBottom={{
|
||||
tickRotation: -45,
|
||||
}}
|
||||
axisLeft={{
|
||||
legend: 'Score',
|
||||
legendPosition: 'middle',
|
||||
legendOffset: -40,
|
||||
}}
|
||||
margin={{ top: 20, right: 20, bottom: 80, left: 50 }}
|
||||
padding={0.25}
|
||||
tooltip={({ data: rowData }) => (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e5e7eb',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number | undefined) => [
|
||||
(value ?? 0).toFixed(2),
|
||||
'Average Score',
|
||||
]}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as CriteriaScoreData
|
||||
return `${item.name} (${item.count} ratings)`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="averageScore" radius={[4, 4, 0, 0]}>
|
||||
{formattedData.map((entry, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={getScoreColor(entry.averageScore)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
>
|
||||
<strong>{rowData.fullName}</strong>
|
||||
<br />
|
||||
Average Score: {Number(rowData.averageScore).toFixed(2)}
|
||||
<br />
|
||||
Ratings: {rowData.count}
|
||||
</div>
|
||||
)}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, BRAND_COLORS } from './chart-theme'
|
||||
|
||||
interface StageComparison {
|
||||
roundId: string
|
||||
@@ -26,128 +18,152 @@ interface CrossStageComparisonProps {
|
||||
data: StageComparison[]
|
||||
}
|
||||
|
||||
const STAGE_COLORS = ['#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f']
|
||||
|
||||
export function CrossStageComparisonChart({ data }: CrossStageComparisonProps) {
|
||||
// Prepare comparison data
|
||||
const comparisonData = data.map((stage, i) => ({
|
||||
name: stage.roundName.length > 20 ? stage.roundName.slice(0, 20) + '...' : stage.roundName,
|
||||
projects: stage.projectCount,
|
||||
evaluations: stage.evaluationCount,
|
||||
completionRate: stage.completionRate,
|
||||
avgScore: stage.averageScore ? parseFloat(stage.averageScore.toFixed(2)) : 0,
|
||||
color: STAGE_COLORS[i % STAGE_COLORS.length],
|
||||
export function CrossStageComparisonChart({
|
||||
data,
|
||||
}: CrossStageComparisonProps) {
|
||||
const baseData = data.map((round) => ({
|
||||
name:
|
||||
round.roundName.length > 20
|
||||
? round.roundName.slice(0, 20) + '...'
|
||||
: round.roundName,
|
||||
projects: round.projectCount,
|
||||
evaluations: round.evaluationCount,
|
||||
completionRate: round.completionRate,
|
||||
avgScore: round.averageScore
|
||||
? parseFloat(round.averageScore.toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
|
||||
const sharedMargin = { top: 10, right: 10, bottom: 40, left: 40 }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Metrics Comparison */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Stage Metrics Comparison</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[350px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 30, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Round Metrics Comparison</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Projects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div style={{ height: '200px' }}>
|
||||
<ResponsiveBar
|
||||
data={baseData}
|
||||
keys={['projects']}
|
||||
indexBy="name"
|
||||
theme={nivoTheme}
|
||||
colors={[BRAND_COLORS[0]]}
|
||||
borderRadius={4}
|
||||
enableLabel={true}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#ffffff"
|
||||
margin={sharedMargin}
|
||||
padding={0.3}
|
||||
axisBottom={{
|
||||
tickRotation: -25,
|
||||
}}
|
||||
animate={true}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar dataKey="projects" name="Projects" fill="#053d57" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="evaluations" name="Evaluations" fill="#557f8c" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Completion & Score Comparison */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Completion Rate by Stage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis domain={[0, 100]} unit="%" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="completionRate" name="Completion %" fill="#6ad82f" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Evaluations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div style={{ height: '200px' }}>
|
||||
<ResponsiveBar
|
||||
data={baseData}
|
||||
keys={['evaluations']}
|
||||
indexBy="name"
|
||||
theme={nivoTheme}
|
||||
colors={[BRAND_COLORS[2]]}
|
||||
borderRadius={4}
|
||||
enableLabel={true}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#ffffff"
|
||||
margin={sharedMargin}
|
||||
padding={0.3}
|
||||
axisBottom={{
|
||||
tickRotation: -25,
|
||||
}}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Average Score by Stage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={comparisonData}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-25}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<YAxis domain={[0, 10]} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="avgScore" name="Avg Score" fill="#de0f1e" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Completion Rate
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div style={{ height: '200px' }}>
|
||||
<ResponsiveBar
|
||||
data={baseData}
|
||||
keys={['completionRate']}
|
||||
indexBy="name"
|
||||
theme={nivoTheme}
|
||||
colors={[BRAND_COLORS[1]]}
|
||||
valueScale={{ type: 'linear', max: 100 }}
|
||||
borderRadius={4}
|
||||
enableLabel={true}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#ffffff"
|
||||
valueFormat={(v) => `${v}%`}
|
||||
margin={sharedMargin}
|
||||
padding={0.3}
|
||||
axisBottom={{
|
||||
tickRotation: -25,
|
||||
}}
|
||||
axisLeft={{
|
||||
format: (v) => `${v}%`,
|
||||
}}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Average Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div style={{ height: '200px' }}>
|
||||
<ResponsiveBar
|
||||
data={baseData}
|
||||
keys={['avgScore']}
|
||||
indexBy="name"
|
||||
theme={nivoTheme}
|
||||
colors={[BRAND_COLORS[0]]}
|
||||
valueScale={{ type: 'linear', max: 10 }}
|
||||
borderRadius={4}
|
||||
enableLabel={true}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#ffffff"
|
||||
margin={sharedMargin}
|
||||
padding={0.3}
|
||||
axisBottom={{
|
||||
tickRotation: -25,
|
||||
}}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Legend,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
} from 'recharts'
|
||||
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
|
||||
@@ -28,12 +18,6 @@ interface DiversityMetricsProps {
|
||||
data: DiversityData
|
||||
}
|
||||
|
||||
const PIE_COLORS = [
|
||||
'#053d57', '#de0f1e', '#557f8c', '#f38a52', '#6ad82f',
|
||||
'#3be31e', '#c9c052', '#e6382f', '#ed6141', '#0bd90f',
|
||||
'#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1',
|
||||
]
|
||||
|
||||
/** Convert ISO 3166-1 alpha-2 code to full country name using Intl API */
|
||||
function getCountryName(code: string): string {
|
||||
if (code === 'Others') return 'Others'
|
||||
@@ -54,33 +38,6 @@ function formatLabel(value: string): string {
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
|
||||
/** Custom tooltip for the pie chart */
|
||||
function CountryTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { country: string; count: number; percentage: number } }> }) {
|
||||
if (!active || !payload?.length) return null
|
||||
const d = payload[0].payload
|
||||
return (
|
||||
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
|
||||
<p className="font-medium">{getCountryName(d.country)}</p>
|
||||
<p className="text-muted-foreground">{d.count} projects ({d.percentage.toFixed(1)}%)</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Custom tooltip for bar charts */
|
||||
function BarTooltip({ active, payload, labelFormatter }: { active?: boolean; payload?: Array<{ value: number }>; label?: string; labelFormatter: (val: string) => string }) {
|
||||
if (!active || !payload?.length) return null
|
||||
const entry = payload[0]
|
||||
const rawPayload = entry as unknown as { payload: Record<string, unknown> }
|
||||
const dataPoint = rawPayload.payload
|
||||
const rawLabel = (dataPoint.category || dataPoint.issue || '') as string
|
||||
return (
|
||||
<div className="rounded-md border bg-card px-3 py-2 text-sm shadow-md">
|
||||
<p className="font-medium">{labelFormatter(rawLabel)}</p>
|
||||
<p className="text-muted-foreground">{entry.value} projects</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
if (data.total === 0) {
|
||||
return (
|
||||
@@ -103,15 +60,21 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
}]
|
||||
: topCountries
|
||||
|
||||
const nivoPieData = countryPieData.map((c) => ({
|
||||
id: c.country,
|
||||
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) => ({
|
||||
...c,
|
||||
category: formatLabel(c.category),
|
||||
count: c.count,
|
||||
}))
|
||||
|
||||
const formattedOceanIssues = data.byOceanIssue.slice(0, 15).map((o) => ({
|
||||
...o,
|
||||
issue: formatLabel(o.issue),
|
||||
count: o.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -151,35 +114,42 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
<CardTitle>Geographic Distribution</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={countryPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={120}
|
||||
paddingAngle={2}
|
||||
dataKey="count"
|
||||
nameKey="country"
|
||||
label={((props: unknown) => {
|
||||
const p = props as { country: string; percentage: number }
|
||||
return `${getCountryName(p.country)} (${p.percentage.toFixed(0)}%)`
|
||||
}) as unknown as boolean}
|
||||
fontSize={13}
|
||||
>
|
||||
{countryPieData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={PIE_COLORS[index % PIE_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip content={<CountryTooltip />} />
|
||||
<Legend
|
||||
formatter={(value: string) => getCountryName(value)}
|
||||
wrapperStyle={{ fontSize: '13px' }}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ height: '400px' }}>
|
||||
<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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -191,25 +161,27 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formattedCategories.length > 0 ? (
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedCategories}
|
||||
layout="vertical"
|
||||
margin={{ top: 5, right: 30, bottom: 5, left: 120 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" tick={{ fontSize: 13 }} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category"
|
||||
width={110}
|
||||
tick={{ fontSize: 13 }}
|
||||
/>
|
||||
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
|
||||
<Bar dataKey="count" fill="#053d57" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<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>
|
||||
@@ -225,26 +197,31 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
<CardTitle>Ocean Issues Addressed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedOceanIssues}
|
||||
margin={{ top: 20, right: 30, bottom: 80, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="issue"
|
||||
angle={-35}
|
||||
textAnchor="end"
|
||||
height={100}
|
||||
tick={{ fontSize: 12 }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 13 }} />
|
||||
<Tooltip content={<BarTooltip labelFormatter={(v) => v} />} />
|
||||
<Bar dataKey="count" fill="#557f8c" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<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>
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Area,
|
||||
ComposedChart,
|
||||
Bar,
|
||||
} from 'recharts'
|
||||
import { ResponsiveLine } from '@nivo/line'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, BRAND_DARK_BLUE } from './chart-theme'
|
||||
|
||||
interface TimelineDataPoint {
|
||||
date: string
|
||||
@@ -26,7 +15,6 @@ interface EvaluationTimelineProps {
|
||||
}
|
||||
|
||||
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||
// Format date for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
|
||||
@@ -38,6 +26,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||
const totalEvaluations =
|
||||
data.length > 0 ? data[data.length - 1].cumulative : 0
|
||||
|
||||
const lineData = [
|
||||
{
|
||||
id: 'Cumulative Evaluations',
|
||||
data: formattedData.map((d) => ({
|
||||
x: d.dateFormatted,
|
||||
y: d.cumulative,
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -49,52 +47,55 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={formattedData}
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="dateFormatted"
|
||||
tick={{ fontSize: 12 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis yAxisId="left" orientation="left" stroke="#8884d8" />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="#82ca9d" />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
value ?? 0,
|
||||
(name ?? '') === 'daily' ? 'Daily' : 'Cumulative',
|
||||
]}
|
||||
labelFormatter={(label) => `Date: ${label}`}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
yAxisId="left"
|
||||
dataKey="daily"
|
||||
name="Daily Evaluations"
|
||||
fill="#8884d8"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="right"
|
||||
type="monotone"
|
||||
dataKey="cumulative"
|
||||
name="Cumulative Total"
|
||||
stroke="#82ca9d"
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ height: '300px' }}>
|
||||
<ResponsiveLine
|
||||
data={lineData}
|
||||
theme={nivoTheme}
|
||||
colors={[BRAND_DARK_BLUE]}
|
||||
enableArea={true}
|
||||
areaOpacity={0.1}
|
||||
areaBaselineValue={0}
|
||||
curve="monotoneX"
|
||||
pointSize={6}
|
||||
pointColor={BRAND_DARK_BLUE}
|
||||
pointBorderWidth={2}
|
||||
pointBorderColor="#ffffff"
|
||||
useMesh={true}
|
||||
enableSlices="x"
|
||||
sliceTooltip={({ slice }) => {
|
||||
const point = slice.points[0]
|
||||
const dataItem = formattedData.find(
|
||||
(d) => d.dateFormatted === point.data.xFormatted
|
||||
)
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<strong>{point.data.xFormatted}</strong>
|
||||
<div>Cumulative: {point.data.yFormatted}</div>
|
||||
{dataItem && <div>Daily: {dataItem.daily}</div>}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
margin={{ top: 20, right: 20, bottom: 50, left: 60 }}
|
||||
axisBottom={{
|
||||
tickRotation: -45,
|
||||
legend: '',
|
||||
legendOffset: 36,
|
||||
}}
|
||||
axisLeft={{
|
||||
legend: 'Evaluations',
|
||||
legendOffset: -50,
|
||||
legendPosition: 'middle',
|
||||
}}
|
||||
yScale={{ type: 'linear', min: 0, max: 'auto' }}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
ScatterChart,
|
||||
Scatter,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { ResponsiveScatterPlot } from '@nivo/scatterplot'
|
||||
import type {
|
||||
ScatterPlotDatum,
|
||||
ScatterPlotNodeProps,
|
||||
} from '@nivo/scatterplot'
|
||||
import { animated } from '@react-spring/web'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
@@ -21,11 +17,11 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
|
||||
|
||||
interface JurorMetric {
|
||||
userId: string
|
||||
name: string
|
||||
email: string
|
||||
evaluationCount: number
|
||||
averageScore: number
|
||||
stddev: number
|
||||
@@ -40,14 +36,73 @@ interface JurorConsistencyProps {
|
||||
}
|
||||
}
|
||||
|
||||
interface JurorDatum extends ScatterPlotDatum {
|
||||
x: number
|
||||
y: number
|
||||
name: string
|
||||
evaluations: number
|
||||
isOutlier: boolean
|
||||
}
|
||||
|
||||
function CustomNode({
|
||||
node,
|
||||
style,
|
||||
blendMode,
|
||||
isInteractive,
|
||||
onMouseEnter,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
onClick,
|
||||
}: ScatterPlotNodeProps<JurorDatum>) {
|
||||
const fillColor = node.data.isOutlier ? BRAND_RED : BRAND_DARK_BLUE
|
||||
|
||||
return (
|
||||
<animated.circle
|
||||
cx={style.x}
|
||||
cy={style.y}
|
||||
r={style.size.to((s: number) => s / 2)}
|
||||
fill={fillColor}
|
||||
fillOpacity={0.7}
|
||||
stroke={fillColor}
|
||||
strokeWidth={1}
|
||||
style={{ mixBlendMode: blendMode }}
|
||||
onMouseEnter={
|
||||
isInteractive && onMouseEnter
|
||||
? (event) => onMouseEnter(node, event)
|
||||
: undefined
|
||||
}
|
||||
onMouseMove={
|
||||
isInteractive && onMouseMove
|
||||
? (event) => onMouseMove(node, event)
|
||||
: undefined
|
||||
}
|
||||
onMouseLeave={
|
||||
isInteractive && onMouseLeave
|
||||
? (event) => onMouseLeave(node, event)
|
||||
: undefined
|
||||
}
|
||||
onClick={
|
||||
isInteractive && onClick
|
||||
? (event) => onClick(node, event)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
const scatterData = data.jurors.map((j) => ({
|
||||
name: j.name,
|
||||
avgScore: parseFloat(j.averageScore.toFixed(2)),
|
||||
stddev: parseFloat(j.stddev.toFixed(2)),
|
||||
evaluations: j.evaluationCount,
|
||||
isOutlier: j.isOutlier,
|
||||
}))
|
||||
const scatterData = [
|
||||
{
|
||||
id: 'Jurors',
|
||||
data: data.jurors.map((j) => ({
|
||||
x: parseFloat(j.averageScore.toFixed(2)),
|
||||
y: parseFloat(j.stddev.toFixed(2)),
|
||||
name: j.name,
|
||||
evaluations: j.evaluationCount,
|
||||
isOutlier: j.isOutlier,
|
||||
})),
|
||||
},
|
||||
]
|
||||
|
||||
const outlierCount = data.jurors.filter((j) => j.isOutlier).length
|
||||
|
||||
@@ -69,51 +124,63 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
type="number"
|
||||
dataKey="avgScore"
|
||||
name="Average Score"
|
||||
domain={[0, 10]}
|
||||
label={{ value: 'Average Score', position: 'insideBottom', offset: -10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="stddev"
|
||||
name="Std Deviation"
|
||||
label={{ value: 'Std Deviation', angle: -90, position: 'insideLeft' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
<div style={{ height: '400px' }}>
|
||||
<ResponsiveScatterPlot<JurorDatum>
|
||||
data={scatterData}
|
||||
theme={nivoTheme}
|
||||
colors={[BRAND_DARK_BLUE]}
|
||||
xScale={{ type: 'linear', min: 0, max: 10 }}
|
||||
yScale={{ type: 'linear', min: 0, max: 'auto' }}
|
||||
axisBottom={{
|
||||
legend: 'Average Score',
|
||||
legendPosition: 'middle',
|
||||
legendOffset: 40,
|
||||
}}
|
||||
axisLeft={{
|
||||
legend: 'Std Deviation',
|
||||
legendPosition: 'middle',
|
||||
legendOffset: -50,
|
||||
}}
|
||||
useMesh={true}
|
||||
nodeSize={(node) =>
|
||||
Math.max(8, Math.min(20, node.data.evaluations * 2))
|
||||
}
|
||||
nodeComponent={CustomNode}
|
||||
margin={{ top: 20, right: 20, bottom: 60, left: 60 }}
|
||||
tooltip={({ node }) => (
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={data.overallAverage}
|
||||
stroke="#de0f1e"
|
||||
strokeDasharray="3 3"
|
||||
label={{ value: 'Avg', fill: '#de0f1e', position: 'top' }}
|
||||
/>
|
||||
<Scatter data={scatterData} fill="#053d57">
|
||||
{scatterData.map((entry, index) => (
|
||||
<circle
|
||||
key={index}
|
||||
r={Math.max(4, entry.evaluations)}
|
||||
fill={entry.isOutlier ? '#de0f1e' : '#053d57'}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
))}
|
||||
</Scatter>
|
||||
</ScatterChart>
|
||||
</ResponsiveContainer>
|
||||
>
|
||||
<strong>{node.data.name}</strong>
|
||||
<div>Avg Score: {node.data.x}</div>
|
||||
<div>Std Dev: {node.data.y}</div>
|
||||
<div>Evaluations: {node.data.evaluations}</div>
|
||||
</div>
|
||||
)}
|
||||
markers={[
|
||||
{
|
||||
axis: 'x',
|
||||
value: data.overallAverage,
|
||||
lineStyle: {
|
||||
stroke: BRAND_RED,
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '6 4',
|
||||
},
|
||||
legend: `Avg: ${data.overallAverage.toFixed(1)}`,
|
||||
legendPosition: 'top',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
Dot size represents number of evaluations. Red dots indicate outlier jurors (2+ points from mean).
|
||||
Dot size represents number of evaluations. Red dots indicate outlier
|
||||
jurors (2+ points from mean).
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -131,22 +198,30 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
<TableHead className="text-right">Evaluations</TableHead>
|
||||
<TableHead className="text-right">Avg Score</TableHead>
|
||||
<TableHead className="text-right">Std Dev</TableHead>
|
||||
<TableHead className="text-right">Deviation from Mean</TableHead>
|
||||
<TableHead className="text-right">
|
||||
Deviation from Mean
|
||||
</TableHead>
|
||||
<TableHead className="text-center">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.jurors.map((juror) => (
|
||||
<TableRow key={juror.userId} className={juror.isOutlier ? 'bg-destructive/5' : ''}>
|
||||
<TableRow
|
||||
key={juror.userId}
|
||||
className={juror.isOutlier ? 'bg-destructive/5' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<div>
|
||||
<p className="font-medium">{juror.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{juror.email}</p>
|
||||
</div>
|
||||
<p className="font-medium">{juror.name}</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.evaluationCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.averageScore.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.stddev.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.evaluationCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.averageScore.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{juror.stddev.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">
|
||||
{juror.deviationFromOverall.toFixed(2)}
|
||||
</TableCell>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { ResponsiveBar, type ComputedDatum } from '@nivo/bar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme } from './chart-theme'
|
||||
|
||||
interface JurorWorkloadData {
|
||||
id: string
|
||||
@@ -24,18 +16,32 @@ interface JurorWorkloadProps {
|
||||
data: JurorWorkloadData[]
|
||||
}
|
||||
|
||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
// Truncate names for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
displayName: d.name.length > 15 ? d.name.substring(0, 15) + '...' : d.name,
|
||||
}))
|
||||
type WorkloadBarDatum = {
|
||||
juror: string
|
||||
completed: number
|
||||
remaining: number
|
||||
completionRate: number
|
||||
fullName: string
|
||||
}
|
||||
|
||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
const totalAssigned = data.reduce((sum, d) => sum + d.assigned, 0)
|
||||
const totalCompleted = data.reduce((sum, d) => sum + d.completed, 0)
|
||||
const overallRate =
|
||||
totalAssigned > 0 ? Math.round((totalCompleted / totalAssigned) * 100) : 0
|
||||
|
||||
const sortedData = [...data].sort(
|
||||
(a, b) => b.completionRate - a.completionRate,
|
||||
)
|
||||
|
||||
const chartData: WorkloadBarDatum[] = sortedData.map((d) => ({
|
||||
juror: d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
||||
completed: d.completed,
|
||||
remaining: d.assigned - d.completed,
|
||||
completionRate: d.completionRate,
|
||||
fullName: d.name,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -47,54 +53,65 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[400px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={formattedData}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 100 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="displayName"
|
||||
type="category"
|
||||
width={90}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
<div
|
||||
style={{ height: `${Math.max(300, data.length * 35)}px` }}
|
||||
>
|
||||
<ResponsiveBar
|
||||
data={chartData}
|
||||
keys={['completed', 'remaining']}
|
||||
indexBy="juror"
|
||||
layout="horizontal"
|
||||
theme={nivoTheme}
|
||||
colors={['#053d57', '#e5e7eb']}
|
||||
borderRadius={2}
|
||||
enableLabel={true}
|
||||
label={(d: ComputedDatum<WorkloadBarDatum>) => {
|
||||
if (d.id === 'completed') {
|
||||
return `${d.data.completionRate}%`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
labelSkipWidth={40}
|
||||
labelTextColor={(d) => {
|
||||
const datum = d as unknown as { data: ComputedDatum<WorkloadBarDatum> }
|
||||
return datum.data.id === 'completed' ? '#ffffff' : '#374151'
|
||||
}}
|
||||
margin={{ top: 10, right: 30, bottom: 30, left: 160 }}
|
||||
padding={0.25}
|
||||
groupMode="stacked"
|
||||
tooltip={({ id, value, data: rowData }) => (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e5e7eb',
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
value ?? 0,
|
||||
(name ?? '') === 'assigned' ? 'Assigned' : 'Completed',
|
||||
]}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as JurorWorkloadData
|
||||
return `${item.name} (${item.completionRate}% complete)`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="assigned"
|
||||
name="Assigned"
|
||||
fill="#8884d8"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="completed"
|
||||
name="Completed"
|
||||
fill="#82ca9d"
|
||||
radius={[0, 4, 4, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
>
|
||||
<strong>{rowData.fullName}</strong>
|
||||
<br />
|
||||
{id === 'completed' ? 'Completed' : 'Remaining'}: {value}
|
||||
<br />
|
||||
Completion: {rowData.completionRate}%
|
||||
</div>
|
||||
)}
|
||||
legends={[
|
||||
{
|
||||
dataFrom: 'keys',
|
||||
anchor: 'bottom',
|
||||
direction: 'row',
|
||||
translateY: 30,
|
||||
itemsSpacing: 20,
|
||||
itemWidth: 100,
|
||||
itemHeight: 18,
|
||||
symbolSize: 12,
|
||||
symbolShape: 'square',
|
||||
},
|
||||
]}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
||||
|
||||
interface ProjectRankingData {
|
||||
id: string
|
||||
@@ -27,93 +18,119 @@ interface ProjectRankingsProps {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
// Generate color based on score (red to green gradient)
|
||||
const getScoreColor = (score: number): string => {
|
||||
if (score >= 8) return '#0bd90f' // Excellent - green
|
||||
if (score >= 6) return '#82ca9d' // Good - light green
|
||||
if (score >= 4) return '#ffc658' // Average - yellow
|
||||
if (score >= 2) return '#ff7300' // Poor - orange
|
||||
return '#de0f1e' // Very poor - red
|
||||
type RankingBarDatum = {
|
||||
project: string
|
||||
score: number
|
||||
fullTitle: string
|
||||
teamName: string
|
||||
evaluationCount: number
|
||||
}
|
||||
|
||||
export function ProjectRankingsChart({
|
||||
data,
|
||||
limit = 20,
|
||||
}: ProjectRankingsProps) {
|
||||
const displayData = data.slice(0, limit).map((d, index) => ({
|
||||
...d,
|
||||
rank: index + 1,
|
||||
displayTitle:
|
||||
d.title.length > 25 ? d.title.substring(0, 25) + '...' : d.title,
|
||||
score: d.averageScore || 0,
|
||||
}))
|
||||
const scoredData = data.filter(
|
||||
(d): d is ProjectRankingData & { averageScore: number } =>
|
||||
d.averageScore !== null,
|
||||
)
|
||||
|
||||
const averageScore =
|
||||
data.length > 0
|
||||
? data.reduce((sum, d) => sum + (d.averageScore || 0), 0) / data.length
|
||||
scoredData.length > 0
|
||||
? scoredData.reduce((sum, d) => sum + d.averageScore, 0) /
|
||||
scoredData.length
|
||||
: 0
|
||||
|
||||
const displayData = scoredData.slice(0, limit)
|
||||
|
||||
const chartData: RankingBarDatum[] = displayData.map((d) => ({
|
||||
project:
|
||||
d.title.length > 30 ? d.title.substring(0, 30) + '...' : d.title,
|
||||
score: d.averageScore,
|
||||
fullTitle: d.title,
|
||||
teamName: d.teamName ?? '',
|
||||
evaluationCount: d.evaluationCount,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>Project Rankings</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Top {displayData.length} of {data.length} projects
|
||||
Top {displayData.length} of {scoredData.length} scored projects
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[500px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={displayData}
|
||||
layout="vertical"
|
||||
margin={{ top: 20, right: 30, bottom: 20, left: 150 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis type="number" domain={[0, 10]} />
|
||||
<YAxis
|
||||
dataKey="displayTitle"
|
||||
type="category"
|
||||
width={140}
|
||||
tick={{ fontSize: 11 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [(value ?? 0).toFixed(2), 'Average Score']}
|
||||
labelFormatter={(_, payload) => {
|
||||
if (payload && payload[0]) {
|
||||
const item = payload[0].payload as ProjectRankingData & {
|
||||
rank: number
|
||||
}
|
||||
return `#${item.rank} - ${item.title}${item.teamName ? ` (${item.teamName})` : ''}`
|
||||
}
|
||||
return ''
|
||||
}}
|
||||
/>
|
||||
<ReferenceLine
|
||||
x={averageScore}
|
||||
stroke="#666"
|
||||
strokeDasharray="5 5"
|
||||
label={{
|
||||
value: `Avg: ${averageScore.toFixed(1)}`,
|
||||
position: 'top',
|
||||
fill: '#666',
|
||||
<div
|
||||
style={{
|
||||
height: `${Math.max(400, displayData.length * 30)}px`,
|
||||
}}
|
||||
>
|
||||
<ResponsiveBar
|
||||
data={chartData}
|
||||
keys={['score']}
|
||||
indexBy="project"
|
||||
layout="horizontal"
|
||||
theme={nivoTheme}
|
||||
colors={(bar) => scoreGradient(bar.data.score as number)}
|
||||
valueScale={{ type: 'linear', max: 10 }}
|
||||
borderRadius={4}
|
||||
enableLabel={true}
|
||||
label={(d) => {
|
||||
const v = d.value
|
||||
return v != null ? Number(v).toFixed(1) : ''
|
||||
}}
|
||||
labelSkipWidth={30}
|
||||
labelTextColor="#ffffff"
|
||||
margin={{ top: 10, right: 30, bottom: 30, left: 200 }}
|
||||
padding={0.2}
|
||||
markers={[
|
||||
{
|
||||
axis: 'x',
|
||||
value: averageScore,
|
||||
lineStyle: {
|
||||
stroke: '#6b7280',
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: '6 4',
|
||||
},
|
||||
legend: `Avg: ${averageScore.toFixed(1)}`,
|
||||
legendPosition: 'top',
|
||||
textStyle: {
|
||||
fill: '#6b7280',
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
]}
|
||||
tooltip={({ data: rowData }) => (
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e5e7eb',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="score" radius={[0, 4, 4, 0]}>
|
||||
{displayData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={getScoreColor(entry.score)} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
>
|
||||
<strong>{rowData.fullTitle}</strong>
|
||||
{rowData.teamName && (
|
||||
<>
|
||||
<br />
|
||||
<span style={{ color: '#6b7280' }}>
|
||||
{rowData.teamName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<br />
|
||||
Score: {Number(rowData.score).toFixed(2)}
|
||||
<br />
|
||||
Evaluations: {rowData.evaluationCount}
|
||||
</div>
|
||||
)}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from 'recharts'
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
||||
|
||||
interface ScoreDistributionProps {
|
||||
data: { score: number; count: number }[]
|
||||
@@ -18,24 +10,16 @@ interface ScoreDistributionProps {
|
||||
totalScores: number
|
||||
}
|
||||
|
||||
const COLORS = [
|
||||
'#de0f1e', // 1 - red (poor)
|
||||
'#e6382f',
|
||||
'#ed6141',
|
||||
'#f38a52',
|
||||
'#f8b364', // 5 - yellow (average)
|
||||
'#c9c052',
|
||||
'#99cc41',
|
||||
'#6ad82f',
|
||||
'#3be31e',
|
||||
'#0bd90f', // 10 - green (excellent)
|
||||
]
|
||||
|
||||
export function ScoreDistributionChart({
|
||||
data,
|
||||
averageScore,
|
||||
totalScores,
|
||||
}: ScoreDistributionProps) {
|
||||
const chartData = data.map((d) => ({
|
||||
score: String(d.score),
|
||||
count: d.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -47,44 +31,31 @@ export function ScoreDistributionChart({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={data}
|
||||
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="score"
|
||||
label={{
|
||||
value: 'Score',
|
||||
position: 'insideBottom',
|
||||
offset: -10,
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
label={{
|
||||
value: 'Count',
|
||||
angle: -90,
|
||||
position: 'insideLeft',
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined) => [value ?? 0, 'Count']}
|
||||
labelFormatter={(label) => `Score: ${label}`}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{data.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ height: '300px' }}>
|
||||
<ResponsiveBar
|
||||
data={chartData}
|
||||
keys={['count']}
|
||||
indexBy="score"
|
||||
theme={nivoTheme}
|
||||
colors={(bar) => scoreGradient(Number(bar.indexValue))}
|
||||
borderRadius={4}
|
||||
enableLabel={true}
|
||||
labelSkipHeight={12}
|
||||
labelTextColor="#ffffff"
|
||||
axisBottom={{
|
||||
legend: 'Score',
|
||||
legendPosition: 'middle',
|
||||
legendOffset: 36,
|
||||
}}
|
||||
axisLeft={{
|
||||
legend: 'Count',
|
||||
legendPosition: 'middle',
|
||||
legendOffset: -40,
|
||||
}}
|
||||
margin={{ top: 20, right: 20, bottom: 50, left: 50 }}
|
||||
padding={0.2}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
'use client'
|
||||
import {
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
|
||||
import { ResponsivePie } from '@nivo/pie'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, getStatusColor, formatStatus } from './chart-theme'
|
||||
|
||||
interface StatusDataPoint {
|
||||
status: string
|
||||
@@ -18,66 +13,14 @@ interface StatusBreakdownProps {
|
||||
data: StatusDataPoint[]
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: '#8884d8',
|
||||
UNDER_REVIEW: '#82ca9d',
|
||||
SHORTLISTED: '#ffc658',
|
||||
SEMIFINALIST: '#ff7300',
|
||||
FINALIST: '#00C49F',
|
||||
WINNER: '#0088FE',
|
||||
ELIMINATED: '#de0f1e',
|
||||
WITHDRAWN: '#999999',
|
||||
}
|
||||
|
||||
const renderCustomLabel = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
percent,
|
||||
}: {
|
||||
cx?: number
|
||||
cy?: number
|
||||
midAngle?: number
|
||||
innerRadius?: number
|
||||
outerRadius?: number
|
||||
percent?: number
|
||||
}) => {
|
||||
if (cx === undefined || cy === undefined || midAngle === undefined ||
|
||||
innerRadius === undefined || outerRadius === undefined || percent === undefined) {
|
||||
return null
|
||||
}
|
||||
if (percent < 0.05) return null // Don't show labels for small slices
|
||||
|
||||
const RADIAN = Math.PI / 180
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * 0.5
|
||||
const x = cx + radius * Math.cos(-midAngle * RADIAN)
|
||||
const y = cy + radius * Math.sin(-midAngle * RADIAN)
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
fill="white"
|
||||
textAnchor={x > cx ? 'start' : 'end'}
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
>
|
||||
{`${(percent * 100).toFixed(0)}%`}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||
const total = data.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
// Format status for display
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
name: d.status.replace(/_/g, ' '),
|
||||
color: STATUS_COLORS[d.status] || '#8884d8',
|
||||
const pieData = data.map((d) => ({
|
||||
id: d.status,
|
||||
label: formatStatus(d.status),
|
||||
value: d.count,
|
||||
color: getStatusColor(d.status),
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -91,39 +34,42 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={formattedData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={renderCustomLabel}
|
||||
outerRadius={100}
|
||||
innerRadius={50}
|
||||
fill="#8884d8"
|
||||
dataKey="count"
|
||||
nameKey="name"
|
||||
>
|
||||
{formattedData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'hsl(var(--card))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
formatter={(value: number | undefined, name: string | undefined) => [
|
||||
`${value ?? 0} (${(((value ?? 0) / total) * 100).toFixed(1)}%)`,
|
||||
name ?? '',
|
||||
]}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div style={{ height: '300px' }}>
|
||||
<ResponsivePie
|
||||
data={pieData}
|
||||
theme={nivoTheme}
|
||||
colors={{ datum: 'data.color' }}
|
||||
innerRadius={0.5}
|
||||
padAngle={0.7}
|
||||
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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user