import { useState } from "react";
import { CheckCircle2, XCircle, RotateCcw } from "lucide-react";
// ── Types ──────────────────────────────────────────────────────────────────
export type Verdict = "supported" | "contradicted" | "neither";
export interface ChartSeries {
name: string;
data: { label: string; value: number }[];
}
export interface ChartData {
type: "bar" | "line";
title: string;
yLabel?: string;
xLabel?: string;
source?: string;
unit?: string; // e.g. '%', '°C', 'min'
series: ChartSeries[];
}
export interface DataClaim {
text: string;
verdict: Verdict;
explanation: string;
}
export interface DataExercise {
title: string;
chart: ChartData;
claims: DataClaim[];
}
// ── Chart palette ──────────────────────────────────────────────────────────
const PALETTE = [
"#3b82f6",
"#8b5cf6",
"#f97316",
"#10b981",
"#ef4444",
"#ec4899",
];
// ── BarChart ───────────────────────────────────────────────────────────────
function BarChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(
null,
);
const labels = chart.series[0].data.map((d) => d.label);
const allValues = chart.series.flatMap((s) => s.data.map((d) => d.value));
const maxVal = Math.max(...allValues);
// Round up max to nearest 10 for cleaner y-axis
const yMax = Math.ceil(maxVal / 10) * 10;
const yTicks = [0, yMax * 0.25, yMax * 0.5, yMax * 0.75, yMax];
const chartH = 180; // px height of bar area
return (
{chart.title}
{/* Y-axis */}
{yTicks.map((t) => (
{t}
{chart.unit ?? ""}
))}
{/* Bar groups */}
{labels.map((_, pi) => (
{/* Bar group */}
{chart.series.map((s, si) => {
const val = s.data[pi].value;
const heightPct = (val / yMax) * 100;
const isHov = hovered?.si === si && hovered?.pi === pi;
return (
setHovered({ si, pi })}
onMouseLeave={() => setHovered(null)}
>
{/* Value label on hover */}
{isHov && (
{val}
{chart.unit ?? ""}
)}
);
})}
))}
{/* X-axis labels */}
{labels.map((label, i) => (
{label}
))}
{chart.xLabel && (
{chart.xLabel}
)}
{/* Legend */}
{chart.series.length > 1 && (
{chart.series.map((s, si) => (
))}
)}
{/* Hover info bar */}
{hovered && (
{chart.series[hovered.si].name}
{" — "}
{chart.series[0].data[hovered.pi].label}:{" "}
{chart.series[hovered.si].data[hovered.pi].value}
{chart.unit ?? ""}
)}
{chart.source && (
Source: {chart.source}
)}
);
}
// ── LineChart ──────────────────────────────────────────────────────────────
function LineChart({ chart }: { chart: ChartData }) {
const [hovered, setHovered] = useState<{ si: number; pi: number } | null>(
null,
);
const W = 480,
H = 200;
const PAD = { top: 20, right: 20, bottom: 36, left: 48 };
const cW = W - PAD.left - PAD.right;
const cH = H - PAD.top - PAD.bottom;
const allValues = chart.series.flatMap((s) => s.data.map((d) => d.value));
const minVal = Math.min(...allValues);
const maxVal = Math.max(...allValues);
const spread = maxVal - minVal || 1;
// Add 10% padding on y-axis
const yPad = spread * 0.15;
const yMin = minVal - yPad;
const yMax = maxVal + yPad;
const yRange = yMax - yMin;
const labels = chart.series[0].data.map((d) => d.label);
const xStep = cW / (labels.length - 1);
const xPos = (i: number) => PAD.left + i * xStep;
const yPos = (v: number) => PAD.top + cH - ((v - yMin) / yRange) * cH;
// Y-axis ticks: 5 evenly spaced
const yTicks = Array.from(
{ length: 5 },
(_, i) => minVal + ((maxVal - minVal) / 4) * i,
);
return (
{chart.title}
{/* Legend */}
{chart.series.length > 1 && (
{chart.series.map((s, si) => (
))}
)}
{/* Hover tooltip */}
{hovered && (
{chart.series[hovered.si].name}
{" · "}
{chart.series[0].data[hovered.pi].label}:{" "}
{chart.series[hovered.si].data[hovered.pi].value}
{chart.unit ?? ""}
)}
{chart.source && (
Source: {chart.source}
)}
);
}
// ── Main widget ────────────────────────────────────────────────────────────
const VERDICT_LABELS: Record = {
supported: "Supported by data",
contradicted: "Contradicted by data",
neither: "Neither proven nor disproven",
};
interface DataClaimWidgetProps {
exercises: DataExercise[];
accentColor?: string;
}
// Pre-resolved accent classes to avoid Tailwind purge issues
const ACCENT_CLASSES: Record<
string,
{ tab: string; header: string; label: string; btn: string }
> = {
amber: {
tab: "border-b-2 border-amber-600 text-amber-700",
header: "bg-amber-50",
label: "text-amber-600",
btn: "bg-amber-600 hover:bg-amber-700",
},
teal: {
tab: "border-b-2 border-teal-600 text-teal-700",
header: "bg-teal-50",
label: "text-teal-600",
btn: "bg-teal-600 hover:bg-teal-700",
},
purple: {
tab: "border-b-2 border-purple-600 text-purple-700",
header: "bg-purple-50",
label: "text-purple-600",
btn: "bg-purple-600 hover:bg-purple-700",
},
fuchsia: {
tab: "border-b-2 border-fuchsia-600 text-fuchsia-700",
header: "bg-fuchsia-50",
label: "text-fuchsia-600",
btn: "bg-fuchsia-600 hover:bg-fuchsia-700",
},
};
export default function DataClaimWidget({
exercises,
accentColor = "amber",
}: DataClaimWidgetProps) {
const [activeEx, setActiveEx] = useState(0);
const [answers, setAnswers] = useState>({});
const [submitted, setSubmitted] = useState(false);
const exercise = exercises[activeEx];
const allAnswered = exercise.claims.every((_, i) => answers[i] !== undefined);
const score = submitted
? exercise.claims.filter((c, i) => answers[i] === c.verdict).length
: 0;
const c = ACCENT_CLASSES[accentColor] ?? ACCENT_CLASSES.amber;
const reset = () => {
setAnswers({});
setSubmitted(false);
};
const switchEx = (i: number) => {
setActiveEx(i);
setAnswers({});
setSubmitted(false);
};
return (
{/* Tabs */}
{exercises.length > 1 && (
{exercises.map((ex, i) => (
))}
)}
{/* Chart */}
Data Source
{exercise.chart.type === "bar" ? (
) : (
)}
{/* Claims */}
For each claim, decide if the data{" "}
supports,{" "}
contradicts, or{" "}
neither proves nor disproves
{" "}
it:
{exercise.claims.map((claim, i) => {
const userAnswer = answers[i];
const isCorrect = submitted && userAnswer === claim.verdict;
const isWrong =
submitted &&
userAnswer !== undefined &&
userAnswer !== claim.verdict;
return (
Claim {i + 1}:
{claim.text}
{(["supported", "contradicted", "neither"] as Verdict[]).map(
(v) => {
const isSelected = userAnswer === v;
const isCorrectOpt = submitted && v === claim.verdict;
let cls =
"border-gray-200 text-gray-600 hover:border-gray-400 hover:bg-gray-50";
if (isSelected && !submitted)
cls = `border-amber-500 bg-amber-50 text-amber-800 font-semibold`;
if (submitted) {
if (isCorrectOpt)
cls =
"border-green-400 bg-green-100 text-green-800 font-semibold";
else if (isSelected)
cls = "border-red-300 bg-red-100 text-red-700";
else cls = "border-gray-100 text-gray-400";
}
return (
);
},
)}
{submitted && (
{isCorrect ? (
) : (
)}
{!isCorrect && (
Answer: {VERDICT_LABELS[claim.verdict]}.{" "}
)}
{claim.explanation}
)}
);
})}
{/* Footer */}
{!submitted ? (
) : (
{score}/{exercise.claims.length} correct
)}
);
}