mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-08-12 17:13:38 +08:00
feat: complete web curriculum visuals
This commit is contained in:
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user