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

@@ -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 }}