Lab 설정 변경 및 유틸 추가

This commit is contained in:
2026-03-16 03:00:04 +09:00
parent dac06fc4eb
commit df54437f47
7 changed files with 1255 additions and 256 deletions

View File

@@ -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;
}
}

View File

@@ -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 (
<div className="daycalc">
<header className="daycalc__header">
<div>
<Link to="/lab" className="daycalc__back"> Lab</Link>
<p className="daycalc__kicker">Lab · 날짜 도구</p>
<h1>일수 계산기</h1>
<p className="daycalc__desc"> 날짜 사이의 기간과 기념일 날짜를 계산합니다.</p>
</div>
</header>
{/* 날짜 입력 */}
<section className="daycalc__input-section">
<div className="daycalc__date-row">
<div className="daycalc__date-field">
<label>시작일</label>
<input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
max={toDate || undefined}
/>
{fromDate && <span className="daycalc__date-fmt">{fmt(fromDate)}</span>}
</div>
<div className="daycalc__arrow">
{result
? <span className={isForward ? 'fwd' : 'bwd'}>{isForward ? '→' : '←'}</span>
: <span></span>}
</div>
<div className="daycalc__date-field">
<label>종료일</label>
<input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
/>
{toDate && <span className="daycalc__date-fmt">{fmt(toDate)}</span>}
</div>
</div>
{/* 빠른 종료일 설정 */}
<div className="daycalc__presets">
<span className="daycalc__presets-label">빠른 설정</span>
{QUICK_PRESETS.map((p) => (
<button
key={p.label}
className="daycalc__preset-btn"
onClick={() => applyPreset(p.offset)}
>
{p.label}
</button>
))}
<button
className="daycalc__preset-btn daycalc__preset-btn--clear"
onClick={() => { setFromDate(''); setToDate(today()); }}
>
초기화
</button>
</div>
</section>
{/* 결과 탭 */}
{fromDate && (
<>
<div className="daycalc__tabs">
{[
{ id: 'diff', label: '기간 계산' },
{ id: 'milestone', label: '기념일' },
].map((t) => (
<button
key={t.id}
className={`daycalc__tab${tab === t.id ? ' is-active' : ''}`}
onClick={() => setTab(t.id)}
>
{t.label}
</button>
))}
</div>
{/* 기간 계산 탭 */}
{tab === 'diff' && result && (
<section className="daycalc__result">
{/* 메인 수치 */}
<div className="daycalc__big-cards">
<div className="daycalc__big-card daycalc__big-card--primary">
<p className="daycalc__big-num">
{isForward ? '+' : ''}{result.totalDays.toLocaleString()}
</p>
<p className="daycalc__big-label"></p>
<p className="daycalc__big-sub">
{isForward ? '경과' : '이전'}
</p>
</div>
<div className="daycalc__big-card">
<p className="daycalc__big-num">{result.totalMonths.toLocaleString()}</p>
<p className="daycalc__big-label">개월</p>
<p className="daycalc__big-sub"> 개월 </p>
</div>
<div className="daycalc__big-card">
<p className="daycalc__big-num">{result.weeks.toLocaleString()}</p>
<p className="daycalc__big-label"> {result.remDays}</p>
<p className="daycalc__big-sub"> 단위</p>
</div>
</div>
{/* 세부 분해 */}
<div className="daycalc__breakdown">
<h3>상세 기간</h3>
<div className="daycalc__breakdown-row">
{result.years > 0 && (
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.years}</span>
<span className="daycalc__breakdown-unit"></span>
</div>
)}
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.months}</span>
<span className="daycalc__breakdown-unit">개월</span>
</div>
<div className="daycalc__breakdown-item">
<span className="daycalc__breakdown-num">{result.days}</span>
<span className="daycalc__breakdown-unit"></span>
</div>
</div>
<p className="daycalc__breakdown-summary">
{fmt(fromDate)} 부터 {fmt(toDate)} 까지
</p>
</div>
</section>
)}
{/* 기념일 탭 */}
{tab === 'milestone' && (
<section className="daycalc__milestones">
<p className="daycalc__milestones-desc">
<strong>{fmt(fromDate)}</strong> 기준으로 기념일 날짜입니다.
</p>
<div className="daycalc__milestone-list">
{milestones.map(({ days, date }) => {
const isPast = date < today();
const isToday = date === today();
const diff = calcDiff(today(), date);
return (
<div
key={days}
className={`daycalc__milestone-row${isPast ? ' is-past' : ''}${isToday ? ' is-today' : ''}`}
>
<div className="daycalc__milestone-badge">
{days < 365
? `D+${days}`
: days % 365 === 0
? `${days / 365}주년`
: `D+${days}`}
</div>
<div className="daycalc__milestone-date">{fmt(date)}</div>
<div className="daycalc__milestone-dday">
{isToday
? '🎉 오늘'
: isPast
? `${Math.abs(diff.totalDays)}일 전`
: `D-${diff.totalDays}`}
</div>
</div>
);
})}
</div>
</section>
)}
</>
)}
{!fromDate && (
<div className="daycalc__empty">
<p className="daycalc__empty-icon">📅</p>
<p>시작일을 입력하면 기간 계산을 시작합니다.</p>
</div>
)}
</div>
);
};
export default DayCalc;

View File

@@ -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;
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;
}
}

View File

@@ -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 STATUS_LABEL = {
live: { label: 'LIVE', color: '#34d399' },
wip: { label: 'WIP', color: '#fbbf24' },
planned: { label: 'PLANNED', color: '#94a3b8' },
};
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 LabCard = ({ item }) => {
const st = STATUS_LABEL[item.status] || STATUS_LABEL.planned;
return (
<div className="effect-lab" ref={containerRef}>
<div className="effect-lab-overlay">
<h2>Sword Stream</h2>
<div className="active-mode">
MODE: <span>{mode}</span>
<Link to={item.path} className="lab-card" style={{ '--card-accent': item.accent }}>
<div className="lab-card__top">
<span className="lab-card__icon">{item.icon}</span>
<span className="lab-card__status" style={{ color: st.color, borderColor: `${st.color}40` }}>
{st.label}
</span>
</div>
<p>
<strong>Move</strong> to Guide &nbsp;|&nbsp;
<strong>Click & Hold</strong> to Orbit & Charge
</p>
<div className="lab-card__body">
<p className="lab-card__category">{item.category}</p>
<h2 className="lab-card__title">{item.title}</h2>
<p className="lab-card__desc">{item.desc}</p>
</div>
<div className="lab-card__footer">
<div className="lab-card__tags">
{item.tags.map((t) => (
<span key={t} className="lab-card__tag">{t}</span>
))}
</div>
<span className="lab-card__arrow"></span>
</div>
</Link>
);
};
const EffectLab = () => (
<div className="lab">
<header className="lab__header">
<p className="lab__kicker">STREAM</p>
<h1>Lab</h1>
<p className="lab__desc">
실험적인 UI, 인터랙티브 효과, 유틸리티 도구를 테스트하고 탐구하는 공간입니다.
</p>
</header>
<div className="lab__grid">
{LAB_ITEMS.map((item) => (
<LabCard key={item.id} item={item} />
))}
</div>
</div>
);
export default EffectLab;

View File

@@ -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;
}

View File

@@ -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 (
<div className="sword-stream" ref={containerRef}>
<div className="sword-stream__overlay">
<Link to="/lab" className="sword-stream__back"> Lab</Link>
<h2>Sword Stream</h2>
<div className="sword-stream__mode">
MODE: <span>{mode}</span>
</div>
<p>
<strong>Move</strong> to Guide &nbsp;|&nbsp;
<strong>Click &amp; Hold</strong> to Orbit &amp; Charge
</p>
</div>
</div>
);
};
export default SwordStream;

View File

@@ -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: <EffectLab />,
},
{
path: 'lab/sword-stream',
element: <SwordStream />,
},
{
path: 'lab/day-calc',
element: <DayCalc />,
},
{
path: 'todo',
element: <Todo />,