From b0e4f4d38ea42c1a327c7d7a1cb4cab9c8d3535c Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 22 Apr 2025 20:52:39 +0200 Subject: [PATCH] feat(playground): add extra info --- messages/en.json | 75 +++ messages/sk.json | 75 +++ src/app/[locale]/playground/page.tsx | 37 +- src/components/algorithm-explanation.tsx | 357 +++++++++++ .../animate-ui/motion-highlight.tsx | 586 ++++++++++++++++++ src/components/animate-ui/tabs.tsx | 282 +++++++++ src/components/sorting-visualizer.tsx | 42 +- src/components/ui/card.tsx | 92 +++ 8 files changed, 1537 insertions(+), 9 deletions(-) create mode 100644 src/components/algorithm-explanation.tsx create mode 100644 src/components/animate-ui/motion-highlight.tsx create mode 100644 src/components/animate-ui/tabs.tsx create mode 100644 src/components/ui/card.tsx diff --git a/messages/en.json b/messages/en.json index 52de43e..d6dc946 100644 --- a/messages/en.json +++ b/messages/en.json @@ -70,5 +70,80 @@ "current": "Current Element", "sorted": "Sorted", "length": "Length:" + }, + "algorithm-explanation": { + "stats": { + "title": "Statistics", + "iterations": "Iterations", + "swaps": "Swaps" + }, + "complexity": { + "title": "Complexity Analysis", + "case": "Case", + "best": "Best", + "average": "Average", + "worst": "Worst", + "time": "Time", + "space": "Space" + }, + "explanation": { + "title": "How It Works", + "steps": "Steps", + "cases": "Cases", + "code": "Code" + }, + "bubble": { + "title": "Bubble Sort", + "description": "A simple comparison-based algorithm that repeatedly steps through the list, compares adjacent elements, and swaps them if they are in the wrong order.", + "steps": [ + "Start at the beginning of the array", + "Compare adjacent elements. If the first is greater than the second, swap them", + "Move to the next pair of elements and repeat the comparison and swap if necessary", + "After one complete pass, the largest element will be at the end. Repeat the process for the remaining elements" + ], + "best-case": "When the array is already sorted, bubble sort makes only one pass through the array with no swaps.", + "average-case": "For random arrays, bubble sort still requires quadratic time.", + "worst-case": "When the array is sorted in reverse order, bubble sort must make the maximum number of comparisons and swaps." + }, + "selection": { + "title": "Selection Sort", + "description": "A simple in-place comparison sorting algorithm that divides the input list into two parts: a sorted sublist and an unsorted sublist.", + "steps": [ + "Find the minimum element in the unsorted part of the array", + "Swap it with the element at the beginning of the unsorted part", + "Move the boundary between the sorted and unsorted parts one element to the right", + "Repeat until the entire array is sorted" + ], + "best-case": "Selection sort always performs the same number of comparisons regardless of the input array's order.", + "average-case": "For random arrays, selection sort requires quadratic time.", + "worst-case": "Selection sort always performs the same number of comparisons and swaps regardless of the input array's order." + }, + "insertion": { + "title": "Insertion Sort", + "description": "A simple sorting algorithm that builds the final sorted array one item at a time, similar to how you might sort playing cards in your hand.", + "steps": [ + "Start with the second element (assume the first element is already sorted)", + "Compare the current element with the previous elements", + "If the previous element is greater than the current element, move the previous element one position ahead", + "Repeat until the correct position for the current element is found, then insert it" + ], + "best-case": "When the array is already sorted, insertion sort makes only one comparison per element.", + "average-case": "For random arrays, insertion sort requires quadratic time.", + "worst-case": "When the array is sorted in reverse order, insertion sort must shift each element to the beginning of the array." + }, + "quicksort": { + "title": "Quick Sort", + "description": "An efficient, divide-and-conquer sorting algorithm that works by selecting a 'pivot' element and partitioning the array around the pivot.", + "steps": [ + "Choose a pivot element from the array", + "Partition the array: items less than the pivot go to the left, items greater go to the right", + "The pivot is now in its final sorted position", + "Recursively apply the above steps to the sub-arrays on the left and right of the pivot", + "The base case is when a sub-array has 0 or 1 elements (already sorted)" + ], + "best-case": "When the pivot always divides the array into nearly equal halves, quick sort achieves its best performance.", + "average-case": "For random arrays, quick sort is very efficient with O(n log n) time complexity.", + "worst-case": "When the pivot is always the smallest or largest element (e.g., in already sorted arrays), quick sort degrades to O(n²) performance." + } } } diff --git a/messages/sk.json b/messages/sk.json index b908896..3d946de 100644 --- a/messages/sk.json +++ b/messages/sk.json @@ -71,5 +71,80 @@ "current": "Aktuálny prvok", "sorted": "Vytriedené", "length": "Dĺžka" + }, + "algorithm-explanation": { + "stats": { + "title": "Štatistiky", + "iterations": "Iterácie", + "swaps": "Výmeny" + }, + "complexity": { + "title": "Analýza zložitosti", + "case": "Prípad", + "best": "Najlepší", + "average": "Priemerný", + "worst": "Najhorší", + "time": "Čas", + "space": "Priestor" + }, + "explanation": { + "title": "Ako to funguje", + "steps": "Kroky", + "cases": "Prípady", + "code": "Kód" + }, + "bubble": { + "title": "Bubble Sort", + "description": "Jednoduchý porovnávací algoritmus, ktorý opakovane prechádza zoznamom, porovnáva susedné prvky a vymieňa ich, ak sú v nesprávnom poradí.", + "steps": [ + "Začnite na začiatku poľa", + "Porovnajte susedné prvky. Ak je prvý väčší ako druhý, vymeňte ich", + "Prejdite na ďalší pár prvkov a opakujte porovnanie a výmenu, ak je to potrebné", + "Po jednom úplnom prechode bude najväčší prvok na konci. Opakujte proces pre zostávajúce prvky" + ], + "best-case": "Keď je pole už zoradené, bubble sort urobí iba jeden prechod poľom bez výmen.", + "average-case": "Pre náhodné polia bubble sort stále vyžaduje kvadratický čas.", + "worst-case": "Keď je pole zoradené v opačnom poradí, bubble sort musí urobiť maximálny počet porovnaní a výmen." + }, + "selection": { + "title": "Selection Sort", + "description": "Jednoduchý porovnávací algoritmus triedenia na mieste, ktorý rozdeľuje vstupný zoznam na dve časti: zoradenú časť a nezoradenú časť.", + "steps": [ + "Nájdite minimálny prvok v nezotriedenej časti poľa", + "Vymeňte ho s prvkom na začiatku nezotriedenej časti", + "Posuňte hranicu medzi zotriedenou a nezotriedenou časťou o jeden prvok doprava", + "Opakujte, kým nie je celé pole zotriedené" + ], + "best-case": "Selection sort vždy vykonáva rovnaký počet porovnaní bez ohľadu na poradie vstupného poľa.", + "average-case": "Pre náhodné polia selection sort vyžaduje kvadratický čas.", + "worst-case": "Selection sort vždy vykonáva rovnaký počet porovnaní a výmen bez ohľadu na poradie vstupného poľa." + }, + "insertion": { + "title": "Insertion Sort", + "description": "Jednoduchý triediaci algoritmus, ktorý buduje konečné zoradené pole po jednom prvku, podobne ako by ste mohli triediť hracie karty v ruke.", + "steps": [ + "Začnite s druhým prvkom (predpokladajte, že prvý prvok je už zotriedený)", + "Porovnajte aktuálny prvok s predchádzajúcimi prvkami", + "Ak je predchádzajúci prvok väčší ako aktuálny prvok, posuňte predchádzajúci prvok o jednu pozíciu dopredu", + "Opakujte, kým sa nenájde správna pozícia pre aktuálny prvok, potom ho vložte" + ], + "best-case": "Keď je pole už zoradené, insertion sort robí iba jedno porovnanie na prvok.", + "average-case": "Pre náhodné polia insertion sort vyžaduje kvadratický čas.", + "worst-case": "Keď je pole zoradené v opačnom poradí, insertion sort musí posunúť každý prvok na začiatok poľa." + }, + "quicksort": { + "title": "Quick Sort", + "description": "Efektívny algoritmus triedenia typu rozdeľuj a panuj, ktorý funguje výberom 'pivotného' prvku a rozdelením poľa okolo pivotu.", + "steps": [ + "Vyberte pivotný prvok z poľa", + "Rozdeľte pole: prvky menšie ako pivot idú doľava, väčšie doprava", + "Pivot je teraz na svojej konečnej zotriedenej pozícii", + "Rekurzívne aplikujte vyššie uvedené kroky na podpolia naľavo a napravo od pivotu", + "Základný prípad je, keď podpole má 0 alebo 1 prvok (už zotriedené)" + ], + "best-case": "Keď pivot vždy rozdelí pole na takmer rovnaké polovice, quick sort dosahuje najlepší výkon.", + "average-case": "Pre náhodné polia je quick sort veľmi efektívny s časovou zložitosťou O(n log n).", + "worst-case": "Keď je pivot vždy najmenší alebo najväčší prvok (napr. v už zoradených poliach), výkon quick sortu sa zhoršuje na O(n²)." + } } } diff --git a/src/app/[locale]/playground/page.tsx b/src/app/[locale]/playground/page.tsx index 3df85f5..7d3cead 100644 --- a/src/app/[locale]/playground/page.tsx +++ b/src/app/[locale]/playground/page.tsx @@ -1,13 +1,42 @@ +"use client"; + +import AlgorithmExplanation from "@/components/algorithm-explanation"; import { HexagonBackground } from "@/components/animate-ui/hexagon-background"; import SortingVisualizer from "@/components/sorting-visualizer"; +import { useState } from "react"; export default function Playground() { + const [algorithm, setAlgorithm] = useState< + "bubble" | "selection" | "insertion" | "quicksort" + >("bubble"); + const [iterations, setIterations] = useState(0); + const [swaps, setSwaps] = useState(0); + return ( -
- +
+
+ + +
+ +
+
-
- +
+ +
+ +
); diff --git a/src/components/algorithm-explanation.tsx b/src/components/algorithm-explanation.tsx new file mode 100644 index 0000000..99568b1 --- /dev/null +++ b/src/components/algorithm-explanation.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { + Tabs, + TabsContent, + TabsContents, + TabsList, + TabsTrigger, +} from "@/components/animate-ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +interface AlgorithmExplanationProps { + algorithm: "bubble" | "selection" | "insertion" | "quicksort"; + iterations: number; + swaps: number; + className?: string; +} + +export default function AlgorithmExplanation({ + algorithm, + iterations, + swaps, + className, +}: AlgorithmExplanationProps) { + const t = useTranslations("algorithm-explanation"); + + const algorithmData = { + bubble: { + title: t("bubble.title"), + description: t("bubble.description"), + steps: [ + t("bubble.steps.0"), + t("bubble.steps.1"), + t("bubble.steps.2"), + t("bubble.steps.3"), + ], + timeComplexity: { + best: "O(n)", + average: "O(n²)", + worst: "O(n²)", + }, + spaceComplexity: "O(1)", + bestCase: t("bubble.best-case"), + averageCase: t("bubble.average-case"), + worstCase: t("bubble.worst-case"), + }, + selection: { + title: t("selection.title"), + description: t("selection.description"), + steps: [ + t("selection.steps.0"), + t("selection.steps.1"), + t("selection.steps.2"), + t("selection.steps.3"), + ], + timeComplexity: { + best: "O(n²)", + average: "O(n²)", + worst: "O(n²)", + }, + spaceComplexity: "O(1)", + bestCase: t("selection.best-case"), + averageCase: t("selection.average-case"), + worstCase: t("selection.worst-case"), + }, + insertion: { + title: t("insertion.title"), + description: t("insertion.description"), + steps: [ + t("insertion.steps.0"), + t("insertion.steps.1"), + t("insertion.steps.2"), + t("insertion.steps.3"), + ], + timeComplexity: { + best: "O(n)", + average: "O(n²)", + worst: "O(n²)", + }, + spaceComplexity: "O(1)", + bestCase: t("insertion.best-case"), + averageCase: t("insertion.average-case"), + worstCase: t("insertion.worst-case"), + }, + quicksort: { + title: t("quicksort.title"), + description: t("quicksort.description"), + steps: [ + t("quicksort.steps.0"), + t("quicksort.steps.1"), + t("quicksort.steps.2"), + t("quicksort.steps.3"), + t("quicksort.steps.4"), + ], + timeComplexity: { + best: "O(n log n)", + average: "O(n log n)", + worst: "O(n²)", + }, + spaceComplexity: "O(log n)", + bestCase: t("quicksort.best-case"), + averageCase: t("quicksort.average-case"), + worstCase: t("quicksort.worst-case"), + }, + }; + + const currentAlgorithm = algorithmData[algorithm]; + + return ( +
+ + + {currentAlgorithm.title} + {currentAlgorithm.description} + + +
+ {/* Stats Section */} +
+

{t("stats.title")}

+ +
+
+
+ {t("stats.iterations")} +
+
+ {iterations} +
+
+
+
+ {t("stats.swaps")} +
+
{swaps}
+
+
+ +
+

{t("complexity.title")}

+
+
+
{t("complexity.case")}
+
{t("complexity.best")}
+
{t("complexity.average")}
+
{t("complexity.worst")}
+
+
+
{t("complexity.time")}
+
+ {currentAlgorithm.timeComplexity.best} +
+
+ {currentAlgorithm.timeComplexity.average} +
+
+ {currentAlgorithm.timeComplexity.worst} +
+
+
+
{t("complexity.space")}
+
+ {currentAlgorithm.spaceComplexity} +
+
+
+
+
+ + {/* Explanation Section */} +
+

+ {t("explanation.title")} +

+ + + + + {t("explanation.steps")} + + + {t("explanation.cases")} + + + {t("explanation.code")} + + + + + +
    + {currentAlgorithm.steps.map((step, index) => ( +
  1. {step}
  2. + ))} +
+
+ + +
+
+

+ {t("complexity.best")} ( + {currentAlgorithm.timeComplexity.best}) +

+

+ {currentAlgorithm.bestCase} +

+
+
+

+ {t("complexity.average")} ( + {currentAlgorithm.timeComplexity.average}) +

+

+ {currentAlgorithm.averageCase} +

+
+
+

+ {t("complexity.worst")} ( + {currentAlgorithm.timeComplexity.worst}) +

+

+ {currentAlgorithm.worstCase} +

+
+
+
+ + +
+                      {getAlgorithmCode(algorithm)}
+                    
+
+
+
+
+
+
+
+
+ ); +} + +function getAlgorithmCode(algorithm: string): string { + switch (algorithm) { + case "bubble": + return `function bubbleSort(arr) { + const n = arr.length; + + for (let i = 0; i < n - 1; i++) { + let swapped = false; + + for (let j = 0; j < n - i - 1; j++) { + if (arr[j] > arr[j + 1]) { + // Swap elements + [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; + swapped = true; + } + } + + // If no swapping occurred in this pass, + // array is sorted + if (!swapped) break; + } + + return arr; +}`; + case "selection": + return `function selectionSort(arr) { + const n = arr.length; + + for (let i = 0; i < n - 1; i++) { + // Find minimum element in unsorted part + let minIndex = i; + + for (let j = i + 1; j < n; j++) { + if (arr[j] < arr[minIndex]) { + minIndex = j; + } + } + + // Swap found minimum with first element + if (minIndex !== i) { + [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]; + } + } + + return arr; +}`; + case "insertion": + return `function insertionSort(arr) { + const n = arr.length; + + for (let i = 1; i < n; i++) { + // Store current element to be inserted + let key = arr[i]; + let j = i - 1; + + // Move elements greater than key + // to one position ahead + while (j >= 0 && arr[j] > key) { + arr[j + 1] = arr[j]; + j--; + } + + // Insert the key at correct position + arr[j + 1] = key; + } + + return arr; +}`; + case "quicksort": + return `function quickSort(arr, low = 0, high = arr.length - 1) { + if (low < high) { + // Find pivot element such that + // elements smaller than pivot are on the left + // elements greater than pivot are on the right + const pivotIndex = partition(arr, low, high); + + // Recursively sort elements before and after pivot + quickSort(arr, low, pivotIndex - 1); + quickSort(arr, pivotIndex + 1, high); + } + + return arr; +} + +function partition(arr, low, high) { + // Choose rightmost element as pivot + const pivot = arr[high]; + let i = low - 1; + + // Compare each element with pivot + for (let j = low; j < high; j++) { + if (arr[j] <= pivot) { + i++; + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + } + + // Place pivot in its final position + [arr[i + 1], arr[high]] = [arr[high], arr[i + 1]]; + return i + 1; +}`; + default: + return ""; + } +} diff --git a/src/components/animate-ui/motion-highlight.tsx b/src/components/animate-ui/motion-highlight.tsx new file mode 100644 index 0000000..680a43c --- /dev/null +++ b/src/components/animate-ui/motion-highlight.tsx @@ -0,0 +1,586 @@ +"use client"; + +import { AnimatePresence, motion, type Transition } from "motion/react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +type MotionHighlightMode = "children" | "parent"; + +type Bounds = { + top: number; + left: number; + width: number; + height: number; +}; + +type MotionHighlightContextType = { + mode: MotionHighlightMode; + activeValue: string | null; + setActiveValue: (value: string | null) => void; + setBounds: (bounds: DOMRect) => void; + clearBounds: () => void; + id: string; + hover: boolean; + className?: string; + activeClassName?: string; + setActiveClassName: (className: string) => void; + transition?: Transition; + disabled?: boolean; + enabled?: boolean; + exitDelay?: number; + forceUpdateBounds?: boolean; +}; + +const MotionHighlightContext = React.createContext< + MotionHighlightContextType | undefined +>(undefined); + +const useMotionHighlight = (): MotionHighlightContextType => { + const context = React.useContext(MotionHighlightContext); + if (!context) { + throw new Error( + "useMotionHighlight must be used within a MotionHighlightProvider", + ); + } + return context; +}; + +type BaseMotionHighlightProps = { + mode?: MotionHighlightMode; + value?: string | null; + defaultValue?: string | null; + onValueChange?: (value: string | null) => void; + className?: string; + transition?: Transition; + hover?: boolean; + disabled?: boolean; + enabled?: boolean; + exitDelay?: number; +}; + +type ParentModeMotionHighlightProps = { + boundsOffset?: Partial; + containerClassName?: string; + forceUpdateBounds?: boolean; +}; + +type ControlledParentModeMotionHighlightProps = BaseMotionHighlightProps & + ParentModeMotionHighlightProps & { + mode: "parent"; + controlledItems: true; + children: React.ReactNode; + }; + +type ControlledChildrenModeMotionHighlightProps = BaseMotionHighlightProps & { + mode?: "children" | undefined; + controlledItems: true; + children: React.ReactNode; +}; + +type UncontrolledParentModeMotionHighlightProps = BaseMotionHighlightProps & + ParentModeMotionHighlightProps & { + mode: "parent"; + controlledItems?: false; + itemsClassName?: string; + children: React.ReactElement | React.ReactElement[]; + }; + +type UncontrolledChildrenModeMotionHighlightProps = BaseMotionHighlightProps & { + mode?: "children"; + controlledItems?: false; + itemsClassName?: string; + children: React.ReactElement | React.ReactElement[]; +}; + +type MotionHighlightProps = React.ComponentProps<"div"> & + ( + | ControlledParentModeMotionHighlightProps + | ControlledChildrenModeMotionHighlightProps + | UncontrolledParentModeMotionHighlightProps + | UncontrolledChildrenModeMotionHighlightProps + ); + +function MotionHighlight({ ref, ...props }: MotionHighlightProps) { + const { + children, + value, + defaultValue, + onValueChange, + className, + transition = { type: "spring", stiffness: 350, damping: 35 }, + hover = false, + enabled = true, + controlledItems, + disabled = false, + exitDelay = 0.2, + mode = "children", + } = props; + + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); + + const [activeValue, setActiveValue] = React.useState( + value ?? defaultValue ?? null, + ); + const [boundsState, setBoundsState] = React.useState(null); + const [activeClassNameState, setActiveClassNameState] = + React.useState(""); + + const safeSetActiveValue = React.useCallback( + (id: string | null) => { + setActiveValue(prev => (prev === id ? prev : id)); + if (id !== activeValue) onValueChange?.(id); + }, + [activeValue, onValueChange], + ); + + const safeSetBounds = React.useCallback( + (bounds: DOMRect) => { + if (!localRef.current) return; + + const boundsOffset = (props as ParentModeMotionHighlightProps) + ?.boundsOffset ?? { + top: 0, + left: 0, + width: 0, + height: 0, + }; + + const containerRect = localRef.current.getBoundingClientRect(); + const newBounds: Bounds = { + top: bounds.top - containerRect.top + (boundsOffset.top ?? 0), + left: bounds.left - containerRect.left + (boundsOffset.left ?? 0), + width: bounds.width + (boundsOffset.width ?? 0), + height: bounds.height + (boundsOffset.height ?? 0), + }; + + setBoundsState(prev => { + if ( + prev && + prev.top === newBounds.top && + prev.left === newBounds.left && + prev.width === newBounds.width && + prev.height === newBounds.height + ) { + return prev; + } + return newBounds; + }); + }, + // biome-ignore lint/correctness/useExhaustiveDependencies: Not needed with React Compiler (https://github.com/biomejs/biome/issues/5293) + [props], + ); + + const clearBounds = React.useCallback(() => { + setBoundsState(prev => (prev === null ? prev : null)); + }, []); + + React.useEffect(() => { + if (value !== undefined) setActiveValue(value); + else if (defaultValue !== undefined) setActiveValue(defaultValue); + }, [value, defaultValue]); + + const id = React.useId(); + + React.useEffect(() => { + if (mode !== "parent") return; + const container = localRef.current; + if (!container) return; + + const onScroll = () => { + if (!activeValue) return; + const activeEl = container.querySelector( + `[data-value="${activeValue}"][data-highlight="true"]`, + ); + if (activeEl) safeSetBounds(activeEl.getBoundingClientRect()); + }; + + container.addEventListener("scroll", onScroll, { passive: true }); + return () => container.removeEventListener("scroll", onScroll); + }, [mode, activeValue, safeSetBounds]); + + const render = React.useCallback( + (children: React.ReactNode) => { + if (mode === "parent") { + return ( +
+ + {boundsState && ( + + )} + + {children} +
+ ); + } + + return children; + }, + [ + mode, + // biome-ignore lint/correctness/useExhaustiveDependencies: Not needed with React Compiler (https://github.com/biomejs/biome/issues/5293) + props, + boundsState, + transition, + exitDelay, + className, + activeClassNameState, + ], + ); + + return ( + + {enabled + ? controlledItems + ? render(children) + : render( + React.Children.map(children, (child, index) => ( + + {child} + + )), + ) + : children} + + ); +} + +function getNonOverridingDataAttributes( + element: React.ReactElement, + dataAttributes: Record, +): Record { + return Object.keys(dataAttributes).reduce>( + (acc, key) => { + if ((element.props as Record)[key] === undefined) { + acc[key] = dataAttributes[key]; + } + return acc; + }, + {}, + ); +} + +type ExtendedChildProps = React.ComponentProps<"div"> & { + id?: string; + ref?: React.Ref; + "data-active"?: string; + "data-value"?: string; + "data-disabled"?: boolean; + "data-highlight"?: boolean; + "data-slot"?: string; +}; + +type MotionHighlightItemProps = React.ComponentProps<"div"> & { + children: React.ReactElement; + id?: string; + value?: string; + className?: string; + transition?: Transition; + activeClassName?: string; + disabled?: boolean; + exitDelay?: number; + asChild?: boolean; + forceUpdateBounds?: boolean; +}; + +function MotionHighlightItem({ + ref, + children, + id, + value, + className, + transition, + disabled = false, + activeClassName, + exitDelay, + asChild = false, + forceUpdateBounds, + ...props +}: MotionHighlightItemProps) { + const itemId = React.useId(); + const { + activeValue, + setActiveValue, + mode, + setBounds, + clearBounds, + hover, + enabled, + className: contextClassName, + transition: contextTransition, + id: contextId, + disabled: contextDisabled, + exitDelay: contextExitDelay, + forceUpdateBounds: contextForceUpdateBounds, + setActiveClassName, + } = useMotionHighlight(); + + const element = children as React.ReactElement; + const childValue = + id ?? value ?? element.props?.["data-value"] ?? element.props?.id ?? itemId; + const isActive = activeValue === childValue; + const isDisabled = disabled === undefined ? contextDisabled : disabled; + const itemTransition = transition ?? contextTransition; + + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); + + React.useEffect(() => { + if (mode !== "parent") return; + let rafId: number; + let previousBounds: Bounds | null = null; + const shouldUpdateBounds = + forceUpdateBounds === true || + (contextForceUpdateBounds && forceUpdateBounds !== false); + + const updateBounds = () => { + if (!localRef.current) return; + + const bounds = localRef.current.getBoundingClientRect(); + + if (shouldUpdateBounds) { + if ( + previousBounds && + previousBounds.top === bounds.top && + previousBounds.left === bounds.left && + previousBounds.width === bounds.width && + previousBounds.height === bounds.height + ) { + rafId = requestAnimationFrame(updateBounds); + return; + } + previousBounds = bounds; + rafId = requestAnimationFrame(updateBounds); + } + + setBounds(bounds); + }; + + if (isActive) { + updateBounds(); + setActiveClassName(activeClassName ?? ""); + } else if (!activeValue) clearBounds(); + + if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId); + }, [ + mode, + isActive, + activeValue, + setBounds, + clearBounds, + activeClassName, + setActiveClassName, + forceUpdateBounds, + contextForceUpdateBounds, + ]); + + if (!React.isValidElement(children)) return children; + + const dataAttributes = { + "data-active": isActive ? "true" : "false", + "aria-selected": isActive, + "data-disabled": isDisabled, + "data-value": childValue, + "data-highlight": true, + }; + + const commonHandlers = hover + ? { + onMouseEnter: (e: React.MouseEvent) => { + setActiveValue(childValue); + element.props.onMouseEnter?.(e); + }, + onMouseLeave: (e: React.MouseEvent) => { + setActiveValue(null); + element.props.onMouseLeave?.(e); + }, + } + : { + onClick: (e: React.MouseEvent) => { + setActiveValue(childValue); + element.props.onClick?.(e); + }, + }; + + if (asChild) { + if (mode === "children") { + return React.cloneElement( + element, + { + key: childValue, + ref: localRef, + className: cn("relative", element.props.className), + ...getNonOverridingDataAttributes(element, { + ...dataAttributes, + "data-slot": "motion-highlight-item-container", + }), + ...commonHandlers, + ...props, + }, + <> + + {isActive && !isDisabled && ( + + )} + + +
+ {children} +
+ , + ); + } + + return React.cloneElement(element, { + ref: localRef, + ...getNonOverridingDataAttributes(element, { + ...dataAttributes, + "data-slot": "motion-highlight-item", + }), + ...commonHandlers, + }); + } + + return enabled ? ( +
+ {mode === "children" && ( + + {isActive && !isDisabled && ( + + )} + + )} + + {React.cloneElement(element, { + className: cn("relative z-[1]", element.props.className), + ...getNonOverridingDataAttributes(element, { + ...dataAttributes, + "data-slot": "motion-highlight-item", + }), + })} +
+ ) : ( + children + ); +} + +export { + MotionHighlight, + MotionHighlightItem, + useMotionHighlight, + type MotionHighlightProps, + type MotionHighlightItemProps, +}; diff --git a/src/components/animate-ui/tabs.tsx b/src/components/animate-ui/tabs.tsx new file mode 100644 index 0000000..66a6e47 --- /dev/null +++ b/src/components/animate-ui/tabs.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { type HTMLMotionProps, motion, type Transition } from "motion/react"; +import * as React from "react"; +import { + MotionHighlight, + MotionHighlightItem, +} from "@/components/animate-ui/motion-highlight"; +import { cn } from "@/lib/utils"; + +type TabsContextType = { + activeValue: string; + handleValueChange: (value: string) => void; + registerTrigger: (value: string, node: HTMLElement | null) => void; +}; + +const TabsContext = React.createContext(undefined); + +const useTabs = (): TabsContextType => { + const context = React.useContext(TabsContext); + if (!context) { + throw new Error("useTabs must be used within a TabsProvider"); + } + return context; +}; + +type BaseTabsProps = React.ComponentProps<"div"> & { + children: React.ReactNode; +}; + +type UnControlledTabsProps = BaseTabsProps & { + defaultValue?: string; + value?: never; + onValueChange?: never; +}; + +type ControlledTabsProps = BaseTabsProps & { + value: string; + onValueChange?: (value: string) => void; + defaultValue?: never; +}; + +type TabsProps = UnControlledTabsProps | ControlledTabsProps; + +function Tabs({ + defaultValue, + value, + onValueChange, + children, + className, + ...props +}: TabsProps) { + const [activeValue, setActiveValue] = React.useState(defaultValue); + const triggersRef = React.useRef(new Map()); + const initialSet = React.useRef(false); + const isControlled = value !== undefined; + + React.useEffect(() => { + if ( + !isControlled && + activeValue === undefined && + triggersRef.current.size > 0 && + !initialSet.current + ) { + const firstTab = Array.from(triggersRef.current.keys())[0]; + setActiveValue(firstTab); + initialSet.current = true; + } + }, [activeValue, isControlled]); + + const registerTrigger = (value: string, node: HTMLElement | null) => { + if (node) { + triggersRef.current.set(value, node); + if (!isControlled && activeValue === undefined && !initialSet.current) { + setActiveValue(value); + initialSet.current = true; + } + } else { + triggersRef.current.delete(value); + } + }; + + const handleValueChange = (val: string) => { + if (!isControlled) setActiveValue(val); + else onValueChange?.(val); + }; + + return ( + +
+ {children} +
+
+ ); +} + +type TabsListProps = React.ComponentProps<"div"> & { + children: React.ReactNode; + activeClassName?: string; + transition?: Transition; +}; + +function TabsList({ + children, + className, + activeClassName, + transition = { + type: "spring", + stiffness: 200, + damping: 25, + }, + ...props +}: TabsListProps) { + const { activeValue } = useTabs(); + + return ( + +
+ {children} +
+
+ ); +} + +type TabsTriggerProps = HTMLMotionProps<"button"> & { + value: string; + children: React.ReactNode; +}; + +function TabsTrigger({ + ref, + value, + children, + className, + ...props +}: TabsTriggerProps) { + const { activeValue, handleValueChange, registerTrigger } = useTabs(); + + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as HTMLButtonElement); + + React.useEffect(() => { + registerTrigger(value, localRef.current); + return () => registerTrigger(value, null); + }, [value, registerTrigger]); + + return ( + + handleValueChange(value)} + data-state={activeValue === value ? "active" : "inactive"} + className={cn( + "z-[1] inline-flex size-full cursor-pointer items-center justify-center whitespace-nowrap rounded-sm px-2 py-1 font-medium text-sm ring-offset-background transition-transform focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground", + className, + )} + {...props} + > + {children} + + + ); +} + +type TabsContentsProps = React.ComponentProps<"div"> & { + children: React.ReactNode; + transition?: Transition; +}; + +function TabsContents({ + children, + className, + transition = { + type: "spring", + stiffness: 300, + damping: 30, + bounce: 0, + restDelta: 0.01, + }, + ...props +}: TabsContentsProps) { + const { activeValue } = useTabs(); + const childrenArray = React.Children.toArray(children); + const activeIndex = childrenArray.findIndex( + (child): child is React.ReactElement<{ value: string }> => + React.isValidElement(child) && + typeof child.props === "object" && + child.props !== null && + "value" in child.props && + child.props.value === activeValue, + ); + + return ( +
+ + {childrenArray.map((child, index) => ( +
+ {child} +
+ ))} +
+
+ ); +} + +type TabsContentProps = HTMLMotionProps<"div"> & { + value: string; + children: React.ReactNode; +}; + +function TabsContent({ + children, + value, + className, + ...props +}: TabsContentProps) { + const { activeValue } = useTabs(); + const isActive = activeValue === value; + return ( + + {children} + + ); +} + +export { + Tabs, + TabsList, + TabsTrigger, + TabsContents, + TabsContent, + useTabs, + type TabsContextType, + type TabsProps, + type TabsListProps, + type TabsTriggerProps, + type TabsContentsProps, + type TabsContentProps, +}; diff --git a/src/components/sorting-visualizer.tsx b/src/components/sorting-visualizer.tsx index 44ce661..1422f41 100644 --- a/src/components/sorting-visualizer.tsx +++ b/src/components/sorting-visualizer.tsx @@ -25,8 +25,18 @@ type BarData = { }; export default function SortingVisualizer({ + algorithm, + setAlgorithm, + setIterations, + setSwaps, className, }: { + algorithm: "bubble" | "selection" | "insertion" | "quicksort"; + setAlgorithm: ( + algorithm: "bubble" | "selection" | "insertion" | "quicksort", + ) => void; + setIterations: (val: ((prev: number) => number) | number) => void; + setSwaps: (val: ((prev: number) => number) | number) => void; className?: string; }) { const t = useTranslations("visualizer"); @@ -45,10 +55,6 @@ export default function SortingVisualizer({ // A ref is needed because the the state in useState would be encapsulated in the sorting algorithm closures const speedRef = useRef(speed); - const [algorithm, setAlgorithm] = useState< - "bubble" | "selection" | "insertion" | "quicksort" - >("bubble"); - const svgRef = useRef(null); const animationFrameId = useRef(null); @@ -250,6 +256,7 @@ export default function SortingVisualizer({ // Mark elements being compared workingData[j]!.state = "comparing"; workingData[j + 1]!.state = "comparing"; + setIterations(prev => prev + 1); setData([...workingData]); await sleep(getDelay()); @@ -263,6 +270,7 @@ export default function SortingVisualizer({ // Update visualization setData([...workingData]); + setSwaps(prev => prev + 1); await sleep(getDelay()); } @@ -320,6 +328,7 @@ export default function SortingVisualizer({ let minIdx = i; workingData[i]!.state = "comparing"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); // Find the minimum element in the unsorted part @@ -332,6 +341,7 @@ export default function SortingVisualizer({ // Mark current element being compared workingData[j]!.state = "comparing"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); if (workingData[j]!.value < workingData[minIdx]!.value) { @@ -366,6 +376,7 @@ export default function SortingVisualizer({ // Update visualization setData([...workingData]); + setSwaps(prev => prev + 1); await sleep(getDelay()); } @@ -414,6 +425,7 @@ export default function SortingVisualizer({ // Mark the first element as sorted initially workingData[0]!.state = "sorted"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); // Main insertion sort loop @@ -431,6 +443,7 @@ export default function SortingVisualizer({ // Mark current element workingData[i]!.state = "comparing"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); // Find the position to insert the current element @@ -456,6 +469,8 @@ export default function SortingVisualizer({ // Update visualization with the shift setData([...workingData]); + setIterations(prev => prev + 1); + setSwaps(prev => prev + 1); await sleep(getDelay()); } @@ -537,6 +552,7 @@ export default function SortingVisualizer({ // Mark current element being compared workingData[j]!.state = "comparing"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); if (workingData[j]!.value < pivotValue) { @@ -554,6 +570,7 @@ export default function SortingVisualizer({ workingData[j] = temp; setData([...workingData]); + setSwaps(prev => prev + 1); await sleep(getDelay()); } } @@ -578,6 +595,7 @@ export default function SortingVisualizer({ // Update visualization setData([...workingData]); + setSwaps(prev => prev + 1); await sleep(getDelay()); // Reset states except for the pivot position @@ -600,11 +618,13 @@ export default function SortingVisualizer({ workingData[left]!.state = "comparing"; workingData[mid]!.state = "comparing"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); if (workingData[left]!.value > workingData[mid]!.value) { const temp = workingData[left]!; workingData[left] = workingData[mid]!; workingData[mid] = temp; + setSwaps(prev => prev + 1); } workingData[left]!.state = "default"; workingData[mid]!.state = "default"; @@ -614,11 +634,13 @@ export default function SortingVisualizer({ workingData[left]!.state = "comparing"; workingData[right]!.state = "comparing"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); if (workingData[left]!.value > workingData[right]!.value) { const temp = workingData[left]!; workingData[left] = workingData[right]!; workingData[right] = temp; + setSwaps(prev => prev + 1); } workingData[left]!.state = "default"; workingData[right]!.state = "default"; @@ -628,11 +650,13 @@ export default function SortingVisualizer({ workingData[mid]!.state = "comparing"; workingData[right]!.state = "comparing"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); if (workingData[mid]!.value > workingData[right]!.value) { const temp = workingData[mid]!; workingData[mid] = workingData[right]!; workingData[right] = temp; + setSwaps(prev => prev + 1); } workingData[mid]!.state = "default"; workingData[right]!.state = "default"; @@ -642,12 +666,14 @@ export default function SortingVisualizer({ workingData[mid]!.state = "comparing"; workingData[right]!.state = "comparing"; setData([...workingData]); + setIterations(prev => prev + 1); await sleep(getDelay()); const temp = workingData[mid]!; workingData[mid] = workingData[right]!; workingData[right] = temp; workingData[mid]!.state = "default"; workingData[right]!.state = "default"; + setSwaps(prev => prev + 1); setData([...workingData]); await sleep(getDelay()); @@ -678,6 +704,8 @@ export default function SortingVisualizer({ setIsSorting(false); setIsPaused(false); + setIterations(0); + setSwaps(0); // Reset to original data with default states const resetData = originalData.map(item => ({ @@ -701,6 +729,8 @@ export default function SortingVisualizer({ setIsSorting(false); setIsPaused(false); + setIterations(0); + setSwaps(0); // Generate new data generateRandomData(arrayLength); @@ -803,7 +833,9 @@ export default function SortingVisualizer({