279 lines
9.4 KiB
TypeScript
279 lines
9.4 KiB
TypeScript
import React, { useState } from "react";
|
|
import {
|
|
ChevronRight,
|
|
RotateCcw,
|
|
CheckCircle2,
|
|
AlertTriangle,
|
|
Info,
|
|
} from "lucide-react";
|
|
|
|
export interface TreeNode {
|
|
id: string;
|
|
question?: string;
|
|
hint?: string;
|
|
yesLabel?: string;
|
|
noLabel?: string;
|
|
yes?: TreeNode;
|
|
no?: TreeNode;
|
|
result?: string;
|
|
resultType?: "correct" | "warning" | "info";
|
|
ruleRef?: string;
|
|
}
|
|
|
|
export interface TreeScenario {
|
|
label: string; // Short tab label, e.g. "Sentence 1"
|
|
sentence: string; // The sentence to analyze
|
|
tree: TreeNode;
|
|
}
|
|
|
|
interface DecisionTreeWidgetProps {
|
|
scenarios: TreeScenario[];
|
|
accentColor?: string;
|
|
}
|
|
|
|
type Answers = Record<string, "yes" | "no">;
|
|
|
|
/** Walk the tree following answers, return ordered list of [node, answer|null] pairs traversed */
|
|
function getPath(
|
|
root: TreeNode,
|
|
answers: Answers,
|
|
): Array<{ node: TreeNode; answer: "yes" | "no" | null }> {
|
|
const path: Array<{ node: TreeNode; answer: "yes" | "no" | null }> = [];
|
|
let current: TreeNode | undefined = root;
|
|
while (current) {
|
|
// @ts-ignore
|
|
const ans = answers[current.id] ?? null;
|
|
path.push({ node: current, answer: ans });
|
|
if (ans === null) break; // not answered yet — this is the active node
|
|
if (current.result !== undefined) break; // leaf
|
|
current = ans === "yes" ? current.yes : current.no;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
const RESULT_STYLES = {
|
|
correct: {
|
|
bg: "bg-green-50",
|
|
border: "border-green-300",
|
|
text: "text-green-800",
|
|
icon: <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />,
|
|
},
|
|
warning: {
|
|
bg: "bg-amber-50",
|
|
border: "border-amber-300",
|
|
text: "text-amber-800",
|
|
icon: <AlertTriangle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />,
|
|
},
|
|
info: {
|
|
bg: "bg-blue-50",
|
|
border: "border-blue-300",
|
|
text: "text-blue-800",
|
|
icon: <Info className="w-5 h-5 text-blue-600 shrink-0 mt-0.5" />,
|
|
},
|
|
};
|
|
|
|
export default function DecisionTreeWidget({
|
|
scenarios,
|
|
accentColor = "purple",
|
|
}: DecisionTreeWidgetProps) {
|
|
const [activeScenario, setActiveScenario] = useState(0);
|
|
const [answers, setAnswers] = useState<Answers>({});
|
|
|
|
const scenario = scenarios[activeScenario];
|
|
const path = getPath(scenario.tree, answers);
|
|
const lastStep = path[path.length - 1];
|
|
|
|
// reached leaf, no more choices needed
|
|
// Actually leaf nodes don't have yes/no — they just show result when we arrive
|
|
const atLeaf = lastStep.node.result !== undefined;
|
|
|
|
const handleAnswer = (nodeId: string, ans: "yes" | "no") => {
|
|
setAnswers((prev) => {
|
|
// Remove all answers for nodes that come AFTER this one in the current path
|
|
const pathIds = path.map((p) => p.node.id);
|
|
const idx = pathIds.indexOf(nodeId);
|
|
const newAnswers: Answers = {};
|
|
for (let i = 0; i < idx; i++) {
|
|
newAnswers[pathIds[i]] = prev[pathIds[i]]!;
|
|
}
|
|
newAnswers[nodeId] = ans;
|
|
return newAnswers;
|
|
});
|
|
};
|
|
|
|
const resetScenario = () => setAnswers({});
|
|
|
|
const switchScenario = (i: number) => {
|
|
setActiveScenario(i);
|
|
setAnswers({});
|
|
};
|
|
|
|
return (
|
|
<div className="rounded-2xl border border-gray-200 bg-white overflow-hidden shadow-sm">
|
|
{/* Scenario tab strip */}
|
|
{scenarios.length > 1 && (
|
|
<div className="flex border-b border-gray-200 bg-gray-50 overflow-x-auto">
|
|
{scenarios.map((sc, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => switchScenario(i)}
|
|
className={`px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors ${
|
|
i === activeScenario
|
|
? `bg-white border-b-2 border-${accentColor}-600 text-${accentColor}-700`
|
|
: "text-gray-500 hover:text-gray-700"
|
|
}`}
|
|
>
|
|
{sc.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Sentence under analysis */}
|
|
<div
|
|
className={`px-5 py-4 border-b border-gray-100 bg-${accentColor}-50`}
|
|
>
|
|
<p
|
|
className={`text-xs font-semibold uppercase tracking-wider text-${accentColor}-500 mb-1`}
|
|
>
|
|
Analyze this sentence
|
|
</p>
|
|
<p className="text-gray-800 font-medium italic leading-relaxed">
|
|
"{scenario.sentence}"
|
|
</p>
|
|
</div>
|
|
|
|
{/* Breadcrumb path */}
|
|
{path.length > 1 && (
|
|
<div className="px-5 py-2.5 border-b border-gray-100 bg-gray-50 flex flex-wrap items-center gap-1 text-xs text-gray-500">
|
|
{path.map((step, i) => {
|
|
if (i === path.length - 1) return null; // last step shown below, not in crumb
|
|
const isAnswered = step.answer !== null;
|
|
return (
|
|
<React.Fragment key={step.node.id}>
|
|
<button
|
|
onClick={() => {
|
|
// Reset from this node forward
|
|
const pathIds = path.map((p) => p.node.id);
|
|
const idx = pathIds.indexOf(step.node.id);
|
|
setAnswers((prev) => {
|
|
const newAnswers: Answers = {};
|
|
for (let j = 0; j < idx; j++)
|
|
newAnswers[pathIds[j]] = prev[pathIds[j]]!;
|
|
return newAnswers;
|
|
});
|
|
}}
|
|
className={`px-2 py-0.5 rounded transition-colors ${
|
|
isAnswered
|
|
? "text-gray-600 hover:text-gray-900 hover:bg-gray-200"
|
|
: "text-gray-400"
|
|
}`}
|
|
>
|
|
{
|
|
// @ts-ignore
|
|
step.node.question.length > 40
|
|
? // @ts-ignore
|
|
step.node.question.slice(0, 40) + "…"
|
|
: step.node.question
|
|
}
|
|
{step.answer && (
|
|
<span
|
|
className={`ml-1 font-semibold ${step.answer === "yes" ? "text-green-600" : "text-red-500"}`}
|
|
>
|
|
→{" "}
|
|
{step.answer === "yes"
|
|
? (step.node.yesLabel ?? "Yes")
|
|
: (step.node.noLabel ?? "No")}
|
|
</span>
|
|
)}
|
|
</button>
|
|
<ChevronRight className="w-3 h-3 shrink-0" />
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Active node */}
|
|
<div className="px-5 py-5">
|
|
{atLeaf
|
|
? /* Leaf result */
|
|
(() => {
|
|
const node = lastStep.node;
|
|
const rType = node.resultType ?? "correct";
|
|
const s = RESULT_STYLES[rType];
|
|
return (
|
|
<div className={`rounded-xl border p-4 ${s.bg} ${s.border}`}>
|
|
<div className="flex gap-3">
|
|
{s.icon}
|
|
<div>
|
|
<p className={`font-semibold ${s.text} leading-snug`}>
|
|
{node.result}
|
|
</p>
|
|
{node.ruleRef && (
|
|
<p
|
|
className={`mt-2 text-sm font-mono ${s.text} opacity-80 bg-white/60 rounded px-2 py-1 inline-block`}
|
|
>
|
|
{node.ruleRef}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()
|
|
: /* Decision question */
|
|
(() => {
|
|
const node = lastStep.node;
|
|
return (
|
|
<div>
|
|
<p className="font-semibold text-gray-800 text-base leading-snug mb-1">
|
|
{node.question}
|
|
</p>
|
|
{node.hint && (
|
|
<p className="text-sm text-gray-500 mb-4">{node.hint}</p>
|
|
)}
|
|
{!node.hint && <div className="mb-4" />}
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
onClick={() => handleAnswer(node.id, "yes")}
|
|
className="flex-1 min-w-35 px-4 py-3 rounded-xl border-2 border-green-300 bg-green-50 text-green-800 font-semibold text-sm hover:bg-green-100 transition-colors"
|
|
>
|
|
✓ {node.yesLabel ?? "Yes"}
|
|
</button>
|
|
<button
|
|
onClick={() => handleAnswer(node.id, "no")}
|
|
className="flex-1 min-w-35 px-4 py-3 rounded-xl border-2 border-red-200 bg-red-50 text-red-700 font-semibold text-sm hover:bg-red-100 transition-colors"
|
|
>
|
|
✗ {node.noLabel ?? "No"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-5 pb-4 flex items-center gap-3">
|
|
<button
|
|
onClick={resetScenario}
|
|
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 transition-colors"
|
|
>
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
Try again
|
|
</button>
|
|
{atLeaf &&
|
|
scenarios.length > 1 &&
|
|
activeScenario < scenarios.length - 1 && (
|
|
<button
|
|
onClick={() => switchScenario(activeScenario + 1)}
|
|
className={`ml-auto flex items-center gap-1.5 text-sm font-semibold text-${accentColor}-700 hover:text-${accentColor}-900 transition-colors`}
|
|
>
|
|
Next sentence <ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|