diff --git a/app/components/deepfield/ShowcaseCard.tsx b/app/components/deepfield/ShowcaseCard.tsx new file mode 100644 index 0000000..2781c3e --- /dev/null +++ b/app/components/deepfield/ShowcaseCard.tsx @@ -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(null); + const wrapRef = useRef(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 = ( +
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 + } + > + {/* 제너러티브 텍스처 (정적) */} +