feat: complete web curriculum visuals

This commit is contained in:
Haoran
2026-05-25 22:37:07 +08:00
parent 956d8272ce
commit fdd7d2a851
155 changed files with 19086 additions and 3584 deletions

View File

@@ -16,6 +16,10 @@ const CLASS_DESCRIPTIONS: Record<string, string> = {
TeammateManager: "Multi-agent team lifecycle and coordination",
Teammate: "Individual agent identity and state tracking",
SharedBoard: "Cross-agent shared state coordination",
CronJob: "Durable recurring job definition",
ProtocolState: "Pending team protocol requests and response matching",
MCPClient: "External tool discovery and invocation client",
RecoveryState: "Retry, fallback, and continuation state",
};
interface ArchDiagramProps {
@@ -68,29 +72,23 @@ function getLayerColorClasses(versionId: string): {
}
}
function collectClassesUpTo(
function collectClassesForVersion(
targetId: string
): { name: string; introducedIn: string }[] {
const { versions, diffs } = versionsData;
const order = versions.map((v) => v.id);
const targetIdx = order.indexOf(targetId);
if (targetIdx < 0) return [];
const targetIndex = versionsData.versions.findIndex((v) => v.id === targetId);
const version = targetIndex >= 0 ? versionsData.versions[targetIndex] : undefined;
const result: { name: string; introducedIn: string }[] = [];
const seen = new Set<string>();
for (let i = 0; i <= targetIdx; i++) {
const v = versions[i];
if (!v.classes) continue;
for (const cls of v.classes) {
if (!seen.has(cls.name)) {
seen.add(cls.name);
result.push({ name: cls.name, introducedIn: v.id });
}
}
}
return result;
return (
version?.classes?.map((cls) => ({
name: cls.name,
introducedIn:
versionsData.versions
.slice(0, targetIndex + 1)
.find((candidate) =>
candidate.classes?.some((candidateCls) => candidateCls.name === cls.name)
)?.id ?? targetId,
})) ?? []
);
}
function getNewClassNames(version: string): Set<string> {
@@ -103,7 +101,7 @@ function getNewClassNames(version: string): Set<string> {
}
export function ArchDiagram({ version }: ArchDiagramProps) {
const allClasses = collectClassesUpTo(version);
const allClasses = collectClassesForVersion(version);
const newClassNames = getNewClassNames(version);
const versionData = versionsData.versions.find((v) => v.id === version);
const tools = versionData?.tools ?? [];

View File

@@ -18,6 +18,14 @@ import s09Annotations from "@/data/annotations/s09.json";
import s10Annotations from "@/data/annotations/s10.json";
import s11Annotations from "@/data/annotations/s11.json";
import s12Annotations from "@/data/annotations/s12.json";
import s13Annotations from "@/data/annotations/s13.json";
import s14Annotations from "@/data/annotations/s14.json";
import s15Annotations from "@/data/annotations/s15.json";
import s16Annotations from "@/data/annotations/s16.json";
import s17Annotations from "@/data/annotations/s17.json";
import s18Annotations from "@/data/annotations/s18.json";
import s19Annotations from "@/data/annotations/s19.json";
import s20Annotations from "@/data/annotations/s20.json";
interface Decision {
id: string;
@@ -46,6 +54,14 @@ const ANNOTATIONS: Record<string, AnnotationFile> = {
s10: s10Annotations as AnnotationFile,
s11: s11Annotations as AnnotationFile,
s12: s12Annotations as AnnotationFile,
s13: s13Annotations as AnnotationFile,
s14: s14Annotations as AnnotationFile,
s15: s15Annotations as AnnotationFile,
s16: s16Annotations as AnnotationFile,
s17: s17Annotations as AnnotationFile,
s18: s18Annotations as AnnotationFile,
s19: s19Annotations as AnnotationFile,
s20: s20Annotations as AnnotationFile,
};
interface DesignDecisionsProps {
@@ -124,7 +140,14 @@ export function DesignDecisions({ version }: DesignDecisionsProps) {
const annotations = ANNOTATIONS[version];
if (!annotations || annotations.decisions.length === 0) {
return null;
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t("design_decisions")}</h2>
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-bg)] p-6 text-sm text-[var(--color-text-secondary)]">
Design decisions are not available for this lesson yet.
</div>
</div>
);
}
return (

View File

@@ -6,8 +6,9 @@ import { getFlowForVersion } from "@/data/execution-flows";
import type { FlowNode, FlowEdge } from "@/types/agent-data";
const NODE_WIDTH = 140;
const NODE_HEIGHT = 40;
const DIAMOND_SIZE = 50;
const NODE_HEIGHT = 44;
const DIAMOND_WIDTH = 92;
const DIAMOND_HEIGHT = 64;
const LAYER_COLORS: Record<string, string> = {
start: "#3B82F6",
@@ -17,39 +18,207 @@ const LAYER_COLORS: Record<string, string> = {
end: "#EF4444",
};
function getNodeCenter(node: FlowNode): { cx: number; cy: number } {
return { cx: node.x, cy: node.y };
function getNodeLines(node: FlowNode): string[] {
const maxChars = node.type === "decision" ? 12 : 18;
return node.label.split("\n").flatMap((line) => {
if (line.length <= maxChars) return [line];
const parts = line.split(/(\s+\/\s+|\s+|_)/).filter(Boolean);
const chunks: string[] = [];
let current = "";
for (const part of parts) {
const next = `${current}${part}`;
if (current && next.trim().length > maxChars) {
chunks.push(current.trim());
current = part.trimStart();
} else {
current = next;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks.length ? chunks : [line];
});
}
function estimateTextWidth(line: string, fontSize: number): number {
return line.length * fontSize * 0.62;
}
function getNodeMetrics(node: FlowNode) {
const lines = getNodeLines(node);
const longest = Math.max(...lines.map((line) => estimateTextWidth(line, 11)), 0);
if (node.type === "decision") {
return {
lines,
width: Math.max(DIAMOND_WIDTH, longest + 54),
height: Math.max(DIAMOND_HEIGHT, lines.length * 15 + 42),
};
}
if (node.type === "start" || node.type === "end") {
return {
lines,
width: Math.max(NODE_WIDTH, longest + 34),
height: Math.max(NODE_HEIGHT, lines.length * 15 + 24),
};
}
return {
lines,
width: Math.max(NODE_WIDTH, longest + 30),
height: Math.max(NODE_HEIGHT, lines.length * 15 + 24),
};
}
function getNodeBounds(node: FlowNode) {
const metrics = getNodeMetrics(node);
const halfW = metrics.width / 2;
const halfH = metrics.height / 2;
return {
cx: node.x,
cy: node.y,
left: node.x - halfW,
right: node.x + halfW,
top: node.y - halfH,
bottom: node.y + halfH,
};
}
const LOOP_RAIL_X = -48;
const RIGHT_LOOP_RAIL_X = 576;
const FLOW_CENTER_X = 300;
const LOOP_PAD = 28;
const LOOP_BACK_DX_LIMIT = 360;
const LOOP_BACK_DY_LIMIT = 70;
type LoopSide = "left" | "right";
function getLoopSide(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>): LoopSide {
return (start.cx + end.cx) / 2 > FLOW_CENTER_X ? "right" : "left";
}
function getLoopRailX(
start: ReturnType<typeof getNodeBounds>,
end: ReturnType<typeof getNodeBounds>,
side = getLoopSide(start, end),
) {
if (side === "right") {
return Math.max(RIGHT_LOOP_RAIL_X, start.right + LOOP_PAD, end.right + LOOP_PAD);
}
return Math.min(LOOP_RAIL_X, start.left - LOOP_PAD, end.left - LOOP_PAD);
}
function isLoopBack(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
const dx = end.cx - start.cx;
const dy = end.cy - start.cy;
return dy < -LOOP_BACK_DY_LIMIT && Math.abs(dx) <= LOOP_BACK_DX_LIMIT;
}
function shouldUseStepRoute(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
const dx = end.cx - start.cx;
const dy = end.cy - start.cy;
return dy > 28 && Math.abs(dx) > 44 && end.top > start.bottom;
}
function getStepBusY(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
const room = end.top - start.bottom;
return Math.min(end.top - 16, start.bottom + Math.max(18, room * 0.35));
}
function getEdgePath(from: FlowNode, to: FlowNode): string {
const { cx: x1, cy: y1 } = getNodeCenter(from);
const { cx: x2, cy: y2 } = getNodeCenter(to);
const start = getNodeBounds(from);
const end = getNodeBounds(to);
const dx = end.cx - start.cx;
const dy = end.cy - start.cy;
const halfH = from.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
const halfHTo = to.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
if (Math.abs(x1 - x2) < 10) {
const startY = y1 + halfH;
const endY = y2 - halfHTo;
return `M ${x1} ${startY} L ${x2} ${endY}`;
if (isLoopBack(start, end)) {
const side = getLoopSide(start, end);
const railX = getLoopRailX(start, end, side);
const startX = side === "right" ? start.right : start.left;
const endX = side === "right" ? end.right : end.left;
const midY = (start.cy + end.cy) / 2;
return `M ${startX} ${start.cy} C ${railX} ${start.cy}, ${railX} ${midY}, ${railX} ${midY} C ${railX} ${end.cy}, ${endX} ${end.cy}, ${endX} ${end.cy}`;
}
const startY = y1 + halfH;
const endY = y2 - halfHTo;
const midY = (startY + endY) / 2;
return `M ${x1} ${startY} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${endY}`;
if (Math.abs(dx) < 10) {
if (dy >= 0) {
return `M ${start.cx} ${start.bottom} L ${end.cx} ${end.top}`;
}
return `M ${start.cx} ${start.top} L ${end.cx} ${end.bottom}`;
}
if (Math.abs(dy) < 10) {
const startX = dx > 0 ? start.right : start.left;
const endX = dx > 0 ? end.left : end.right;
const midX = (startX + endX) / 2;
return `M ${startX} ${start.cy} C ${midX} ${start.cy}, ${midX} ${end.cy}, ${endX} ${end.cy}`;
}
if (shouldUseStepRoute(start, end)) {
const busY = getStepBusY(start, end);
return `M ${start.cx} ${start.bottom} L ${start.cx} ${busY} L ${end.cx} ${busY} L ${end.cx} ${end.top}`;
}
if (Math.abs(dx) > 70) {
const startX = dx > 0 ? start.right : start.left;
const endX = dx > 0 ? end.left : end.right;
const control = Math.max(56, Math.abs(dx) * 0.45);
return `M ${startX} ${start.cy} C ${startX + (dx > 0 ? control : -control)} ${start.cy}, ${endX - (dx > 0 ? control : -control)} ${end.cy}, ${endX} ${end.cy}`;
}
const startY = dy > 0 ? start.bottom : start.top;
const endY = dy > 0 ? end.top : end.bottom;
const controlDistance = Math.max(44, Math.abs(endY - startY) * 0.42);
const controlY1 = startY + (endY > startY ? controlDistance : -controlDistance);
const controlY2 = endY - (endY > startY ? controlDistance : -controlDistance);
return `M ${start.cx} ${startY} C ${start.cx} ${controlY1}, ${end.cx} ${controlY2}, ${end.cx} ${endY}`;
}
function getEdgeLabelPosition(from: FlowNode, to: FlowNode): { x: number; y: number } {
const start = getNodeBounds(from);
const end = getNodeBounds(to);
const dx = end.cx - start.cx;
const dy = end.cy - start.cy;
if (isLoopBack(start, end)) {
const side = getLoopSide(start, end);
return {
x: getLoopRailX(start, end, side) + (side === "right" ? -24 : 24),
y: (start.cy + end.cy) / 2 - 6,
};
}
if (Math.abs(dy) < 10) {
return { x: (start.cx + end.cx) / 2, y: start.cy - 12 };
}
if (shouldUseStepRoute(start, end)) {
return { x: (start.cx + end.cx) / 2, y: getStepBusY(start, end) - 8 };
}
return {
x: (start.cx + end.cx) / 2 + (dx > 0 ? 18 : -18),
y: (start.bottom + end.top) / 2 - 8,
};
}
function NodeShape({ node }: { node: FlowNode }) {
const color = LAYER_COLORS[node.type];
const lines = node.label.split("\n");
const { lines, width, height } = getNodeMetrics(node);
if (node.type === "decision") {
const half = DIAMOND_SIZE / 2;
const halfW = width / 2;
const halfH = height / 2;
return (
<g>
<polygon
points={`${node.x},${node.y - half} ${node.x + half},${node.y} ${node.x},${node.y + half} ${node.x - half},${node.y}`}
points={`${node.x},${node.y - halfH} ${node.x + halfW},${node.y} ${node.x},${node.y + halfH} ${node.x - halfW},${node.y}`}
fill="none"
stroke={color}
strokeWidth={2}
@@ -58,10 +227,10 @@ function NodeShape({ node }: { node: FlowNode }) {
<text
key={i}
x={node.x}
y={node.y + (i - (lines.length - 1) / 2) * 12}
y={node.y + (i - (lines.length - 1) / 2) * 13}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontSize={lines.length > 2 ? 9 : 10}
fontFamily="monospace"
fill="currentColor"
>
@@ -76,27 +245,30 @@ function NodeShape({ node }: { node: FlowNode }) {
return (
<g>
<rect
x={node.x - NODE_WIDTH / 2}
y={node.y - NODE_HEIGHT / 2}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={NODE_HEIGHT / 2}
x={node.x - width / 2}
y={node.y - height / 2}
width={width}
height={height}
rx={height / 2}
fill="none"
stroke={color}
strokeWidth={2}
/>
<text
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
fontWeight={600}
fontFamily="monospace"
fill="currentColor"
>
{node.label}
</text>
{lines.map((line, i) => (
<text
key={i}
x={node.x}
y={node.y + (i - (lines.length - 1) / 2) * 14}
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
fontWeight={600}
fontFamily="monospace"
fill="currentColor"
>
{line}
</text>
))}
</g>
);
}
@@ -105,10 +277,10 @@ function NodeShape({ node }: { node: FlowNode }) {
return (
<g>
<rect
x={node.x - NODE_WIDTH / 2}
y={node.y - NODE_HEIGHT / 2}
width={NODE_WIDTH}
height={NODE_HEIGHT}
x={node.x - width / 2}
y={node.y - height / 2}
width={width}
height={height}
rx={4}
fill="none"
stroke={color}
@@ -147,12 +319,14 @@ function EdgePath({
if (!from || !to) return null;
const d = getEdgePath(from, to);
const midX = (from.x + to.x) / 2;
const midY = (from.y + to.y) / 2;
const label = getEdgeLabelPosition(from, to);
return (
<g>
<motion.path
data-edge-from={edge.from}
data-edge-to={edge.to}
data-edge-label={edge.label ?? ""}
d={d}
fill="none"
stroke="var(--color-text-secondary)"
@@ -164,10 +338,15 @@ function EdgePath({
/>
{edge.label && (
<motion.text
x={midX + 8}
y={midY - 4}
x={label.x}
y={label.y}
textAnchor="middle"
fontSize={10}
fill="var(--color-text-secondary)"
stroke="var(--color-bg)"
strokeWidth={5}
strokeLinejoin="round"
paintOrder="stroke"
fontFamily="monospace"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -191,15 +370,24 @@ export function ExecutionFlow({ version }: ExecutionFlowProps) {
setFlow(getFlowForVersion(version));
}, [version]);
if (!flow) return null;
if (!flow) {
return (
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-bg)] p-6 text-sm text-[var(--color-text-secondary)]">
Execution flow is not available for this lesson yet.
</div>
);
}
const maxY = Math.max(...flow.nodes.map((n) => n.y)) + 50;
const bounds = flow.nodes.map(getNodeBounds);
const minX = Math.min(-40, ...bounds.map((b) => b.left)) - 24;
const maxX = Math.max(700, ...bounds.map((b) => b.right)) + 24;
const maxY = Math.max(...bounds.map((b) => b.bottom)) + 50;
return (
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
<svg
viewBox={`0 0 600 ${maxY}`}
className="mx-auto w-full max-w-[600px]"
viewBox={`${minX} 0 ${maxX - minX} ${maxY}`}
className="mx-auto w-full max-w-[720px]"
style={{ minHeight: 300 }}
>
<defs>
@@ -219,12 +407,14 @@ export function ExecutionFlow({ version }: ExecutionFlowProps) {
</defs>
{flow.edges.map((edge, i) => (
<EdgePath key={`${edge.from}-${edge.to}`} edge={edge} nodes={flow.nodes} index={i} />
<EdgePath key={`${edge.from}-${edge.to}-${i}`} edge={edge} nodes={flow.nodes} index={i} />
))}
{flow.nodes.map((node, i) => (
<motion.g
key={node.id}
data-node-id={node.id}
data-node-label={node.label}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06, duration: 0.3 }}