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 (
+
+
+
+ {/* 날짜 입력 */}
+
+
+
+
+ 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 = () => (
+
+
+
+
+ {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:
,