feat(viz): two pointers narrative

This commit is contained in:
2025-08-24 15:30:46 +01:00
parent e5ebe7b188
commit 21227628fa
17 changed files with 2191 additions and 2 deletions

View File

@@ -0,0 +1,69 @@
"use client";
import { useVisualization } from "@/lib/visualizations/use-visualization";
import type { AlgorithmDefinition } from "@/lib/visualizations/types";
import { VisualizationContainer } from "../core/visualization-container";
import { ArrayView } from "../data-structures/array-view";
import { CalculationBubble } from "../primitives/calculation-bubble";
interface TwoPointersVisualizationProps {
algorithm: AlgorithmDefinition;
className?: string;
}
export function TwoPointersVisualization({
algorithm,
className,
}: TwoPointersVisualizationProps) {
const {
currentStep,
currentStepIndex,
totalSteps,
playback,
controls,
currentPhase,
progress,
} = useVisualization(algorithm);
const { dataState } = currentStep;
const mainArray = dataState.arrays[0];
const calculation = dataState.calculations[0] ?? null;
return (
<VisualizationContainer
title={algorithm.title}
pattern={algorithm.pattern}
code={algorithm.code}
currentLine={currentStep.codeLine}
highlightLines={currentStep.codeHighlightLines}
explanation={currentStep.explanation}
decision={currentStep.decision}
phase={currentPhase}
variables={dataState.variables}
currentStepIndex={currentStepIndex}
totalSteps={totalSteps}
isPlaying={playback.isPlaying}
speed={playback.speed}
controls={controls}
progress={progress}
className={className}
>
<div className="flex flex-col items-center">
{/* Fixed height area for calculation bubble */}
<div className="flex h-8 items-center justify-center">
<CalculationBubble calculation={calculation} />
</div>
{/* Array visualization with pointers */}
<div className="mt-2">
{mainArray && (
<ArrayView
array={mainArray}
pointers={dataState.pointers}
elementSize="md"
/>
)}
</div>
</div>
</VisualizationContainer>
);
}

View File

@@ -0,0 +1,111 @@
"use client";
import { useEffect, useRef } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { cn } from "@/lib/utils";
import type { AlgorithmCode } from "@/lib/visualizations/types";
interface CodePanelProps {
code: AlgorithmCode;
currentLine?: number;
highlightLines?: number[];
className?: string;
}
const LANGUAGE_MAP: Record<AlgorithmCode["language"], string> = {
typescript: "typescript",
javascript: "javascript",
python: "python",
};
export function CodePanel({
code,
currentLine,
highlightLines = [],
className,
}: CodePanelProps) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (currentLine && containerRef.current) {
const container = containerRef.current;
const lineHeight = 24;
const lineTop = (currentLine - 1) * lineHeight;
const containerHeight = container.clientHeight;
const scrollTarget = lineTop - containerHeight / 2 + lineHeight / 2;
container.scrollTo({
top: Math.max(0, scrollTarget),
behavior: "smooth",
});
}
}, [currentLine]);
const codeLines = code.code.trim().split("\n");
return (
<div
ref={containerRef}
className={cn(
"overflow-auto rounded-lg border border-[var(--border)] bg-[#282c34]",
className
)}
>
<div className="relative min-w-max">
{codeLines.map((line, i) => {
const lineNumber = i + 1;
const isCurrentLine = lineNumber === currentLine;
const isHighlighted = highlightLines.includes(lineNumber);
return (
<div
key={i}
className={cn(
"flex transition-colors duration-200",
isCurrentLine && "bg-[var(--primary)]/20",
isHighlighted && !isCurrentLine && "bg-[var(--primary)]/10"
)}
>
<span className="inline-block w-10 flex-shrink-0 select-none py-0.5 pr-3 text-right font-mono text-xs text-[var(--muted-foreground)]/60">
{lineNumber}
</span>
{isCurrentLine && (
<span
className="inline-block w-4 flex-shrink-0 py-0.5 font-mono text-xs text-[var(--primary)]"
aria-hidden="true"
>
</span>
)}
{!isCurrentLine && (
<span
className="inline-block w-4 flex-shrink-0"
aria-hidden="true"
/>
)}
<SyntaxHighlighter
language={LANGUAGE_MAP[code.language]}
style={oneDark}
customStyle={{
margin: 0,
padding: "2px 8px 2px 0",
background: "transparent",
fontSize: "0.875rem",
lineHeight: "1.5",
}}
codeTagProps={{
style: {
fontFamily: "var(--font-mono, monospace)",
},
}}
>
{line || " "}
</SyntaxHighlighter>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { DecisionCallout, VisualizationPhase } from "@/lib/visualizations/types";
interface ExplanationPanelProps {
explanation: string;
decision?: DecisionCallout;
phase: VisualizationPhase;
className?: string;
}
const PHASE_LABELS: Record<VisualizationPhase, string> = {
problem: "Problem",
intuition: "Intuition",
pattern: "Pattern",
code: "Code",
execution: "Execution",
};
const PHASE_COLORS: Record<VisualizationPhase, string> = {
problem: "bg-red-500/20 text-red-500",
intuition: "bg-amber-500/20 text-amber-500",
pattern: "bg-blue-500/20 text-blue-500",
code: "bg-[var(--primary)]/20 text-[var(--primary)]",
execution: "bg-green-500/20 text-green-500",
};
export function ExplanationPanel({
explanation,
decision,
phase,
className,
}: ExplanationPanelProps) {
return (
<div
className={cn(
"h-72 overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
className
)}
>
<div className="mb-3 flex items-center gap-2">
<span
className={cn(
"rounded-full px-2.5 py-0.5 text-xs font-medium",
PHASE_COLORS[phase]
)}
>
{PHASE_LABELS[phase]}
</span>
</div>
<AnimatePresence mode="wait">
<motion.div
key={explanation}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
>
<p className="text-base leading-relaxed text-[var(--foreground)]">
{explanation}
</p>
{decision && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="mt-4 rounded-lg border border-[var(--primary)]/30 bg-[var(--primary)]/5 p-4"
>
<div className="mb-2 text-sm font-medium text-[var(--primary)]">
Decision Point
</div>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<span className="font-medium text-[var(--muted-foreground)]">Q:</span>
<span className="text-[var(--foreground)]">{decision.question}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-[var(--muted-foreground)]">A:</span>
<span className="font-mono text-[var(--primary)]">{decision.answer}</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium text-[var(--muted-foreground)]"></span>
<span className="text-green-500">{decision.action}</span>
</div>
</div>
</motion.div>
)}
</motion.div>
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,267 @@
"use client";
import { cn } from "@/lib/utils";
import type {
PlaybackSpeed,
VisualizationControls,
} from "@/lib/visualizations/types";
interface StepControlsProps {
currentStepIndex: number;
totalSteps: number;
isPlaying: boolean;
speed: PlaybackSpeed;
controls: VisualizationControls;
progress: number;
className?: string;
}
const SPEED_OPTIONS: PlaybackSpeed[] = [0.5, 1, 2];
export function StepControls({
currentStepIndex,
totalSteps,
isPlaying,
speed,
controls,
progress,
className,
}: StepControlsProps) {
const isAtStart = currentStepIndex === 0;
const isAtEnd = currentStepIndex === totalSteps - 1;
return (
<div
className={cn(
"flex flex-col gap-3 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
className
)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<button
type="button"
onClick={controls.goToFirst}
disabled={isAtStart}
aria-label="Go to first step"
className={cn(
"rounded-md p-2 transition-colors",
"hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]",
"disabled:cursor-not-allowed disabled:opacity-40"
)}
>
<FirstIcon />
</button>
<button
type="button"
onClick={controls.stepBackward}
disabled={isAtStart}
aria-label="Previous step"
className={cn(
"rounded-md p-2 transition-colors",
"hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]",
"disabled:cursor-not-allowed disabled:opacity-40"
)}
>
<PreviousIcon />
</button>
<button
type="button"
onClick={controls.togglePlay}
aria-label={isPlaying ? "Pause" : "Play"}
className={cn(
"rounded-full bg-[var(--primary)] p-3 text-white transition-colors",
"hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-[var(--primary)] focus:ring-offset-2"
)}
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<button
type="button"
onClick={controls.stepForward}
disabled={isAtEnd}
aria-label="Next step"
className={cn(
"rounded-md p-2 transition-colors",
"hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]",
"disabled:cursor-not-allowed disabled:opacity-40"
)}
>
<NextIcon />
</button>
<button
type="button"
onClick={controls.goToLast}
disabled={isAtEnd}
aria-label="Go to last step"
className={cn(
"rounded-md p-2 transition-colors",
"hover:bg-[var(--muted)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]",
"disabled:cursor-not-allowed disabled:opacity-40"
)}
>
<LastIcon />
</button>
</div>
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--muted-foreground)]">
Step {currentStepIndex + 1} of {totalSteps}
</span>
<select
value={speed}
onChange={(e) =>
controls.setSpeed(parseFloat(e.target.value) as PlaybackSpeed)
}
aria-label="Playback speed"
className={cn(
"rounded-md border border-[var(--border)] bg-[var(--card)] px-2 py-1 text-sm",
"focus:border-[var(--primary)] focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
)}
>
{SPEED_OPTIONS.map((s) => (
<option key={s} value={s}>
{s}x
</option>
))}
</select>
</div>
</div>
<div className="relative h-2 w-full overflow-hidden rounded-full bg-[var(--muted)]">
<button
type="button"
className="absolute inset-0 w-full cursor-pointer"
aria-label="Jump to step"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const percent = x / rect.width;
const step = Math.round(percent * (totalSteps - 1));
controls.goToStep(step);
}}
>
<div
className="h-full bg-[var(--primary)] transition-all duration-200"
style={{ width: `${progress}%` }}
/>
</button>
</div>
</div>
);
}
function PlayIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M4.5 5.653c0-1.426 1.529-2.33 2.779-1.643l11.54 6.348c1.295.712 1.295 2.573 0 3.285L7.28 19.991c-1.25.687-2.779-.217-2.779-1.643V5.653z"
clipRule="evenodd"
/>
</svg>
);
}
function PauseIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M6.75 5.25a.75.75 0 01.75-.75H9a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H7.5a.75.75 0 01-.75-.75V5.25zm7.5 0A.75.75 0 0115 4.5h1.5a.75.75 0 01.75.75v13.5a.75.75 0 01-.75.75H15a.75.75 0 01-.75-.75V5.25z"
clipRule="evenodd"
/>
</svg>
);
}
function PreviousIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M7.72 12.53a.75.75 0 010-1.06l7.5-7.5a.75.75 0 111.06 1.06L9.31 12l6.97 6.97a.75.75 0 11-1.06 1.06l-7.5-7.5z"
clipRule="evenodd"
/>
</svg>
);
}
function NextIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M16.28 11.47a.75.75 0 010 1.06l-7.5 7.5a.75.75 0 01-1.06-1.06L14.69 12 7.72 5.03a.75.75 0 011.06-1.06l7.5 7.5z"
clipRule="evenodd"
/>
</svg>
);
}
function FirstIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M5.25 5.25a.75.75 0 01.75.75v12a.75.75 0 01-1.5 0V6a.75.75 0 01.75-.75zm6.22.53a.75.75 0 011.06 0l6 6a.75.75 0 010 1.06l-6 6a.75.75 0 01-1.06-1.06L16.19 12 11.47 6.84a.75.75 0 010-1.06z"
clipRule="evenodd"
transform="scale(-1, 1) translate(-24, 0)"
/>
</svg>
);
}
function LastIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M18.75 5.25a.75.75 0 01.75.75v12a.75.75 0 01-1.5 0V6a.75.75 0 01.75-.75zm-6.22.53a.75.75 0 00-1.06 0l-6 6a.75.75 0 000 1.06l6 6a.75.75 0 001.06-1.06L7.81 12l4.72-5.16a.75.75 0 000-1.06z"
clipRule="evenodd"
transform="scale(-1, 1) translate(-24, 0)"
/>
</svg>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { VariableState } from "@/lib/visualizations/types";
interface VariableInspectorProps {
variables: VariableState[];
className?: string;
}
export function VariableInspector({
variables,
className,
}: VariableInspectorProps) {
return (
<div
className={cn(
"h-40 overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--card)] p-4",
className
)}
>
<div className="mb-3 text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
Variables
</div>
<div className="flex min-h-10 flex-wrap gap-3">
<AnimatePresence mode="popLayout">
{variables.length === 0 && (
<span className="text-sm text-[var(--muted-foreground)]/50">
No variables yet
</span>
)}
{variables.map((variable) => (
<motion.div
key={variable.id}
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="flex items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--muted)] px-3 py-2"
>
<span className="font-mono text-sm text-[var(--muted-foreground)]">
{variable.name}
</span>
<span className="text-[var(--muted-foreground)]">=</span>
<motion.span
key={`${variable.id}-${variable.value}`}
initial={{ scale: 1.2, color: "var(--primary)" }}
animate={{ scale: 1, color: "var(--foreground)" }}
transition={{ duration: 0.3 }}
className="font-mono text-sm font-medium"
>
{String(variable.value)}
</motion.span>
{variable.previousValue !== undefined &&
variable.previousValue !== variable.value && (
<span className="ml-1 text-xs text-[var(--muted-foreground)] line-through">
{String(variable.previousValue)}
</span>
)}
{variable.derivation && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
({variable.derivation})
</span>
)}
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { cn } from "@/lib/utils";
import type { ReactNode } from "react";
import type {
AlgorithmCode,
AlgorithmPattern,
DecisionCallout,
PlaybackSpeed,
VariableState,
VisualizationControls,
VisualizationPhase,
} from "@/lib/visualizations/types";
import { CodePanel } from "./code-panel";
import { ExplanationPanel } from "./explanation-panel";
import { StepControls } from "./step-controls";
import { VariableInspector } from "./variable-inspector";
interface VisualizationContainerProps {
title: string;
pattern: AlgorithmPattern;
code: AlgorithmCode;
currentLine?: number;
highlightLines?: number[];
explanation: string;
decision?: DecisionCallout;
phase: VisualizationPhase;
variables: VariableState[];
currentStepIndex: number;
totalSteps: number;
isPlaying: boolean;
speed: PlaybackSpeed;
controls: VisualizationControls;
progress: number;
children: ReactNode;
className?: string;
}
export function VisualizationContainer({
title,
pattern,
code,
currentLine,
highlightLines,
explanation,
decision,
phase,
variables,
currentStepIndex,
totalSteps,
isPlaying,
speed,
controls,
progress,
children,
className,
}: VisualizationContainerProps) {
return (
<div
className={cn(
"flex flex-col gap-4 rounded-xl border border-[var(--border)] bg-[var(--card)] p-4",
className
)}
>
<header className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-xl font-semibold text-[var(--foreground)]">{title}</h2>
<span className="rounded-full bg-[var(--primary)]/20 px-3 py-1 text-sm font-medium text-[var(--primary)]">
{pattern.name}
</span>
</header>
<div className="grid gap-4 lg:grid-cols-[1fr_1.5fr]">
<div className="flex flex-col gap-4">
<CodePanel
code={code}
currentLine={currentLine}
highlightLines={highlightLines}
className="max-h-80 min-h-48"
/>
<VariableInspector variables={variables} />
</div>
<div className="flex flex-col gap-4">
<div
className="flex h-64 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--background)] p-4"
role="img"
aria-label="Algorithm visualization"
>
{children}
</div>
<ExplanationPanel
explanation={explanation}
decision={decision}
phase={phase}
/>
</div>
</div>
<StepControls
currentStepIndex={currentStepIndex}
totalSteps={totalSteps}
isPlaying={isPlaying}
speed={speed}
controls={controls}
progress={progress}
/>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { cn } from "@/lib/utils";
import type { ArrayState, PointerState } from "@/lib/visualizations/types";
import { ArrayElement } from "../primitives/array-element";
import { Pointer } from "../primitives/pointer";
interface ArrayViewProps {
array: ArrayState;
pointers: PointerState[];
showIndices?: boolean;
elementSize?: "sm" | "md" | "lg";
className?: string;
}
// Must match SIZE_CLASSES in array-element.tsx (Tailwind units × 4)
const ELEMENT_WIDTHS = {
sm: 40, // w-10 = 40px
md: 56, // w-14 = 56px
lg: 72, // w-18 = 72px
} as const;
const ELEMENT_GAPS = {
sm: 4,
md: 8,
lg: 12,
} as const;
export function ArrayView({
array,
pointers,
showIndices = true,
elementSize = "md",
className,
}: ArrayViewProps) {
const elementWidth = ELEMENT_WIDTHS[elementSize];
const gap = ELEMENT_GAPS[elementSize];
// Calculate total array width for proper pointer alignment
const totalWidth = array.elements.length * elementWidth + (array.elements.length - 1) * gap;
return (
<div className={cn("flex flex-col items-center", className)}>
{array.label && (
<span className="mb-2 text-sm font-medium text-[var(--muted-foreground)]">
{array.label}
</span>
)}
{/* Container that sizes to array width - ensures pointer/array alignment */}
<div className="relative" style={{ width: totalWidth }}>
{/* Pointers row */}
<div className="relative mb-1 h-10">
{pointers.map((pointer) => (
<Pointer
key={pointer.id}
pointer={pointer}
elementWidth={elementWidth}
gap={gap}
value={array.elements[pointer.index]?.value}
/>
))}
</div>
{/* Array elements */}
<div
className="flex"
style={{ gap: `${gap}px` }}
>
{array.elements.map((element) => (
<ArrayElement
key={`${array.id}-${element.index}`}
element={element}
size={elementSize}
showIndex={showIndices}
/>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
// Core components
export { VisualizationContainer } from "./core/visualization-container";
export { StepControls } from "./core/step-controls";
export { CodePanel } from "./core/code-panel";
export { ExplanationPanel } from "./core/explanation-panel";
export { VariableInspector } from "./core/variable-inspector";
// Primitives
export { ArrayElement } from "./primitives/array-element";
export { Pointer } from "./primitives/pointer";
export { CalculationBubble } from "./primitives/calculation-bubble";
// Data structures
export { ArrayView } from "./data-structures/array-view";
// Algorithm visualizations
export { TwoPointersVisualization } from "./algorithms/two-pointers";

View File

@@ -0,0 +1,56 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { ArrayElementState } from "@/lib/visualizations/types";
interface ArrayElementProps {
element: ArrayElementState;
size?: "sm" | "md" | "lg";
showIndex?: boolean;
className?: string;
}
const SIZE_CLASSES = {
sm: "w-10 h-10 text-sm",
md: "w-14 h-14 text-base",
lg: "w-18 h-18 text-lg",
} as const;
const STATE_CLASSES = {
normal: "bg-[var(--muted)] border-[var(--border)] text-[var(--foreground)]",
highlighted: "bg-[var(--primary)]/20 border-[var(--primary)] text-[var(--primary)]",
dimmed: "bg-[var(--muted)]/50 border-[var(--border)]/50 text-[var(--muted-foreground)] opacity-30",
success: "bg-green-500/20 border-green-500 text-green-500",
comparing: "bg-amber-500/20 border-amber-500 text-amber-500",
} as const;
export function ArrayElement({
element,
size = "md",
showIndex = true,
className,
}: ArrayElementProps) {
return (
<div className={cn("flex flex-col items-center gap-1", className)}>
<motion.div
layout
initial={false}
animate={{
scale: element.state === "highlighted" ? 1.05 : 1,
}}
transition={{ duration: 0.2 }}
className={cn(
"flex items-center justify-center rounded-lg border-2 font-mono font-medium transition-colors duration-200",
SIZE_CLASSES[size],
STATE_CLASSES[element.state]
)}
>
{element.value}
</motion.div>
{showIndex && (
<span className="text-xs text-[var(--muted-foreground)]">{element.index}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { CalculationState } from "@/lib/visualizations/types";
interface CalculationBubbleProps {
calculation: CalculationState | null;
className?: string;
}
export function CalculationBubble({
calculation,
className,
}: CalculationBubbleProps) {
return (
<AnimatePresence>
{calculation && (
<motion.div
initial={{ opacity: 0, scale: 0.8, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: -10 }}
transition={{ duration: 0.25, ease: "easeOut" }}
className={cn(
"inline-flex items-center gap-2 rounded-lg border border-[var(--primary)]/40 bg-[var(--primary)]/10 px-3 py-2 font-mono text-sm",
className
)}
>
<span className="text-[var(--muted-foreground)]">{calculation.expression}</span>
<span className="text-[var(--primary)]">=</span>
<span className="font-semibold text-[var(--primary)]">{calculation.result}</span>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { PointerState } from "@/lib/visualizations/types";
interface PointerProps {
pointer: PointerState;
elementWidth: number;
gap: number;
value?: number;
className?: string;
}
const COLOR_CLASSES = {
left: "text-blue-500 fill-blue-500",
right: "text-orange-500 fill-orange-500",
mid: "text-purple-500 fill-purple-500",
default: "text-blue-500 fill-blue-500",
} as const;
export function Pointer({
pointer,
elementWidth,
gap,
value,
className,
}: PointerProps) {
// Calculate center of element: index * (width + gap) + width / 2
const offset = pointer.index * (elementWidth + gap) + elementWidth / 2;
return (
<motion.div
initial={false}
animate={{ left: offset }}
transition={{
type: "spring",
stiffness: 300,
damping: 30,
}}
className={cn("absolute top-0 h-full", className)}
>
{/* Label - centered above the point */}
<span
className={cn(
"absolute left-0 top-0 -translate-x-1/2 whitespace-nowrap rounded-full px-2 py-0.5 text-xs font-medium",
COLOR_CLASSES[pointer.color]
)}
>
{pointer.name}
{pointer.showValue && value !== undefined && (
<span className="ml-1 font-mono">= {value}</span>
)}
</span>
{/* Arrow - centered at position */}
<svg
viewBox="0 0 24 24"
className={cn("absolute left-0 bottom-0 -translate-x-1/2 h-4 w-4", COLOR_CLASSES[pointer.color])}
aria-hidden="true"
>
<path d="M12 22L4 14h6V2h4v12h6L12 22z" />
</svg>
</motion.div>
);
}