Observer platform redesign Phase 4: migrate charts to Tremor, redesign all pages
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s
Some checks failed
Build and Push Docker Image / build (push) Failing after 23s
- Migrate 9 chart components from Nivo to @tremor/react (BarChart, AreaChart, DonutChart, ScatterChart) - Remove @nivo/*, @react-spring/web dependencies (45 packages removed) - Redesign dashboard: 6 stat tiles, competition pipeline, score distribution, juror workload, activity feed - Add new /observer/projects page with search, filters, sorting, pagination, CSV export - Restructure reports page from 5 tabs to 3 (Progress, Jurors, Scores & Analytics) with per-tab CSV export - Redesign project detail: breadcrumb nav, score card header, 3-tab layout (Overview/Evaluations/Files) - Update loading skeletons to match new layouts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
import type { PartialTheme } from '@nivo/theming'
|
||||
|
||||
// Brand colors from CLAUDE.md
|
||||
export const BRAND_DARK_BLUE = '#053d57'
|
||||
export const BRAND_RED = '#de0f1e'
|
||||
@@ -73,50 +71,6 @@ function lerpColor(a: string, b: string, t: number): string {
|
||||
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
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
||||
import { BRAND_TEAL } from './chart-theme'
|
||||
|
||||
interface CriteriaScoreData {
|
||||
id: string
|
||||
@@ -15,13 +15,6 @@ interface CriteriaScoresProps {
|
||||
data: CriteriaScoreData[]
|
||||
}
|
||||
|
||||
type CriterionBarDatum = {
|
||||
criterion: string
|
||||
averageScore: number
|
||||
fullName: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||
if (!data?.length) return null
|
||||
|
||||
@@ -30,12 +23,10 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||
? data.reduce((sum, d) => sum + d.averageScore, 0) / data.length
|
||||
: 0
|
||||
|
||||
const chartData: CriterionBarDatum[] = data.map((d) => ({
|
||||
const chartData = data.map((d) => ({
|
||||
criterion:
|
||||
d.name.length > 25 ? d.name.substring(0, 25) + '...' : d.name,
|
||||
averageScore: d.averageScore,
|
||||
fullName: d.name,
|
||||
count: d.count,
|
||||
'Avg Score': parseFloat(d.averageScore.toFixed(2)),
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -49,55 +40,17 @@ export function CriteriaScoresChart({ data }: CriteriaScoresProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<strong>{rowData.fullName}</strong>
|
||||
<br />
|
||||
Average Score: {Number(rowData.averageScore).toFixed(2)}
|
||||
<br />
|
||||
Ratings: {rowData.count}
|
||||
</div>
|
||||
)}
|
||||
animate={true}
|
||||
/>
|
||||
</div>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="criterion"
|
||||
categories={['Avg Score']}
|
||||
colors={[BRAND_TEAL] as string[]}
|
||||
maxValue={10}
|
||||
yAxisWidth={40}
|
||||
showLegend={false}
|
||||
className="h-[300px]"
|
||||
rotateLabelX={{ angle: -45, xAxisHeight: 60 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, BRAND_COLORS } from './chart-theme'
|
||||
import { BRAND_COLORS } from './chart-theme'
|
||||
|
||||
interface StageComparison {
|
||||
roundId: string
|
||||
@@ -36,16 +36,14 @@ export function CrossStageComparisonChart({
|
||||
round.roundName.length > 20
|
||||
? round.roundName.slice(0, 20) + '...'
|
||||
: round.roundName,
|
||||
projects: round.projectCount,
|
||||
evaluations: round.evaluationCount,
|
||||
completionRate: round.completionRate,
|
||||
avgScore: round.averageScore
|
||||
Projects: round.projectCount,
|
||||
Evaluations: round.evaluationCount,
|
||||
'Completion Rate': round.completionRate,
|
||||
'Avg Score': round.averageScore
|
||||
? parseFloat(round.averageScore.toFixed(2))
|
||||
: 0,
|
||||
}))
|
||||
|
||||
const sharedMargin = { top: 10, right: 10, bottom: 40, left: 40 }
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -58,25 +56,16 @@ export function CrossStageComparisonChart({
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Projects']}
|
||||
colors={[BRAND_COLORS[0]] as string[]}
|
||||
showLegend={false}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -87,25 +76,16 @@ export function CrossStageComparisonChart({
|
||||
</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>
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Evaluations']}
|
||||
colors={[BRAND_COLORS[2]] as string[]}
|
||||
showLegend={false}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -116,30 +96,18 @@ export function CrossStageComparisonChart({
|
||||
</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>
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Completion Rate']}
|
||||
colors={[BRAND_COLORS[1]] as string[]}
|
||||
showLegend={false}
|
||||
maxValue={100}
|
||||
yAxisWidth={40}
|
||||
valueFormatter={(v) => `${v}%`}
|
||||
className="h-[200px]"
|
||||
rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -150,26 +118,17 @@ export function CrossStageComparisonChart({
|
||||
</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>
|
||||
<BarChart
|
||||
data={baseData}
|
||||
index="name"
|
||||
categories={['Avg Score']}
|
||||
colors={[BRAND_COLORS[0]] as string[]}
|
||||
showLegend={false}
|
||||
maxValue={10}
|
||||
yAxisWidth={40}
|
||||
className="h-[200px]"
|
||||
rotateLabelX={{ angle: -25, xAxisHeight: 40 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsivePie } from '@nivo/pie'
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { DonutChart, BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { nivoTheme, BRAND_COLORS } from './chart-theme'
|
||||
import { BRAND_COLORS } from './chart-theme'
|
||||
|
||||
interface DiversityData {
|
||||
total: number
|
||||
@@ -49,10 +48,10 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// Top countries for pie chart (max 10, others grouped)
|
||||
// Top countries for donut chart (max 10, others grouped)
|
||||
const topCountries = (data.byCountry || []).slice(0, 10)
|
||||
const otherCountries = (data.byCountry || []).slice(10)
|
||||
const countryPieData = otherCountries.length > 0
|
||||
const countryData = otherCountries.length > 0
|
||||
? [...topCountries, {
|
||||
country: 'Others',
|
||||
count: otherCountries.reduce((sum, c) => sum + c.count, 0),
|
||||
@@ -60,21 +59,19 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
}]
|
||||
: topCountries
|
||||
|
||||
const nivoPieData = countryPieData.map((c) => ({
|
||||
id: c.country === 'Others' ? 'Others' : c.country.toUpperCase(),
|
||||
label: getCountryName(c.country),
|
||||
const donutData = countryData.map((c) => ({
|
||||
name: getCountryName(c.country),
|
||||
value: c.count,
|
||||
}))
|
||||
|
||||
// Pre-format category and ocean issue data for display
|
||||
const formattedCategories = (data.byCategory || []).slice(0, 10).map((c) => ({
|
||||
const categoryData = (data.byCategory || []).slice(0, 10).map((c) => ({
|
||||
category: formatLabel(c.category),
|
||||
count: c.count,
|
||||
Count: c.count,
|
||||
}))
|
||||
|
||||
const formattedOceanIssues = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
|
||||
const oceanIssueData = (data.byOceanIssue || []).slice(0, 15).map((o) => ({
|
||||
issue: formatLabel(o.issue),
|
||||
count: o.count,
|
||||
Count: o.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -114,45 +111,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
<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>
|
||||
{donutData.length > 0 ? (
|
||||
<DonutChart
|
||||
data={donutData}
|
||||
category="value"
|
||||
index="name"
|
||||
colors={[...BRAND_COLORS] as string[]}
|
||||
className="h-[400px]"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">No geographic data</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -162,29 +131,17 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
<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>
|
||||
{categoryData.length > 0 ? (
|
||||
<BarChart
|
||||
data={categoryData}
|
||||
index="category"
|
||||
categories={['Count']}
|
||||
colors={[BRAND_COLORS[0]] as string[]}
|
||||
layout="horizontal"
|
||||
yAxisWidth={120}
|
||||
showLegend={false}
|
||||
className="h-[400px]"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">No category data</p>
|
||||
)}
|
||||
@@ -193,38 +150,22 @@ export function DiversityMetricsChart({ data }: DiversityMetricsProps) {
|
||||
</div>
|
||||
|
||||
{/* Ocean Issues */}
|
||||
{formattedOceanIssues.length > 0 && (
|
||||
{oceanIssueData.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>
|
||||
<BarChart
|
||||
data={oceanIssueData}
|
||||
index="issue"
|
||||
categories={['Count']}
|
||||
colors={[BRAND_COLORS[2]] as string[]}
|
||||
showLegend={false}
|
||||
yAxisWidth={40}
|
||||
className="h-[400px]"
|
||||
rotateLabelX={{ angle: -35, xAxisHeight: 80 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsiveLine } from '@nivo/line'
|
||||
import { AreaChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, BRAND_DARK_BLUE } from './chart-theme'
|
||||
import { BRAND_DARK_BLUE, BRAND_TEAL } from './chart-theme'
|
||||
|
||||
interface TimelineDataPoint {
|
||||
date: string
|
||||
@@ -17,26 +17,17 @@ interface EvaluationTimelineProps {
|
||||
export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||
if (!data?.length) return null
|
||||
|
||||
const formattedData = data.map((d) => ({
|
||||
...d,
|
||||
dateFormatted: new Date(d.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
}))
|
||||
|
||||
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,
|
||||
})),
|
||||
},
|
||||
]
|
||||
const chartData = data.map((d) => ({
|
||||
date: new Date(d.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}),
|
||||
Cumulative: d.cumulative,
|
||||
Daily: d.daily,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -49,57 +40,16 @@ export function EvaluationTimelineChart({ data }: EvaluationTimelineProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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={formattedData.length >= 2 ? 'x' : false}
|
||||
sliceTooltip={({ slice }) => {
|
||||
const point = slice.points[0]
|
||||
if (!point) return null
|
||||
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>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
index="date"
|
||||
categories={['Cumulative', 'Daily']}
|
||||
colors={[BRAND_DARK_BLUE, BRAND_TEAL] as string[]}
|
||||
curveType="monotone"
|
||||
showGradient={true}
|
||||
yAxisWidth={50}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsiveScatterPlot } from '@nivo/scatterplot'
|
||||
import type {
|
||||
ScatterPlotDatum,
|
||||
ScatterPlotNodeProps,
|
||||
} from '@nivo/scatterplot'
|
||||
import { animated } from '@react-spring/web'
|
||||
import { ScatterChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
@@ -17,7 +12,7 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { nivoTheme, BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
|
||||
import { BRAND_DARK_BLUE, BRAND_RED } from './chart-theme'
|
||||
|
||||
interface JurorMetric {
|
||||
userId: string
|
||||
@@ -36,60 +31,6 @@ 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) {
|
||||
if (!data?.jurors?.length) {
|
||||
return (
|
||||
@@ -101,21 +42,17 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const scatterData = data.jurors.map((j) => ({
|
||||
'Average Score': parseFloat(j.averageScore.toFixed(2)),
|
||||
'Std Deviation': parseFloat(j.stddev.toFixed(2)),
|
||||
category: j.isOutlier ? 'Outlier' : 'Normal',
|
||||
name: j.name,
|
||||
evaluations: j.evaluationCount,
|
||||
size: Math.max(8, Math.min(20, j.evaluationCount * 2)),
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Scatter: Average Score vs Standard Deviation */}
|
||||
@@ -134,60 +71,15 @@ export function JurorConsistencyChart({ data }: JurorConsistencyProps) {
|
||||
</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>
|
||||
<ScatterChart
|
||||
data={scatterData}
|
||||
x="Average Score"
|
||||
y="Std Deviation"
|
||||
category="category"
|
||||
size="size"
|
||||
colors={[BRAND_DARK_BLUE, BRAND_RED] as string[]}
|
||||
className="h-[400px]"
|
||||
/>
|
||||
<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).
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsiveBar, type ComputedDatum } from '@nivo/bar'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme } from './chart-theme'
|
||||
import { BRAND_DARK_BLUE } from './chart-theme'
|
||||
|
||||
interface JurorWorkloadData {
|
||||
id: string
|
||||
@@ -16,14 +16,6 @@ interface JurorWorkloadProps {
|
||||
data: JurorWorkloadData[]
|
||||
}
|
||||
|
||||
type WorkloadBarDatum = {
|
||||
juror: string
|
||||
completed: number
|
||||
remaining: number
|
||||
completionRate: number
|
||||
fullName: string
|
||||
}
|
||||
|
||||
export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
if (!data?.length) return null
|
||||
|
||||
@@ -36,12 +28,10 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
(a, b) => b.completionRate - a.completionRate,
|
||||
)
|
||||
|
||||
const chartData: WorkloadBarDatum[] = sortedData.map((d) => ({
|
||||
const chartData = 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,
|
||||
Completed: d.completed,
|
||||
Remaining: d.assigned - d.completed,
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -55,66 +45,17 @@ export function JurorWorkloadChart({ data }: JurorWorkloadProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="juror"
|
||||
categories={['Completed', 'Remaining']}
|
||||
colors={[BRAND_DARK_BLUE, '#e5e7eb'] as string[]}
|
||||
layout="horizontal"
|
||||
stack={true}
|
||||
yAxisWidth={160}
|
||||
className={`h-[${Math.max(300, data.length * 35)}px]`}
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<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,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
||||
import { BRAND_TEAL } from './chart-theme'
|
||||
|
||||
interface ProjectRankingData {
|
||||
id: string
|
||||
@@ -18,14 +18,6 @@ interface ProjectRankingsProps {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
type RankingBarDatum = {
|
||||
project: string
|
||||
score: number
|
||||
fullTitle: string
|
||||
teamName: string
|
||||
evaluationCount: number
|
||||
}
|
||||
|
||||
export function ProjectRankingsChart({
|
||||
data,
|
||||
limit = 20,
|
||||
@@ -37,21 +29,12 @@ export function ProjectRankingsChart({
|
||||
|
||||
if (!scoredData.length) return null
|
||||
|
||||
const averageScore =
|
||||
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) => ({
|
||||
const chartData = 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,
|
||||
Score: parseFloat(d.averageScore.toFixed(2)),
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -65,75 +48,18 @@ export function ProjectRankingsChart({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="project"
|
||||
categories={['Score']}
|
||||
colors={[BRAND_TEAL] as string[]}
|
||||
layout="horizontal"
|
||||
yAxisWidth={200}
|
||||
maxValue={10}
|
||||
showLegend={false}
|
||||
className={`h-[${Math.max(400, displayData.length * 30)}px]`}
|
||||
style={{ height: `${Math.max(400, displayData.length * 30)}px` }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsiveBar } from '@nivo/bar'
|
||||
import { BarChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, scoreGradient } from './chart-theme'
|
||||
import { BRAND_TEAL } from './chart-theme'
|
||||
|
||||
interface ScoreDistributionProps {
|
||||
data: { score: number; count: number }[]
|
||||
@@ -19,7 +19,7 @@ export function ScoreDistributionChart({
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
score: String(d.score),
|
||||
count: d.count,
|
||||
Count: d.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
@@ -33,32 +33,15 @@ export function ScoreDistributionChart({
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<BarChart
|
||||
data={chartData}
|
||||
index="score"
|
||||
categories={['Count']}
|
||||
colors={[BRAND_TEAL] as (string)[]}
|
||||
yAxisWidth={40}
|
||||
showLegend={false}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { ResponsivePie } from '@nivo/pie'
|
||||
import { DonutChart } from '@tremor/react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { nivoTheme, getStatusColor, formatStatus } from './chart-theme'
|
||||
import { getStatusColor, formatStatus } from './chart-theme'
|
||||
|
||||
interface StatusDataPoint {
|
||||
status: string
|
||||
@@ -18,13 +18,13 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||
|
||||
const total = data.reduce((sum, item) => sum + item.count, 0)
|
||||
|
||||
const pieData = data.map((d) => ({
|
||||
id: d.status,
|
||||
label: formatStatus(d.status),
|
||||
const chartData = data.map((d) => ({
|
||||
name: formatStatus(d.status),
|
||||
value: d.count,
|
||||
color: getStatusColor(d.status),
|
||||
}))
|
||||
|
||||
const colors = data.map((d) => getStatusColor(d.status))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -36,43 +36,14 @@ export function StatusBreakdownChart({ data }: StatusBreakdownProps) {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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>
|
||||
<DonutChart
|
||||
data={chartData}
|
||||
category="value"
|
||||
index="name"
|
||||
colors={colors as string[]}
|
||||
showLabel={true}
|
||||
className="h-[300px]"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user