LogoLoop 컴포넌트를 추가해 카테고리별(언어/프레임워크/인프라/도구) 4줄 가로 스크롤 캐러셀로 skill 로고를 표시. simpleicons CDN을 사용하며 매칭 안 되는 항목(APScheduler, KIS Open API)은 텍스트 칩으로 자동 fallback. 편집 모드에서는 기존 칩 UI를 유지해 편집·삭제 가능. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
323 lines
11 KiB
JavaScript
323 lines
11 KiB
JavaScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import './LogoLoop.css';
|
|
|
|
const ANIMATION_CONFIG = {
|
|
SMOOTH_TAU: 0.25,
|
|
MIN_COPIES: 2,
|
|
COPY_HEADROOM: 2,
|
|
};
|
|
|
|
const toCssLength = (value) =>
|
|
typeof value === 'number' ? `${value}px` : value ?? undefined;
|
|
|
|
const cx = (...parts) => parts.filter(Boolean).join(' ');
|
|
|
|
function useResizeObserver(callback, elements, deps) {
|
|
useEffect(() => {
|
|
if (!window.ResizeObserver) {
|
|
const handler = () => callback();
|
|
window.addEventListener('resize', handler);
|
|
callback();
|
|
return () => window.removeEventListener('resize', handler);
|
|
}
|
|
const observers = elements.map((ref) => {
|
|
if (!ref.current) return null;
|
|
const observer = new ResizeObserver(callback);
|
|
observer.observe(ref.current);
|
|
return observer;
|
|
});
|
|
callback();
|
|
return () => {
|
|
observers.forEach((o) => o?.disconnect());
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, deps);
|
|
}
|
|
|
|
function useImageLoader(seqRef, onLoad, deps) {
|
|
useEffect(() => {
|
|
const images = seqRef.current?.querySelectorAll('img') ?? [];
|
|
if (images.length === 0) {
|
|
onLoad();
|
|
return;
|
|
}
|
|
let remaining = images.length;
|
|
const handleLoad = () => {
|
|
remaining -= 1;
|
|
if (remaining === 0) onLoad();
|
|
};
|
|
images.forEach((img) => {
|
|
if (img.complete) {
|
|
handleLoad();
|
|
} else {
|
|
img.addEventListener('load', handleLoad, { once: true });
|
|
img.addEventListener('error', handleLoad, { once: true });
|
|
}
|
|
});
|
|
return () => {
|
|
images.forEach((img) => {
|
|
img.removeEventListener('load', handleLoad);
|
|
img.removeEventListener('error', handleLoad);
|
|
});
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, deps);
|
|
}
|
|
|
|
function useAnimationLoop(trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical) {
|
|
const rafRef = useRef(null);
|
|
const lastTsRef = 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;
|
|
track.style.transform = isVertical
|
|
? `translate3d(0, ${-offsetRef.current}px, 0)`
|
|
: `translate3d(${-offsetRef.current}px, 0, 0)`;
|
|
}
|
|
|
|
if (prefersReduced) {
|
|
track.style.transform = 'translate3d(0, 0, 0)';
|
|
return () => {
|
|
lastTsRef.current = null;
|
|
};
|
|
}
|
|
|
|
const animate = (ts) => {
|
|
if (lastTsRef.current === null) lastTsRef.current = ts;
|
|
const dt = Math.max(0, ts - lastTsRef.current) / 1000;
|
|
lastTsRef.current = ts;
|
|
|
|
const target = isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
|
|
const easing = 1 - Math.exp(-dt / ANIMATION_CONFIG.SMOOTH_TAU);
|
|
velocityRef.current += (target - velocityRef.current) * easing;
|
|
|
|
if (seqSize > 0) {
|
|
let next = offsetRef.current + velocityRef.current * dt;
|
|
next = ((next % seqSize) + seqSize) % seqSize;
|
|
offsetRef.current = next;
|
|
track.style.transform = isVertical
|
|
? `translate3d(0, ${-offsetRef.current}px, 0)`
|
|
: `translate3d(${-offsetRef.current}px, 0, 0)`;
|
|
}
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
};
|
|
|
|
rafRef.current = requestAnimationFrame(animate);
|
|
return () => {
|
|
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
|
|
rafRef.current = null;
|
|
lastTsRef.current = null;
|
|
};
|
|
}, [trackRef, targetVelocity, seqWidth, seqHeight, isHovered, hoverSpeed, isVertical]);
|
|
}
|
|
|
|
export default function LogoLoop({
|
|
logos,
|
|
speed = 60,
|
|
direction = 'left',
|
|
width = '100%',
|
|
logoHeight = 36,
|
|
gap = 32,
|
|
pauseOnHover = true,
|
|
hoverSpeed,
|
|
fadeOut = true,
|
|
fadeOutColor,
|
|
scaleOnHover = true,
|
|
ariaLabel = 'Skill 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);
|
|
const dirMul = isVertical
|
|
? direction === 'up'
|
|
? 1
|
|
: -1
|
|
: direction === 'left'
|
|
? 1
|
|
: -1;
|
|
const speedMul = speed < 0 ? -1 : 1;
|
|
return magnitude * dirMul * speedMul;
|
|
}, [speed, direction, isVertical]);
|
|
|
|
const updateDimensions = useCallback(() => {
|
|
const containerWidth = containerRef.current?.clientWidth ?? 0;
|
|
const seqRect = seqRef.current?.getBoundingClientRect?.();
|
|
const sw = seqRect?.width ?? 0;
|
|
const sh = seqRect?.height ?? 0;
|
|
if (isVertical) {
|
|
const parentH = containerRef.current?.parentElement?.clientHeight ?? 0;
|
|
if (containerRef.current && parentH > 0) {
|
|
const h = Math.ceil(parentH);
|
|
if (containerRef.current.style.height !== `${h}px`)
|
|
containerRef.current.style.height = `${h}px`;
|
|
}
|
|
if (sh > 0) {
|
|
setSeqHeight(Math.ceil(sh));
|
|
const viewport = containerRef.current?.clientHeight ?? parentH ?? sh;
|
|
const copies = Math.ceil(viewport / sh) + ANIMATION_CONFIG.COPY_HEADROOM;
|
|
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
|
|
}
|
|
} else if (sw > 0) {
|
|
setSeqWidth(Math.ceil(sw));
|
|
const copies = Math.ceil(containerWidth / sw) + ANIMATION_CONFIG.COPY_HEADROOM;
|
|
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copies));
|
|
}
|
|
}, [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 cssVars = useMemo(() => ({
|
|
'--logoloop-gap': `${gap}px`,
|
|
'--logoloop-logoHeight': `${logoHeight}px`,
|
|
...(fadeOutColor && { '--logoloop-fadeColor': fadeOutColor }),
|
|
}), [gap, logoHeight, fadeOutColor]);
|
|
|
|
const containerStyle = useMemo(() => ({
|
|
width: isVertical
|
|
? toCssLength(width) === '100%'
|
|
? undefined
|
|
: toCssLength(width)
|
|
: toCssLength(width) ?? '100%',
|
|
...cssVars,
|
|
...style,
|
|
}), [width, cssVars, style, isVertical]);
|
|
|
|
const handleEnter = useCallback(() => {
|
|
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
|
|
}, [effectiveHoverSpeed]);
|
|
const handleLeave = useCallback(() => {
|
|
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
|
|
}, [effectiveHoverSpeed]);
|
|
|
|
const renderItem = (item, key) => {
|
|
const isNode = 'node' in item;
|
|
const inner = isNode ? (
|
|
<span
|
|
className={cx('logoloop__node', scaleOnHover && 'logoloop__node--scale')}
|
|
aria-hidden={!!item.href && !item.ariaLabel}
|
|
>
|
|
{item.node}
|
|
</span>
|
|
) : (
|
|
<img
|
|
className={cx('logoloop__img', scaleOnHover && 'logoloop__img--scale')}
|
|
src={item.src}
|
|
srcSet={item.srcSet}
|
|
sizes={item.sizes}
|
|
width={item.width}
|
|
height={item.height}
|
|
alt={item.alt ?? ''}
|
|
title={item.title}
|
|
loading="lazy"
|
|
decoding="async"
|
|
draggable={false}
|
|
/>
|
|
);
|
|
const itemAriaLabel = isNode ? item.ariaLabel ?? item.title : item.alt ?? item.title;
|
|
const wrapper = item.href ? (
|
|
<a
|
|
className="logoloop__link"
|
|
href={item.href}
|
|
aria-label={itemAriaLabel || 'logo link'}
|
|
target="_blank"
|
|
rel="noreferrer noopener"
|
|
>
|
|
{inner}
|
|
</a>
|
|
) : (
|
|
inner
|
|
);
|
|
return (
|
|
<li
|
|
className={cx('logoloop__item', scaleOnHover && 'logoloop__item--scalable')}
|
|
key={key}
|
|
role="listitem"
|
|
>
|
|
{wrapper}
|
|
</li>
|
|
);
|
|
};
|
|
|
|
const lists = useMemo(
|
|
() =>
|
|
Array.from({ length: copyCount }, (_, i) => (
|
|
<ul
|
|
className={cx('logoloop__list', isVertical && 'logoloop__list--vertical')}
|
|
key={`copy-${i}`}
|
|
role="list"
|
|
aria-hidden={i > 0}
|
|
ref={i === 0 ? seqRef : undefined}
|
|
>
|
|
{logos.map((it, idx) => renderItem(it, `${i}-${idx}`))}
|
|
</ul>
|
|
)),
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[copyCount, logos, isVertical, scaleOnHover],
|
|
);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={cx('logoloop', isVertical && 'logoloop--vertical', className)}
|
|
style={containerStyle}
|
|
role="region"
|
|
aria-label={ariaLabel}
|
|
>
|
|
{fadeOut && (
|
|
<>
|
|
<div
|
|
aria-hidden
|
|
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--top' : 'logoloop__fade--left')}
|
|
/>
|
|
<div
|
|
aria-hidden
|
|
className={cx('logoloop__fade', isVertical ? 'logoloop__fade--bottom' : 'logoloop__fade--right')}
|
|
/>
|
|
</>
|
|
)}
|
|
<div
|
|
className={cx('logoloop__track', isVertical && 'logoloop__track--vertical')}
|
|
ref={trackRef}
|
|
onMouseEnter={handleEnter}
|
|
onMouseLeave={handleLeave}
|
|
>
|
|
{lists}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|