diff --git a/src/pages/effect-lab/DayCalc.css b/src/pages/effect-lab/DayCalc.css new file mode 100644 index 0000000..6edb999 --- /dev/null +++ b/src/pages/effect-lab/DayCalc.css @@ -0,0 +1,448 @@ +/* ── DayCalc ─────────────────────────────────────────────────────────────── */ + +.daycalc { + max-width: 860px; + margin: 0 auto; + padding: 32px 24px 64px; + display: flex; + flex-direction: column; + gap: 28px; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.daycalc__back { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: var(--text-dim); + text-decoration: none; + padding: 5px 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: transparent; + margin-bottom: 12px; + transition: color 0.2s, border-color 0.2s; +} + +.daycalc__back:hover { + color: var(--neon-cyan); + border-color: var(--line-bright); +} + +.daycalc__kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--accent-lab); + margin-bottom: 6px; +} + +.daycalc__header h1 { + font-size: 28px; + font-weight: 700; + color: var(--text-bright); + margin-bottom: 6px; +} + +.daycalc__desc { + font-size: 14px; + color: var(--text-dim); + margin: 0; +} + +/* ── Input Section ───────────────────────────────────────────────────────── */ + +.daycalc__input-section { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 28px; + display: flex; + flex-direction: column; + gap: 20px; +} + +.daycalc__date-row { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 16px; + align-items: center; +} + +.daycalc__date-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.daycalc__date-field label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); +} + +.daycalc__date-field input[type="date"] { + padding: 10px 14px; + font-size: 15px; + font-family: var(--font-display); + font-weight: 500; + color: var(--text-bright); + background: var(--bg-secondary); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + outline: none; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; + width: 100%; + color-scheme: dark; +} + +.daycalc__date-field input[type="date"]:focus { + border-color: var(--neon-cyan-dim); + box-shadow: 0 0 0 3px var(--neon-cyan-muted); +} + +.daycalc__date-fmt { + font-size: 12px; + color: var(--text-dim); + padding-left: 2px; +} + +.daycalc__arrow { + font-size: 22px; + font-weight: 300; + color: var(--text-muted); + text-align: center; + user-select: none; + padding-top: 20px; +} + +.daycalc__arrow .fwd { color: var(--neon-cyan); } +.daycalc__arrow .bwd { color: var(--neon-purple); } + +/* ── Presets ─────────────────────────────────────────────────────────────── */ + +.daycalc__presets { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.daycalc__presets-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.06em; + text-transform: uppercase; + margin-right: 4px; +} + +.daycalc__preset-btn { + padding: 5px 12px; + font-size: 12px; + font-weight: 500; + border: 1px solid var(--line); + border-radius: 20px; + background: transparent; + color: var(--text-dim); + cursor: pointer; + transition: border-color 0.18s, color 0.18s, background 0.18s; +} + +.daycalc__preset-btn:hover { + border-color: var(--neon-cyan-dim); + color: var(--neon-cyan); + background: var(--neon-cyan-muted); +} + +.daycalc__preset-btn--clear { + color: var(--text-muted); + border-color: transparent; +} + +.daycalc__preset-btn--clear:hover { + border-color: rgba(248, 113, 113, 0.4); + color: #f87171; + background: rgba(248, 113, 113, 0.08); +} + +/* ── Tabs ────────────────────────────────────────────────────────────────── */ + +.daycalc__tabs { + display: flex; + gap: 4px; + border-bottom: 1px solid var(--line); + padding-bottom: 0; +} + +.daycalc__tab { + padding: 8px 18px; + font-size: 13px; + font-weight: 600; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.18s, border-color 0.18s; +} + +.daycalc__tab:hover { + color: var(--text-bright); +} + +.daycalc__tab.is-active { + color: var(--accent-lab); + border-bottom-color: var(--accent-lab); +} + +/* ── Result Section ──────────────────────────────────────────────────────── */ + +.daycalc__result { + display: flex; + flex-direction: column; + gap: 20px; +} + +.daycalc__big-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; +} + +.daycalc__big-card { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 24px 20px; + text-align: center; + transition: border-color 0.2s; +} + +.daycalc__big-card--primary { + border-color: rgba(251, 191, 36, 0.3); + background: rgba(251, 191, 36, 0.04); +} + +.daycalc__big-num { + font-family: var(--font-display); + font-size: 40px; + font-weight: 700; + color: var(--text-bright); + line-height: 1; + margin-bottom: 4px; +} + +.daycalc__big-card--primary .daycalc__big-num { + color: var(--accent-lab); + text-shadow: 0 0 24px rgba(251, 191, 36, 0.4); +} + +.daycalc__big-label { + font-size: 16px; + font-weight: 600; + color: var(--text); + margin-bottom: 4px; +} + +.daycalc__big-sub { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* ── Breakdown ───────────────────────────────────────────────────────────── */ + +.daycalc__breakdown { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-md); + padding: 20px 24px; +} + +.daycalc__breakdown h3 { + font-size: 13px; + font-weight: 700; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 16px; +} + +.daycalc__breakdown-row { + display: flex; + gap: 32px; + align-items: baseline; + margin-bottom: 14px; +} + +.daycalc__breakdown-item { + display: flex; + align-items: baseline; + gap: 5px; +} + +.daycalc__breakdown-num { + font-family: var(--font-display); + font-size: 32px; + font-weight: 700; + color: var(--text-bright); +} + +.daycalc__breakdown-unit { + font-size: 14px; + color: var(--text-dim); + font-weight: 500; +} + +.daycalc__breakdown-summary { + font-size: 13px; + color: var(--text-muted); + border-top: 1px solid var(--line-subtle); + padding-top: 12px; + margin: 0; +} + +/* ── Milestones ──────────────────────────────────────────────────────────── */ + +.daycalc__milestones { + display: flex; + flex-direction: column; + gap: 16px; +} + +.daycalc__milestones-desc { + font-size: 13px; + color: var(--text-dim); + margin: 0; +} + +.daycalc__milestones-desc strong { + color: var(--text-bright); +} + +.daycalc__milestone-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.daycalc__milestone-row { + display: grid; + grid-template-columns: 100px 1fr auto; + align-items: center; + gap: 16px; + padding: 12px 16px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-sm); + transition: border-color 0.18s; +} + +.daycalc__milestone-row:hover { + border-color: rgba(251, 191, 36, 0.3); +} + +.daycalc__milestone-row.is-past { + opacity: 0.45; +} + +.daycalc__milestone-row.is-today { + border-color: var(--accent-lab); + background: rgba(251, 191, 36, 0.06); + opacity: 1; +} + +.daycalc__milestone-badge { + font-family: var(--font-display); + font-size: 14px; + font-weight: 700; + color: var(--accent-lab); +} + +.daycalc__milestone-row.is-past .daycalc__milestone-badge { + color: var(--text-muted); +} + +.daycalc__milestone-date { + font-size: 14px; + color: var(--text); +} + +.daycalc__milestone-dday { + font-size: 13px; + font-weight: 600; + color: var(--text-dim); + text-align: right; + white-space: nowrap; +} + +.daycalc__milestone-row.is-today .daycalc__milestone-dday { + color: var(--accent-lab); +} + +/* ── Empty State ─────────────────────────────────────────────────────────── */ + +.daycalc__empty { + text-align: center; + padding: 60px 20px; + color: var(--text-muted); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} + +.daycalc__empty-icon { + font-size: 48px; + margin-bottom: 4px; +} + +.daycalc__empty p { + font-size: 14px; + color: var(--text-muted); +} + +/* ── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .daycalc { + padding: 20px 16px 48px; + gap: 20px; + } + + .daycalc__date-row { + grid-template-columns: 1fr; + gap: 12px; + } + + .daycalc__arrow { + display: none; + } + + .daycalc__big-cards { + grid-template-columns: 1fr; + } + + .daycalc__milestone-row { + grid-template-columns: 80px 1fr auto; + gap: 10px; + } + + .daycalc__breakdown-row { + gap: 20px; + } + + .daycalc__breakdown-num { + font-size: 26px; + } +} diff --git a/src/pages/effect-lab/DayCalc.jsx b/src/pages/effect-lab/DayCalc.jsx new file mode 100644 index 0000000..530ac54 --- /dev/null +++ b/src/pages/effect-lab/DayCalc.jsx @@ -0,0 +1,275 @@ +import React, { useState, useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import './DayCalc.css'; + +const today = () => new Date().toISOString().slice(0, 10); + +const fmt = (d) => { + if (!d) return ''; + const [y, m, day] = d.split('-'); + return `${y}년 ${parseInt(m)}월 ${parseInt(day)}일`; +}; + +// 두 날짜 사이 diff 계산 +const calcDiff = (from, to) => { + const f = new Date(from); + const t = new Date(to); + + const totalMs = t - f; + const totalDays = Math.round(totalMs / 86400000); + + // 연/월/일 분리 계산 + let years = t.getFullYear() - f.getFullYear(); + let months = t.getMonth() - f.getMonth(); + let days = t.getDate() - f.getDate(); + + if (days < 0) { + months -= 1; + const prevMonth = new Date(t.getFullYear(), t.getMonth(), 0); + days += prevMonth.getDate(); + } + if (months < 0) { + years -= 1; + months += 12; + } + + const totalMonths = years * 12 + months; + const weeks = Math.floor(Math.abs(totalDays) / 7); + const remDays = Math.abs(totalDays) % 7; + + return { totalDays, totalMonths, years, months, days, weeks, remDays }; +}; + +// 특정 날짜로부터 N일 후 날짜 계산 +const addDays = (dateStr, n) => { + const d = new Date(dateStr); + d.setDate(d.getDate() + n); + return d.toISOString().slice(0, 10); +}; + +// 기념일 체크포인트 +const MILESTONES = [100, 200, 365, 500, 730, 1000, 1461, 2000, 3000]; + +const QUICK_PRESETS = [ + { label: '오늘 기준', offset: 0 }, + { label: '1주 후', offset: 7 }, + { label: '1개월 후', offset: 30 }, + { label: '3개월 후', offset: 90 }, + { label: '6개월 후', offset: 180 }, + { label: '1년 후', offset: 365 }, +]; + +const DayCalc = () => { + const [fromDate, setFromDate] = useState(''); + const [toDate, setToDate] = useState(today()); + const [tab, setTab] = useState('diff'); // diff | milestone | future + + const result = useMemo(() => { + if (!fromDate || !toDate) return null; + try { + return calcDiff(fromDate, toDate); + } catch { + return null; + } + }, [fromDate, toDate]); + + const milestones = useMemo(() => { + if (!fromDate) return []; + return MILESTONES.map((n) => ({ + days: n, + date: addDays(fromDate, n - 1), + })); + }, [fromDate]); + + const isForward = result ? result.totalDays >= 0 : true; + + const applyPreset = (offset) => { + setToDate(addDays(today(), offset)); + }; + + return ( +
+
+
+ ← Lab +

Lab · 날짜 도구

+

일수 계산기

+

두 날짜 사이의 기간과 기념일 날짜를 계산합니다.

+
+
+ + {/* 날짜 입력 */} +
+
+
+ + setFromDate(e.target.value)} + max={toDate || undefined} + /> + {fromDate && {fmt(fromDate)}} +
+ +
+ {result + ? {isForward ? '→' : '←'} + : } +
+ +
+ + setToDate(e.target.value)} + /> + {toDate && {fmt(toDate)}} +
+
+ + {/* 빠른 종료일 설정 */} +
+ 빠른 설정 + {QUICK_PRESETS.map((p) => ( + + ))} + +
+
+ + {/* 결과 탭 */} + {fromDate && ( + <> +
+ {[ + { id: 'diff', label: '기간 계산' }, + { id: 'milestone', label: '기념일' }, + ].map((t) => ( + + ))} +
+ + {/* 기간 계산 탭 */} + {tab === 'diff' && result && ( +
+ {/* 메인 수치 */} +
+
+

+ {isForward ? '+' : ''}{result.totalDays.toLocaleString()} +

+

+

+ {isForward ? '경과' : '이전'} +

+
+ +
+

{result.totalMonths.toLocaleString()}

+

개월

+

총 개월 수

+
+ +
+

{result.weeks.toLocaleString()}

+

주 {result.remDays}일

+

주 단위

+
+
+ + {/* 세부 분해 */} +
+

상세 기간

+
+ {result.years > 0 && ( +
+ {result.years} + +
+ )} +
+ {result.months} + 개월 +
+
+ {result.days} + +
+
+

+ {fmt(fromDate)} 부터 {fmt(toDate)} 까지 +

+
+
+ )} + + {/* 기념일 탭 */} + {tab === 'milestone' && ( +
+

+ {fmt(fromDate)} 을 기준으로 한 기념일 날짜입니다. +

+
+ {milestones.map(({ days, date }) => { + const isPast = date < today(); + const isToday = date === today(); + const diff = calcDiff(today(), date); + return ( +
+
+ {days < 365 + ? `D+${days}` + : days % 365 === 0 + ? `${days / 365}주년` + : `D+${days}`} +
+
{fmt(date)}
+
+ {isToday + ? '🎉 오늘' + : isPast + ? `${Math.abs(diff.totalDays)}일 전` + : `D-${diff.totalDays}`} +
+
+ ); + })} +
+
+ )} + + )} + + {!fromDate && ( +
+

📅

+

시작일을 입력하면 기간 계산을 시작합니다.

+
+ )} +
+ ); +}; + +export default DayCalc; diff --git a/src/pages/effect-lab/EffectLab.css b/src/pages/effect-lab/EffectLab.css index 2eb02f5..9057c05 100644 --- a/src/pages/effect-lab/EffectLab.css +++ b/src/pages/effect-lab/EffectLab.css @@ -1,59 +1,196 @@ -.effect-lab { - position: relative; - width: 100%; - height: 100%; - min-height: calc(100vh - 80px); - /* Adjust based on navbar height */ - overflow: hidden; - background-color: #050505; - border-radius: 20px; - border: 1px solid var(--line); +/* ── Lab Landing Page ────────────────────────────────────────────────────── */ + +.lab { + max-width: 1000px; + margin: 0 auto; + padding: 40px 24px 80px; display: flex; flex-direction: column; + gap: 40px; } -.effect-lab canvas { - display: block; - outline: none; +/* ── Header ─────────────────────────────────────────────────────────────── */ + +.lab__header { + display: flex; + flex-direction: column; + gap: 8px; } -.effect-lab-overlay { - position: absolute; - top: 20px; - left: 20px; - color: rgba(255, 255, 255, 0.7); - pointer-events: none; - z-index: 10; +.lab__kicker { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent-lab); } -.effect-lab-overlay h2 { - margin: 0 0 8px; - font-family: var(--font-display); - font-size: 28px; - color: var(--text); - text-shadow: 0 0 20px rgba(68, 170, 221, 0.5); +.lab__header h1 { + font-size: 36px; + font-weight: 700; + color: var(--text-bright); + line-height: 1.1; } -.active-mode { - display: inline-block; - background: rgba(68, 170, 221, 0.1); - border: 1px solid rgba(68, 170, 221, 0.3); - padding: 6px 12px; - border-radius: 99px; - font-size: 12px; - color: #44aadd; - margin-bottom: 12px; - font-weight: 600; -} - -.active-mode span { - color: #fff; -} - -.effect-lab-overlay p { - margin: 0; +.lab__desc { font-size: 14px; - color: var(--muted); - max-width: 400px; - line-height: 1.5; -} \ No newline at end of file + color: var(--text-dim); + max-width: 560px; + margin: 0; +} + +/* ── Grid ────────────────────────────────────────────────────────────────── */ + +.lab__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 18px; +} + +/* ── Lab Card ────────────────────────────────────────────────────────────── */ + +.lab-card { + --card-accent: var(--neon-cyan); + display: flex; + flex-direction: column; + gap: 16px; + padding: 24px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + text-decoration: none; + color: inherit; + position: relative; + overflow: hidden; + transition: border-color 0.22s, transform 0.22s, box-shadow 0.22s; +} + +.lab-card::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(135deg, color-mix(in srgb, var(--card-accent) 8%, transparent), transparent 60%); + opacity: 0; + transition: opacity 0.22s; + pointer-events: none; +} + +.lab-card:hover { + border-color: color-mix(in srgb, var(--card-accent) 40%, transparent); + transform: translateY(-3px); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px color-mix(in srgb, var(--card-accent) 15%, transparent); +} + +.lab-card:hover::before { + opacity: 1; +} + +/* ── Card Top ────────────────────────────────────────────────────────────── */ + +.lab-card__top { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.lab-card__icon { + font-size: 32px; + line-height: 1; + filter: drop-shadow(0 0 10px color-mix(in srgb, var(--card-accent) 50%, transparent)); +} + +.lab-card__status { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.1em; + padding: 3px 8px; + border-radius: 20px; + border: 1px solid; + background: rgba(255, 255, 255, 0.04); +} + +/* ── Card Body ───────────────────────────────────────────────────────────── */ + +.lab-card__body { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; +} + +.lab-card__category { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: color-mix(in srgb, var(--card-accent) 80%, var(--text-dim)); + margin: 0; +} + +.lab-card__title { + font-size: 20px; + font-weight: 700; + color: var(--text-bright); + line-height: 1.2; +} + +.lab-card__desc { + font-size: 13px; + color: var(--text-dim); + line-height: 1.6; + margin: 4px 0 0; +} + +/* ── Card Footer ─────────────────────────────────────────────────────────── */ + +.lab-card__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: auto; +} + +.lab-card__tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.lab-card__tag { + font-size: 11px; + padding: 2px 8px; + border-radius: 20px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--line); + color: var(--text-muted); +} + +.lab-card__arrow { + font-size: 18px; + color: color-mix(in srgb, var(--card-accent) 60%, transparent); + transition: transform 0.18s, color 0.18s; + flex-shrink: 0; +} + +.lab-card:hover .lab-card__arrow { + transform: translateX(4px); + color: var(--card-accent); +} + +/* ── Responsive ──────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .lab { + padding: 24px 16px 60px; + gap: 28px; + } + + .lab__grid { + grid-template-columns: 1fr; + } + + .lab__header h1 { + font-size: 28px; + } +} diff --git a/src/pages/effect-lab/EffectLab.jsx b/src/pages/effect-lab/EffectLab.jsx index b15a974..2ee715c 100644 --- a/src/pages/effect-lab/EffectLab.jsx +++ b/src/pages/effect-lab/EffectLab.jsx @@ -1,218 +1,81 @@ -import React, { useEffect, useRef, useState } from 'react'; -import * as THREE from 'three'; +import React from 'react'; +import { Link } from 'react-router-dom'; import './EffectLab.css'; -const EffectLab = () => { - const containerRef = useRef(null); - const requestRef = useRef(); - const [mode, setMode] = useState('HOVER'); // HOVER, ATTACK, ORBIT +const LAB_ITEMS = [ + { + id: 'sword-stream', + path: '/lab/sword-stream', + title: 'Sword Stream', + category: '3D · 인터랙티브', + desc: '1,500개의 검 파티클이 마우스를 따라 흐릅니다. 클릭하면 나선형 궤도로 전환됩니다.', + tags: ['Three.js', '파티클', '인터랙티브'], + accent: '#44aadd', + icon: '⚔️', + status: 'live', + }, + { + id: 'day-calc', + path: '/lab/day-calc', + title: '일수 계산기', + category: '유틸리티 · 날짜', + desc: '두 날짜 사이의 기간을 일, 주, 월, 연 단위로 계산하고 기념일 날짜를 확인합니다.', + tags: ['날짜', '계산기', '기념일'], + accent: '#fbbf24', + icon: '📅', + status: 'live', + }, +]; - useEffect(() => { - if (!containerRef.current) return; - - // --- Configuration --- - const COUNT = 1500; - const SWORD_COLOR = 0x44aadd; - const SWORD_EMISSIVE = 0x112244; - - // --- Helper: Random Range --- - const rand = (min, max) => Math.random() * (max - min) + min; - - // --- Setup Scene --- - const width = containerRef.current.clientWidth; - const height = containerRef.current.clientHeight; - - const scene = new THREE.Scene(); - scene.fog = new THREE.FogExp2(0x050505, 0.002); - - const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000); - camera.position.z = 80; - - const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); - renderer.setSize(width, height); - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - // Tone mapping for better glow look - renderer.toneMapping = THREE.ACESFilmicToneMapping; - containerRef.current.appendChild(renderer.domElement); - - // --- Lighting --- - const ambientLight = new THREE.AmbientLight(0x404040); - scene.add(ambientLight); - - const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100); - scene.add(pointLight); - - // --- Geometry & Material --- - // Sword shape: Cone stretched - const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8); - geometry.rotateX(Math.PI / 2); // Point towards Z - - const material = new THREE.MeshPhongMaterial({ - color: SWORD_COLOR, - emissive: SWORD_EMISSIVE, - shininess: 100, - flatShading: true, - }); - - const mesh = new THREE.InstancedMesh(geometry, material, COUNT); - scene.add(mesh); - - // --- Particle Data --- - const dummy = new THREE.Object3D(); - const particles = []; - - for (let i = 0; i < COUNT; i++) { - particles.push({ - pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)), - vel: new THREE.Vector3(), - acc: new THREE.Vector3(), - // Orbit parameters - angle: rand(0, Math.PI * 2), - radius: rand(15, 30), - speed: rand(0.02, 0.05), - // Offset for natural movement - offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)) - }); - } - - // --- Mouse & Interaction State --- - const mouse = new THREE.Vector3(); - const target = new THREE.Vector3(); - const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); - const raycaster = new THREE.Raycaster(); - - let isMouseDown = false; - let time = 0; - - const handleMouseMove = (e) => { - const rect = renderer.domElement.getBoundingClientRect(); - const x = ((e.clientX - rect.left) / rect.width) * 2 - 1; - const y = -((e.clientY - rect.top) / rect.height) * 2 + 1; - - raycaster.setFromCamera(new THREE.Vector2(x, y), camera); - raycaster.ray.intersectPlane(mousePlane, mouse); - - // Allow light to follow mouse - pointLight.position.copy(mouse); - pointLight.position.z = 20; - }; - - const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); }; - const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); }; - - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mousedown', handleMouseDown); - window.addEventListener('mouseup', handleMouseUp); - - // --- Animation Loop --- - const animate = () => { - requestRef.current = requestAnimationFrame(animate); - time += 0.01; - - for (let i = 0; i < COUNT; i++) { - const p = particles[i]; - - // --- Behavior Logic --- - if (isMouseDown) { - // 1. ORBIT MODE: Rotate around mouse - p.angle += p.speed + 0.02; // Spin faster - - const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius; - const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius; - // Spiraling Z for depth - const orbitZ = Math.sin(p.angle * 2 + time) * 10; - - target.set(orbitX, orbitY, orbitZ); - - // Strong pull to orbit positions - p.acc.subVectors(target, p.pos).multiplyScalar(0.08); - - } else { - // 2. HOVER/FOLLOW MODE: Follow mouse with flocking feel - - // Add noise/wandering - const noiseX = Math.sin(time + i * 0.1) * 5; - const noiseY = Math.cos(time + i * 0.1) * 5; - - target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0)); - - // Gentle pull - p.acc.subVectors(target, p.pos).multiplyScalar(0.008); - } - - // Physics update - p.vel.add(p.acc); - p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94); // Drag - p.pos.add(p.vel); - - // Update Matrix - dummy.position.copy(p.pos); - - // Rotation: Look at velocity direction (dynamic) or mouse (focused) - // Blending lookAt target for smoother rotation - const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5)); - - // If moving very slowly, keep previous rotation to avoid jitter - if (p.vel.lengthSq() > 0.01) { - dummy.lookAt(lookPos); - } - - // Scale effect based on speed (stretch when fast) - const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5; - dummy.scale.set(1, 1, speedScale); - - dummy.updateMatrix(); - mesh.setMatrixAt(i, dummy.matrix); - } - - mesh.instanceMatrix.needsUpdate = true; - renderer.render(scene, camera); - }; - - animate(); - - // --- Resize --- - const handleResize = () => { - if (!containerRef.current) return; - const newWidth = containerRef.current.clientWidth; - const newHeight = containerRef.current.clientHeight; - - camera.aspect = newWidth / newHeight; - camera.updateProjectionMatrix(); - renderer.setSize(newWidth, newHeight); - }; - - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mousedown', handleMouseDown); - window.removeEventListener('mouseup', handleMouseUp); - window.removeEventListener('resize', handleResize); - cancelAnimationFrame(requestRef.current); - if (containerRef.current && renderer.domElement) { - containerRef.current.removeChild(renderer.domElement); - } - geometry.dispose(); - material.dispose(); - renderer.dispose(); - }; - }, []); +const STATUS_LABEL = { + live: { label: 'LIVE', color: '#34d399' }, + wip: { label: 'WIP', color: '#fbbf24' }, + planned: { label: 'PLANNED', color: '#94a3b8' }, +}; +const LabCard = ({ item }) => { + const st = STATUS_LABEL[item.status] || STATUS_LABEL.planned; return ( -
-
-

Sword Stream

-
- MODE: {mode} -
-

- Move to Guide  |  - Click & Hold to Orbit & Charge -

+ +
+ {item.icon} + + {st.label} +
-
+
+

{item.category}

+

{item.title}

+

{item.desc}

+
+
+
+ {item.tags.map((t) => ( + {t} + ))} +
+ +
+ ); }; +const EffectLab = () => ( +
+
+

STREAM

+

Lab

+

+ 실험적인 UI, 인터랙티브 효과, 유틸리티 도구를 테스트하고 탐구하는 공간입니다. +

+
+ +
+ {LAB_ITEMS.map((item) => ( + + ))} +
+
+); + export default EffectLab; diff --git a/src/pages/effect-lab/SwordStream.css b/src/pages/effect-lab/SwordStream.css new file mode 100644 index 0000000..2561383 --- /dev/null +++ b/src/pages/effect-lab/SwordStream.css @@ -0,0 +1,82 @@ +.sword-stream { + position: relative; + width: 100%; + height: 100%; + min-height: calc(100vh - 80px); + overflow: hidden; + background-color: #050505; + border-radius: 20px; + border: 1px solid var(--line); +} + +.sword-stream canvas { + display: block; + outline: none; +} + +.sword-stream__overlay { + position: absolute; + top: 20px; + left: 20px; + color: rgba(255, 255, 255, 0.7); + pointer-events: none; + z-index: 10; + display: flex; + flex-direction: column; + gap: 8px; +} + +.sword-stream__back { + pointer-events: all; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + font-weight: 600; + color: rgba(68, 170, 221, 0.7); + text-decoration: none; + padding: 5px 10px; + border: 1px solid rgba(68, 170, 221, 0.2); + border-radius: 6px; + background: rgba(68, 170, 221, 0.06); + transition: color 0.2s, border-color 0.2s, background 0.2s; + width: fit-content; +} + +.sword-stream__back:hover { + color: #44aadd; + border-color: rgba(68, 170, 221, 0.5); + background: rgba(68, 170, 221, 0.12); +} + +.sword-stream__overlay h2 { + margin: 0; + font-family: var(--font-display); + font-size: 28px; + color: var(--text); + text-shadow: 0 0 20px rgba(68, 170, 221, 0.5); +} + +.sword-stream__mode { + display: inline-block; + background: rgba(68, 170, 221, 0.1); + border: 1px solid rgba(68, 170, 221, 0.3); + padding: 6px 12px; + border-radius: 99px; + font-size: 12px; + color: #44aadd; + font-weight: 600; + width: fit-content; +} + +.sword-stream__mode span { + color: #fff; +} + +.sword-stream__overlay p { + margin: 0; + font-size: 14px; + color: var(--muted); + max-width: 400px; + line-height: 1.5; +} diff --git a/src/pages/effect-lab/SwordStream.jsx b/src/pages/effect-lab/SwordStream.jsx new file mode 100644 index 0000000..27d498b --- /dev/null +++ b/src/pages/effect-lab/SwordStream.jsx @@ -0,0 +1,184 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; +import * as THREE from 'three'; +import './SwordStream.css'; + +const SwordStream = () => { + const containerRef = useRef(null); + const requestRef = useRef(); + const [mode, setMode] = useState('HOVER'); // HOVER, ORBIT + + useEffect(() => { + if (!containerRef.current) return; + + const COUNT = 1500; + const SWORD_COLOR = 0x44aadd; + const SWORD_EMISSIVE = 0x112244; + + const rand = (min, max) => Math.random() * (max - min) + min; + + const width = containerRef.current.clientWidth; + const height = containerRef.current.clientHeight; + + const scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x050505, 0.002); + + const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000); + camera.position.z = 80; + + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.toneMapping = THREE.ACESFilmicToneMapping; + containerRef.current.appendChild(renderer.domElement); + + const ambientLight = new THREE.AmbientLight(0x404040); + scene.add(ambientLight); + + const pointLight = new THREE.PointLight(SWORD_COLOR, 2, 100); + scene.add(pointLight); + + const geometry = new THREE.CylinderGeometry(0.1, 0.4, 4, 8); + geometry.rotateX(Math.PI / 2); + + const material = new THREE.MeshPhongMaterial({ + color: SWORD_COLOR, + emissive: SWORD_EMISSIVE, + shininess: 100, + flatShading: true, + }); + + const mesh = new THREE.InstancedMesh(geometry, material, COUNT); + scene.add(mesh); + + const dummy = new THREE.Object3D(); + const particles = []; + + for (let i = 0; i < COUNT; i++) { + particles.push({ + pos: new THREE.Vector3(rand(-100, 100), rand(-100, 100), rand(-50, 50)), + vel: new THREE.Vector3(), + acc: new THREE.Vector3(), + angle: rand(0, Math.PI * 2), + radius: rand(15, 30), + speed: rand(0.02, 0.05), + offset: new THREE.Vector3(rand(-5, 5), rand(-5, 5), rand(-5, 5)), + }); + } + + const mouse = new THREE.Vector3(); + const target = new THREE.Vector3(); + const mousePlane = new THREE.Plane(new THREE.Vector3(0, 0, 1), 0); + const raycaster = new THREE.Raycaster(); + + let isMouseDown = false; + let time = 0; + + const handleMouseMove = (e) => { + const rect = renderer.domElement.getBoundingClientRect(); + const x = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const y = -((e.clientY - rect.top) / rect.height) * 2 + 1; + + raycaster.setFromCamera(new THREE.Vector2(x, y), camera); + raycaster.ray.intersectPlane(mousePlane, mouse); + + pointLight.position.copy(mouse); + pointLight.position.z = 20; + }; + + const handleMouseDown = () => { isMouseDown = true; setMode('ORBIT'); }; + const handleMouseUp = () => { isMouseDown = false; setMode('HOVER'); }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mousedown', handleMouseDown); + window.addEventListener('mouseup', handleMouseUp); + + const animate = () => { + requestRef.current = requestAnimationFrame(animate); + time += 0.01; + + for (let i = 0; i < COUNT; i++) { + const p = particles[i]; + + if (isMouseDown) { + p.angle += p.speed + 0.02; + const orbitX = mouse.x + Math.cos(p.angle + time) * p.radius; + const orbitY = mouse.y + Math.sin(p.angle + time) * p.radius; + const orbitZ = Math.sin(p.angle * 2 + time) * 10; + target.set(orbitX, orbitY, orbitZ); + p.acc.subVectors(target, p.pos).multiplyScalar(0.08); + } else { + const noiseX = Math.sin(time + i * 0.1) * 5; + const noiseY = Math.cos(time + i * 0.1) * 5; + target.copy(mouse).add(p.offset).add(new THREE.Vector3(noiseX, noiseY, 0)); + p.acc.subVectors(target, p.pos).multiplyScalar(0.008); + } + + p.vel.add(p.acc); + p.vel.multiplyScalar(isMouseDown ? 0.90 : 0.94); + p.pos.add(p.vel); + + dummy.position.copy(p.pos); + + const lookPos = new THREE.Vector3().copy(p.pos).add(p.vel.clone().multiplyScalar(5)); + if (p.vel.lengthSq() > 0.01) { + dummy.lookAt(lookPos); + } + + const speedScale = 1 + Math.min(p.vel.length(), 2) * 0.5; + dummy.scale.set(1, 1, speedScale); + + dummy.updateMatrix(); + mesh.setMatrixAt(i, dummy.matrix); + } + + mesh.instanceMatrix.needsUpdate = true; + renderer.render(scene, camera); + }; + + animate(); + + const handleResize = () => { + if (!containerRef.current) return; + const newWidth = containerRef.current.clientWidth; + const newHeight = containerRef.current.clientHeight; + camera.aspect = newWidth / newHeight; + camera.updateProjectionMatrix(); + renderer.setSize(newWidth, newHeight); + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mousedown', handleMouseDown); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('resize', handleResize); + cancelAnimationFrame(requestRef.current); + if (containerRef.current && renderer.domElement) { + containerRef.current.removeChild(renderer.domElement); + } + geometry.dispose(); + material.dispose(); + renderer.dispose(); + }; + }, []); + + return ( +
+
+ ← Lab +

Sword Stream

+
+ MODE: {mode} +
+

+ Move to Guide  |  + Click & Hold to Orbit & Charge +

+
+
+ ); +}; + +export default SwordStream; diff --git a/src/routes.jsx b/src/routes.jsx index 095ca85..b4e7ccf 100644 --- a/src/routes.jsx +++ b/src/routes.jsx @@ -19,6 +19,8 @@ const StockTrade = lazy(() => import('./pages/stock/StockTrade')); const RealEstate = lazy(() => import('./pages/realestate/RealEstate')); const Subscription = lazy(() => import('./pages/subscription/Subscription')); const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab')); +const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream')); +const DayCalc = lazy(() => import('./pages/effect-lab/DayCalc')); const Todo = lazy(() => import('./pages/todo/Todo')); export const navLinks = [ @@ -133,6 +135,14 @@ export const appRoutes = [ path: 'lab', element: , }, + { + path: 'lab/sword-stream', + element: , + }, + { + path: 'lab/day-calc', + element: , + }, { path: 'todo', element: ,