diff --git a/apps/katanemo-www/public/logos/chase.svg b/apps/katanemo-www/public/logos/chase.svg new file mode 100644 index 00000000..40d2ae76 --- /dev/null +++ b/apps/katanemo-www/public/logos/chase.svg @@ -0,0 +1,68 @@ + + + + + diff --git a/apps/katanemo-www/public/logos/hp.svg b/apps/katanemo-www/public/logos/hp.svg new file mode 100644 index 00000000..e53232c8 --- /dev/null +++ b/apps/katanemo-www/public/logos/hp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/katanemo-www/public/logos/huggingface.svg b/apps/katanemo-www/public/logos/huggingface.svg new file mode 100644 index 00000000..7e254084 --- /dev/null +++ b/apps/katanemo-www/public/logos/huggingface.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/katanemo-www/public/logos/sandisk.svg b/apps/katanemo-www/public/logos/sandisk.svg new file mode 100644 index 00000000..d5fe1092 --- /dev/null +++ b/apps/katanemo-www/public/logos/sandisk.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/katanemo-www/public/logos/tmobile.svg b/apps/katanemo-www/public/logos/tmobile.svg new file mode 100644 index 00000000..6616be40 --- /dev/null +++ b/apps/katanemo-www/public/logos/tmobile.svg @@ -0,0 +1,165 @@ + + + +image/svg+xml + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/katanemo-www/src/app/page.tsx b/apps/katanemo-www/src/app/page.tsx index 263da803..eb3aa532 100644 --- a/apps/katanemo-www/src/app/page.tsx +++ b/apps/katanemo-www/src/app/page.tsx @@ -1,5 +1,6 @@ import Image from "next/image"; import Link from "next/link"; +import LogoSlider from "../components/LogoSlider"; export default function HomePage() { return ( @@ -23,25 +24,34 @@ export default function HomePage() { Bringing industry-leading research and open-source technologies to accelerate the development of AI agents.

+

+ Trusted by leading companies to deliver agents to production. +

+
Models Research - + + ↗ + Plano - Open Source Agent Infrastructure - + + ↗ +
- Move faster and more reliably by letting Katanemo do the heavy-lifting. + Move faster and more reliably by letting Katanemo do the + heavy-lifting.
-
React.ReactNode; + ariaLabel?: string; + className?: string; + style?: React.CSSProperties; +} + +const ANIMATION_CONFIG = { + SMOOTH_TAU: 0.25, + MIN_COPIES: 2, + COPY_HEADROOM: 2, +} as const; + +const toCssLength = (value?: number | string): string | undefined => + typeof value === "number" ? `${value}px` : (value ?? undefined); + +const cx = (...parts: Array) => + parts.filter(Boolean).join(" "); + +const isNodeItem = ( + item: LogoItem, +): item is Extract => "node" in item; + +const isImageItem = ( + item: LogoItem, +): item is Extract => "src" in item; + +const useResizeObserver = ( + callback: () => void, + elements: Array>, + dependencies: React.DependencyList, +) => { + useEffect(() => { + if (!window.ResizeObserver) { + const handleResize = () => callback(); + window.addEventListener("resize", handleResize); + callback(); + return () => window.removeEventListener("resize", handleResize); + } + + const observers: Array = []; + for (const ref of elements) { + if (!ref.current) { + observers.push(null); + continue; + } + const observer = new ResizeObserver(callback); + observer.observe(ref.current); + observers.push(observer); + } + + callback(); + + return () => { + for (const observer of observers) { + observer?.disconnect(); + } + }; + }, [callback, elements, ...elements, ...dependencies]); +}; + +const useImageLoader = ( + seqRef: React.RefObject, + onLoad: () => void, + dependencies: React.DependencyList, +) => { + useEffect(() => { + const images = seqRef.current?.querySelectorAll("img") ?? []; + + if (images.length === 0) { + onLoad(); + return; + } + + let remainingImages = images.length; + const handleImageLoad = () => { + remainingImages -= 1; + if (remainingImages === 0) { + onLoad(); + } + }; + + images.forEach((img) => { + const htmlImg = img as HTMLImageElement; + if (htmlImg.complete) { + handleImageLoad(); + } else { + htmlImg.addEventListener("load", handleImageLoad, { once: true }); + htmlImg.addEventListener("error", handleImageLoad, { once: true }); + } + }); + + return () => { + images.forEach((img) => { + img.removeEventListener("load", handleImageLoad); + img.removeEventListener("error", handleImageLoad); + }); + }; + }, [onLoad, seqRef, ...dependencies]); +}; + +const useAnimationLoop = ( + trackRef: React.RefObject, + targetVelocity: number, + seqWidth: number, + seqHeight: number, + isHovered: boolean, + hoverSpeed: number | undefined, + isVertical: boolean, +) => { + const rafRef = useRef(null); + const lastTimestampRef = useRef(null); + const offsetRef = useRef(0); + const velocityRef = useRef(0); + + useEffect(() => { + const track = trackRef.current; + if (!track) return; + + const prefersReduced = + typeof window !== "undefined" && + window.matchMedia && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + const seqSize = isVertical ? seqHeight : seqWidth; + + if (seqSize > 0) { + offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize; + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; + } + + if (prefersReduced) { + track.style.transform = isVertical + ? "translate3d(0, 0, 0)" + : "translate3d(0, 0, 0)"; + return () => { + lastTimestampRef.current = null; + }; + } + + const animate = (timestamp: number) => { + if (lastTimestampRef.current === null) { + lastTimestampRef.current = timestamp; + } + + const deltaTime = + Math.max(0, timestamp - lastTimestampRef.current) / 1000; + lastTimestampRef.current = timestamp; + + const target = + isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity; + + const easingFactor = + 1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU); + velocityRef.current += (target - velocityRef.current) * easingFactor; + + if (seqSize > 0) { + let nextOffset = offsetRef.current + velocityRef.current * deltaTime; + nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize; + offsetRef.current = nextOffset; + + const transformValue = isVertical + ? `translate3d(0, ${-offsetRef.current}px, 0)` + : `translate3d(${-offsetRef.current}px, 0, 0)`; + track.style.transform = transformValue; + } + + rafRef.current = requestAnimationFrame(animate); + }; + + rafRef.current = requestAnimationFrame(animate); + + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + lastTimestampRef.current = null; + }; + }, [ + trackRef, + targetVelocity, + seqWidth, + seqHeight, + isHovered, + hoverSpeed, + isVertical, + ]); +}; + +export const LogoLoop = React.memo( + ({ + logos, + speed = 120, + direction = "left", + width = "100%", + logoHeight = 28, + gap = 32, + pauseOnHover, + hoverSpeed, + fadeOut = false, + fadeOutColor, + scaleOnHover = false, + renderItem, + ariaLabel = "Partner logos", + className, + style, + }) => { + const containerRef = useRef(null); + const trackRef = useRef(null); + const seqRef = useRef(null); + + const [seqWidth, setSeqWidth] = useState(0); + const [seqHeight, setSeqHeight] = useState(0); + const [copyCount, setCopyCount] = useState( + ANIMATION_CONFIG.MIN_COPIES, + ); + const [isHovered, setIsHovered] = useState(false); + + const effectiveHoverSpeed = useMemo(() => { + if (hoverSpeed !== undefined) return hoverSpeed; + if (pauseOnHover === true) return 0; + if (pauseOnHover === false) return undefined; + return 0; + }, [hoverSpeed, pauseOnHover]); + + const isVertical = direction === "up" || direction === "down"; + + const targetVelocity = useMemo(() => { + const magnitude = Math.abs(speed); + let directionMultiplier: number; + if (isVertical) { + directionMultiplier = direction === "up" ? 1 : -1; + } else { + directionMultiplier = direction === "left" ? 1 : -1; + } + const speedMultiplier = speed < 0 ? -1 : 1; + return magnitude * directionMultiplier * speedMultiplier; + }, [speed, direction, isVertical]); + + const updateDimensions = useCallback(() => { + const containerWidth = containerRef.current?.clientWidth ?? 0; + const sequenceRect = seqRef.current?.getBoundingClientRect?.(); + const sequenceWidth = sequenceRect?.width ?? 0; + const sequenceHeight = sequenceRect?.height ?? 0; + if (isVertical) { + const parentHeight = + containerRef.current?.parentElement?.clientHeight ?? 0; + if (containerRef.current && parentHeight > 0) { + const targetHeight = Math.ceil(parentHeight); + if (containerRef.current.style.height !== `${targetHeight}px`) + containerRef.current.style.height = `${targetHeight}px`; + } + if (sequenceHeight > 0) { + setSeqHeight(Math.ceil(sequenceHeight)); + const viewport = + containerRef.current?.clientHeight ?? + parentHeight ?? + sequenceHeight; + const copiesNeeded = + Math.ceil(viewport / sequenceHeight) + + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + } else if (sequenceWidth > 0) { + setSeqWidth(Math.ceil(sequenceWidth)); + const copiesNeeded = + Math.ceil(containerWidth / sequenceWidth) + + ANIMATION_CONFIG.COPY_HEADROOM; + setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded)); + } + }, [isVertical]); + + useResizeObserver( + updateDimensions, + [containerRef, seqRef], + [logos, gap, logoHeight, isVertical], + ); + + useImageLoader(seqRef, updateDimensions, [ + logos, + gap, + logoHeight, + isVertical, + ]); + + useAnimationLoop( + trackRef, + targetVelocity, + seqWidth, + seqHeight, + isHovered, + effectiveHoverSpeed, + isVertical, + ); + + const cssVariables = useMemo( + () => + ({ + "--logoloop-gap": `${gap}px`, + "--logoloop-logoHeight": `${logoHeight}px`, + ...(fadeOutColor && { "--logoloop-fadeColor": fadeOutColor }), + }) as React.CSSProperties, + [gap, logoHeight, fadeOutColor], + ); + + const rootClasses = useMemo( + () => + cx( + "relative group", + isVertical + ? "overflow-hidden h-full inline-block" + : "overflow-x-hidden", + "[--logoloop-gap:32px]", + "[--logoloop-logoHeight:28px]", + "[--logoloop-fadeColorAuto:#ffffff]", + "dark:[--logoloop-fadeColorAuto:#0b0b0b]", + scaleOnHover && "py-[calc(var(--logoloop-logoHeight)*0.1)]", + className, + ), + [isVertical, scaleOnHover, className], + ); + + const handleMouseEnter = useCallback(() => { + if (effectiveHoverSpeed !== undefined) setIsHovered(true); + }, [effectiveHoverSpeed]); + const handleMouseLeave = useCallback(() => { + if (effectiveHoverSpeed !== undefined) setIsHovered(false); + }, [effectiveHoverSpeed]); + + const renderLogoItem = useCallback( + (item: LogoItem, key: React.Key) => { + if (renderItem) { + return ( +
  • + {renderItem(item, key)} +
  • + ); + } + + const content = isNodeItem(item) ? ( + + {item.node} + + ) : ( + {item.alt + ); + + const itemAriaLabel = isNodeItem(item) + ? (item.ariaLabel ?? item.title) + : (item.alt ?? item.title); + + const inner = item.href ? ( +
    + {content} + + ) : ( + content + ); + + return ( +
  • + {inner} +
  • + ); + }, + [isVertical, scaleOnHover, renderItem], + ); + + const logoLists = useMemo( + () => + Array.from({ length: copyCount }, (_, copyIndex) => ( + + )), + [copyCount, logos, renderLogoItem, isVertical], + ); + + const containerStyle = useMemo( + (): React.CSSProperties => ({ + width: isVertical + ? toCssLength(width) === "100%" + ? undefined + : toCssLength(width) + : (toCssLength(width) ?? "100%"), + ...cssVariables, + ...style, + }), + [width, cssVariables, style, isVertical], + ); + + return ( +
    + {fadeOut && + (isVertical ? ( + <> +
    +
    + + ) : ( + <> +
    +
    + + ))} + +
    + {logoLists} +
    +
    + ); + }, +); + +LogoLoop.displayName = "LogoLoop"; + +const logos: LogoItem[] = [ + { src: "/logos/chase.svg", alt: "Chase" }, + { src: "/logos/hp.svg", alt: "HP" }, + { src: "/logos/huggingface.svg", alt: "Hugging Face" }, + { src: "/logos/sandisk.svg", alt: "SanDisk" }, + { src: "/logos/tmobile.svg", alt: "T-Mobile" }, +]; + +export default function LogoSlider() { + return ( +
    + { + if (isImageItem(item)) { + return ( + {item.alt + ); + } + + return {item.node}; + }} + className="relative" + style={{ + WebkitMaskImage: + "linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)", + maskImage: + "linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)", + }} + /> +
    + ); +}