All checks were successful
Build and Push Docker Image / build (push) Successful in 8m59s
All 9 chart components now have early-return null/empty checks before calling .map() on data props. The diversity-metrics chart guards all nested array fields (byCountry, byCategory, byOceanIssue, byTag). Analytics backend guards p.tags in getDiversityMetrics. This prevents any "Cannot read properties of null (reading 'map')" crashes even if upstream data shapes are unexpected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
257 lines
8.0 KiB
TypeScript
257 lines
8.0 KiB
TypeScript
'use client'
|
|
|
|
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 {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
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
|
|
evaluationCount: number
|
|
averageScore: number
|
|
stddev: number
|
|
deviationFromOverall: number
|
|
isOutlier: boolean
|
|
}
|
|
|
|
interface JurorConsistencyProps {
|
|
data: {
|
|
overallAverage: number
|
|
jurors: JurorMetric[]
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (!data?.jurors?.length) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="flex items-center justify-center py-12">
|
|
<p className="text-muted-foreground">No juror consistency data available</p>
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
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
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Scatter: Average Score vs Standard Deviation */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between">
|
|
<span>Juror Scoring Patterns</span>
|
|
<span className="text-sm font-normal text-muted-foreground">
|
|
Overall Avg: {data.overallAverage.toFixed(2)}
|
|
{outlierCount > 0 && (
|
|
<Badge variant="destructive" className="ml-2">
|
|
{outlierCount} outlier{outlierCount > 1 ? 's' : ''}
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<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)',
|
|
}}
|
|
>
|
|
<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).
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Juror details table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Juror Consistency Details</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Juror</TableHead>
|
|
<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-center">Status</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data.jurors.map((juror) => (
|
|
<TableRow
|
|
key={juror.userId}
|
|
className={juror.isOutlier ? 'bg-destructive/5' : ''}
|
|
>
|
|
<TableCell>
|
|
<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.deviationFromOverall.toFixed(2)}
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
{juror.isOutlier ? (
|
|
<Badge variant="destructive" className="gap-1">
|
|
<AlertTriangle className="h-3 w-3" />
|
|
Outlier
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary">Normal</Badge>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|