feat(deepfield): 쇼케이스 카드·그리드 (시드 제너러티브 타일 + 호버 시차)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
347
app/components/deepfield/ShowcaseCard.tsx
Normal file
347
app/components/deepfield/ShowcaseCard.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { ShowcaseSlot } from '@/lib/showcase';
|
||||
|
||||
interface Props {
|
||||
slot: ShowcaseSlot;
|
||||
size?: 'feature' | 'standard';
|
||||
index: number;
|
||||
}
|
||||
|
||||
// ───────────────────────── 시드 PRNG (결정적) ─────────────────────────
|
||||
|
||||
/** slug → 32bit 정수 시드 (cyrb-ish 해시) */
|
||||
function hashSlug(s: string): number {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** mulberry32 — 시드 하나로 결정적 난수열 생성 */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0;
|
||||
a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
/** #rrggbb → {r,g,b} */
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
const h = hex.replace('#', '');
|
||||
return {
|
||||
r: parseInt(h.slice(0, 2), 16),
|
||||
g: parseInt(h.slice(2, 4), 16),
|
||||
b: parseInt(h.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────────── 패턴 3종 (정적 텍스처) ─────────────────────────
|
||||
|
||||
type RGB = { r: number; g: number; b: number };
|
||||
|
||||
/** 1. 등고선 — accent 동심 곡선 흐름 */
|
||||
function drawContour(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
rng: () => number,
|
||||
c: RGB,
|
||||
) {
|
||||
const cx = w * (0.3 + rng() * 0.4);
|
||||
const cy = h * (0.3 + rng() * 0.4);
|
||||
const rings = 9 + Math.floor(rng() * 5);
|
||||
const step = Math.max(w, h) / rings;
|
||||
const wobble = 0.12 + rng() * 0.1;
|
||||
const phase = rng() * Math.PI * 2;
|
||||
ctx.lineWidth = 1.25;
|
||||
for (let i = 1; i <= rings; i++) {
|
||||
const baseR = i * step;
|
||||
const alpha = 0.1 + (1 - i / rings) * 0.08; // 0.10~0.18
|
||||
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},${alpha})`;
|
||||
ctx.beginPath();
|
||||
const segs = 72;
|
||||
for (let s = 0; s <= segs; s++) {
|
||||
const a = (s / segs) * Math.PI * 2;
|
||||
const r = baseR * (1 + Math.sin(a * 3 + phase + i * 0.6) * wobble);
|
||||
const x = cx + Math.cos(a) * r;
|
||||
const y = cy + Math.sin(a) * r * 0.82;
|
||||
if (s === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/** 2. 격자 왜곡 — 미세하게 휘어진 그리드 라인 */
|
||||
function drawGrid(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
rng: () => number,
|
||||
c: RGB,
|
||||
) {
|
||||
const cols = 7 + Math.floor(rng() * 4);
|
||||
const rows = 5 + Math.floor(rng() * 4);
|
||||
const amp = 6 + rng() * 10;
|
||||
const fx = 1.5 + rng() * 2;
|
||||
const fy = 1.5 + rng() * 2;
|
||||
const phase = rng() * Math.PI * 2;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeStyle = `rgba(${c.r},${c.g},${c.b},0.14)`;
|
||||
// 세로선
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const baseX = (i / cols) * w;
|
||||
ctx.beginPath();
|
||||
for (let j = 0; j <= 40; j++) {
|
||||
const ny = j / 40;
|
||||
const y = ny * h;
|
||||
const x = baseX + Math.sin(ny * Math.PI * fy + phase + i * 0.4) * amp;
|
||||
if (j === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
// 가로선
|
||||
for (let i = 0; i <= rows; i++) {
|
||||
const baseY = (i / rows) * h;
|
||||
ctx.beginPath();
|
||||
for (let j = 0; j <= 40; j++) {
|
||||
const nx = j / 40;
|
||||
const x = nx * w;
|
||||
const y = baseY + Math.cos(nx * Math.PI * fx + phase + i * 0.4) * amp;
|
||||
if (j === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/** 3. 도트 필드 — 밀도 그라데이션 도트 */
|
||||
function drawDots(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
w: number,
|
||||
h: number,
|
||||
rng: () => number,
|
||||
c: RGB,
|
||||
) {
|
||||
const gap = 16 + rng() * 8;
|
||||
const ox = rng() * Math.PI * 2;
|
||||
const oy = rng() * Math.PI * 2;
|
||||
// 밀도 중심 (가장 진한 지점)
|
||||
const dcx = w * (0.2 + rng() * 0.6);
|
||||
const dcy = h * (0.2 + rng() * 0.6);
|
||||
const maxD = Math.hypot(w, h);
|
||||
for (let y = gap * 0.5; y < h; y += gap) {
|
||||
for (let x = gap * 0.5; x < w; x += gap) {
|
||||
const jx = Math.sin((y / gap) * 1.3 + ox) * 2.5;
|
||||
const jy = Math.cos((x / gap) * 1.3 + oy) * 2.5;
|
||||
const px = x + jx;
|
||||
const py = y + jy;
|
||||
const d = Math.hypot(px - dcx, py - dcy) / maxD; // 0~~1
|
||||
const density = 1 - d; // 중심부 1
|
||||
const alpha = 0.06 + density * 0.16; // 0.06~0.22
|
||||
const radius = 0.9 + density * 1.8;
|
||||
ctx.fillStyle = `rgba(${c.r},${c.g},${c.b},${alpha.toFixed(3)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const PATTERNS = [drawContour, drawGrid, drawDots];
|
||||
|
||||
// ───────────────────────── 컴포넌트 ─────────────────────────
|
||||
|
||||
export default function ShowcaseCard({ slot, size = 'standard', index }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
// 슬러그 시드 — 결정적 패턴 선택/파라미터
|
||||
const seed = hashSlug(slot.slug);
|
||||
const patternType = seed % PATTERNS.length;
|
||||
|
||||
// 캔버스 1회 정적 렌더 (DPR 반영, 애니메이션 루프 없음)
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
if (w === 0 || h === 0) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
canvas.width = Math.round(w * dpr);
|
||||
canvas.height = Math.round(h * dpr);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const rng = mulberry32(seed);
|
||||
const c = hexToRgb(slot.accent);
|
||||
PATTERNS[patternType](ctx, w, h, rng, c);
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
// 컨테이너 리사이즈 시 재렌더 (정적 — 루프 아님)
|
||||
const ro = new ResizeObserver(() => draw());
|
||||
ro.observe(canvas);
|
||||
return () => ro.disconnect();
|
||||
}, [seed, patternType, slot.accent]);
|
||||
|
||||
// 호버 시차 — 리스너/rAF는 hover 중에만 가동 (상시 rAF 금지)
|
||||
useEffect(() => {
|
||||
if (!hovered) return;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||||
const canvas = canvasRef.current;
|
||||
const wrap = wrapRef.current;
|
||||
if (!canvas || !wrap) return;
|
||||
|
||||
let rafId = 0;
|
||||
let tx = 0;
|
||||
let ty = 0;
|
||||
|
||||
const apply = () => {
|
||||
rafId = 0;
|
||||
canvas.style.transform = `translate(${tx.toFixed(2)}px, ${ty.toFixed(2)}px)`;
|
||||
};
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = wrap.getBoundingClientRect();
|
||||
const nx = (e.clientX - rect.left) / rect.width - 0.5; // -0.5~0.5
|
||||
const ny = (e.clientY - rect.top) / rect.height - 0.5;
|
||||
tx = nx * 12; // ±6px
|
||||
ty = ny * 12;
|
||||
if (!rafId) rafId = requestAnimationFrame(apply);
|
||||
};
|
||||
|
||||
wrap.addEventListener('mousemove', onMove);
|
||||
return () => {
|
||||
wrap.removeEventListener('mousemove', onMove);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
canvas.style.transform = '';
|
||||
};
|
||||
}, [hovered]);
|
||||
|
||||
const isFeature = size === 'feature';
|
||||
const isLink = Boolean(slot.href);
|
||||
|
||||
// 타일 본체 (링크/div 공통)
|
||||
const tile = (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
data-index={index}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
className={[
|
||||
'group/card relative isolate h-full w-full overflow-hidden rounded-2xl',
|
||||
'ring-1 transition-[transform,box-shadow,border-color] duration-500',
|
||||
'[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]',
|
||||
'motion-safe:hover:scale-[1.03]',
|
||||
isLink ? 'cursor-pointer' : 'cursor-default',
|
||||
isFeature ? 'aspect-[16/10]' : 'aspect-[4/3]',
|
||||
].join(' ')}
|
||||
style={
|
||||
{
|
||||
'--card-accent': slot.accent,
|
||||
backgroundImage: `linear-gradient(135deg, ${slot.palette[0]}, ${slot.palette[1]})`,
|
||||
// 기본 보더는 다크 라인, hover 시 accent 점등 + 코너 글로우 (인라인 hover는 className으로)
|
||||
boxShadow: hovered
|
||||
? `0 0 0 1px ${slot.accent}, 0 18px 50px -20px ${slot.accent}66, inset 0 0 60px -30px ${slot.accent}80`
|
||||
: '0 0 0 1px var(--jsm-dark-line, rgba(148,163,184,0.14))',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{/* 제너러티브 텍스처 (정적) */}
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full will-change-transform"
|
||||
/>
|
||||
|
||||
{/* 하단 스크림 — 텍스트 가독성 */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 h-2/3"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(to top, rgba(7,13,26,0.92) 0%, rgba(7,13,26,0.55) 45%, transparent 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 텍스트 레이어 */}
|
||||
<div className="absolute inset-x-0 bottom-0 flex flex-col gap-1.5 p-5 sm:p-6">
|
||||
<span
|
||||
className="font-mono text-[11px] uppercase tracking-[0.18em]"
|
||||
style={{ color: slot.accent }}
|
||||
>
|
||||
{slot.label}
|
||||
</span>
|
||||
<h3
|
||||
className={[
|
||||
'font-bold leading-snug [word-break:keep-all]',
|
||||
isFeature ? 'text-xl sm:text-2xl' : 'text-lg',
|
||||
].join(' ')}
|
||||
style={{ color: 'var(--jsm-dark-ink, #f8fafc)' }}
|
||||
>
|
||||
{slot.title}
|
||||
</h3>
|
||||
<p
|
||||
className="line-clamp-1 text-sm [word-break:keep-all]"
|
||||
style={{ color: 'var(--jsm-dark-soft, #94a3b8)' }}
|
||||
>
|
||||
{slot.desc}
|
||||
</p>
|
||||
|
||||
{isLink && (
|
||||
<span
|
||||
className="mt-1 inline-flex items-center gap-1.5 text-[13px] font-medium transition-transform duration-500 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)] group-hover/card:translate-x-1"
|
||||
style={{ color: slot.accent }}
|
||||
>
|
||||
데모 보기
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="transition-transform duration-500 [transition-timing-function:cubic-bezier(0.16,1,0.3,1)] group-hover/card:translate-x-0.5"
|
||||
>
|
||||
<path
|
||||
d="M5 12h14M13 6l6 6-6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLink) {
|
||||
return (
|
||||
<Link href={slot.href!} aria-label={slot.title} className="block h-full w-full">
|
||||
{tile}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return tile;
|
||||
}
|
||||
59
app/components/deepfield/ShowcaseGrid.tsx
Normal file
59
app/components/deepfield/ShowcaseGrid.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ShowcaseSlot } from '@/lib/showcase';
|
||||
|
||||
import ScrollReveal from './ScrollReveal';
|
||||
import ShowcaseCard from './ShowcaseCard';
|
||||
|
||||
interface Props {
|
||||
slots: ShowcaseSlot[];
|
||||
variant: 'home' | 'full';
|
||||
}
|
||||
|
||||
/**
|
||||
* home: 6슬롯 비대칭 — 1번 feature(col-span-2 좌측) / 2·3 standard /
|
||||
* 4번 feature(col-span-2 우측 배치로 리듬 변화) / 5·6 standard.
|
||||
* 모바일은 1col 전부 standard.
|
||||
* full: 8슬롯 데스크톱 2col 균등(standard), 모바일 1col.
|
||||
*/
|
||||
export default function ShowcaseGrid({ slots, variant }: Props) {
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 md:gap-6">
|
||||
{slots.slice(0, 8).map((slot, i) => (
|
||||
<ScrollReveal key={slot.slug} delay={i * 80}>
|
||||
<ShowcaseCard slot={slot} size="standard" index={i} />
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// home — 6슬롯 (3col 그리드)
|
||||
const items = slots.slice(0, 6);
|
||||
|
||||
// 데스크톱 흐름 (3col):
|
||||
// row1: [0 feature span2][1 std]
|
||||
// row2: [2 std][3 std][?] — 3번을 col-start-1로 줄바꿈,
|
||||
// row3: [4 std][5 feature span2 우측] ← 4번 와이드를 우측에 두어 리듬 변화
|
||||
// col-start로 흐름을 고정해 비대칭 리듬을 결정적으로 만든다.
|
||||
const layout: Array<{ span: string; size: 'feature' | 'standard' }> = [
|
||||
{ span: 'md:col-span-2 md:col-start-1', size: 'feature' }, // 0 — 좌측 와이드
|
||||
{ span: 'md:col-span-1', size: 'standard' }, // 1 — 우측 1칸
|
||||
{ span: 'md:col-span-1 md:col-start-1', size: 'standard' }, // 2 — 다음 줄 시작
|
||||
{ span: 'md:col-span-1', size: 'standard' }, // 3
|
||||
{ span: 'md:col-span-1 md:col-start-1', size: 'standard' }, // 4 — 다음 줄 시작
|
||||
{ span: 'md:col-span-2', size: 'feature' }, // 5 — 우측 와이드 (리듬 변화)
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 md:grid-cols-3 md:gap-6">
|
||||
{items.map((slot, i) => {
|
||||
const cfg = layout[i] ?? { span: 'md:col-span-1', size: 'standard' as const };
|
||||
return (
|
||||
<ScrollReveal key={slot.slug} delay={i * 80} className={cfg.span}>
|
||||
<ShowcaseCard slot={slot} size={cfg.size} index={i} />
|
||||
</ScrollReveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user