Lab 설정 변경 및 유틸 추가
This commit is contained in:
448
src/pages/effect-lab/DayCalc.css
Normal file
448
src/pages/effect-lab/DayCalc.css
Normal 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;
|
||||
}
|
||||
}
|
||||
275
src/pages/effect-lab/DayCalc.jsx
Normal file
275
src/pages/effect-lab/DayCalc.jsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="effect-lab" ref={containerRef}>
|
||||
<div className="effect-lab-overlay">
|
||||
<h2>Sword Stream</h2>
|
||||
<div className="active-mode">
|
||||
MODE: <span>{mode}</span>
|
||||
</div>
|
||||
<p>
|
||||
<strong>Move</strong> to Guide |
|
||||
<strong>Click & Hold</strong> to Orbit & Charge
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<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;
|
||||
|
||||
82
src/pages/effect-lab/SwordStream.css
Normal file
82
src/pages/effect-lab/SwordStream.css
Normal 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;
|
||||
}
|
||||
184
src/pages/effect-lab/SwordStream.jsx
Normal file
184
src/pages/effect-lab/SwordStream.jsx
Normal 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 |
|
||||
<strong>Click & Hold</strong> to Orbit & Charge
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SwordStream;
|
||||
@@ -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 />,
|
||||
|
||||
Reference in New Issue
Block a user