// Reusable atoms — donut, bar, chip, browser/phone frames
const { useState, useEffect, useRef } = React;
const L = window.lucideReact || {};
const { MAJOR, majorOf } = window.MajorLinkData;
// === Match score gradient color ===
function scoreColor(score) {
if (score >= 85) return { stroke: 'url(#gradHi)', text: '#4F46E5', label: 'EXCELLENT', labelBg: '#EEF2FF', labelText: '#4F46E5' };
if (score >= 70) return { stroke: '#22C55E', text: '#15803D', label: 'GOOD', labelBg: '#DCFCE7', labelText: '#15803D' };
if (score >= 50) return { stroke: '#F59E0B', text: '#B45309', label: 'FAIR', labelBg: '#FEF3C7', labelText: '#B45309' };
return { stroke: '#F43F5E', text: '#BE123C', label: 'LOW', labelBg: '#FFE4E6', labelText: '#BE123C' };
}
// === Animated count-up ===
function useCountUp(target, durationMs = 1200, delayMs = 200, key = 0) {
const [val, setVal] = useState(0);
useEffect(() => {
setVal(0);
let start = null;
let raf;
const t = setTimeout(() => {
const tick = (ts) => {
if (start == null) start = ts;
const p = Math.min(1, (ts - start) / durationMs);
const eased = 1 - Math.pow(1 - p, 3);
setVal(Math.round(target * eased));
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
}, delayMs);
return () => { clearTimeout(t); if (raf) cancelAnimationFrame(raf); };
}, [target, durationMs, delayMs, key]);
return val;
}
// === Donut score chart ===
function DonutScore({ score, size = 120, stroke = 10, animKey = 0, showLabel = true, delay = 200 }) {
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const v = useCountUp(score, 1200, delay, animKey);
const col = scoreColor(score);
const offset = c - (c * v) / 100;
return (
);
}
// === Score breakdown bars ===
const BREAKDOWN_LABELS = [
{ key: 'role', label: '역할', max: 30, color: '#4F46E5' },
{ key: 'skill', label: '기술', max: 25, color: '#22C55E' },
{ key: 'period', label: '기간', max: 20, color: '#F59E0B' },
{ key: 'interest', label: '관심', max: 15, color: '#EC4899' },
{ key: 'folio', label: '포폴', max: 10, color: '#14B8A6' },
];
function ScoreBars({ b, animKey = 0, dense = false }) {
return (
{BREAKDOWN_LABELS.map((row, i) => {
const v = b[row.key] || 0;
const pct = (v / row.max) * 100;
return (
{row.label}
{v}/{row.max}
);
})}
);
}
// === Major chip ===
function MajorChip({ dept, year, size = 'sm' }) {
const m = MAJOR[majorOf(dept)];
const cls = size === 'xs' ? 'text-[11px] px-2 py-0.5' : 'text-xs px-2.5 py-1';
return (
{dept}{year ? ` ${year}` : ''}
);
}
// === Generic chip ===
function Chip({ children, tone = 'gray', className = '' }) {
const tones = {
gray: { bg: '#F4F4F5', text: '#475569' },
indigo: { bg: '#EEF2FF', text: '#4F46E5' },
lime: { bg: '#ECFCCB', text: '#3F6212' },
amber: { bg: '#FEF3C7', text: '#B45309' },
rose: { bg: '#FFE4E6', text: '#BE123C' },
};
const t = tones[tone] || tones.gray;
return {children};
}
// === Avatar ===
function Avatar({ name, dept, size = 32, ring = true }) {
const initial = (name || '?').slice(0, 1);
const m = dept ? MAJOR[majorOf(dept)] : null;
const bg = m ? m.bg : '#E4E4E7';
const text = m ? m.text : '#475569';
const ringColor = m ? m.ring : '#E4E4E7';
return (
40 ? 3 : 2}px ${ringColor}` : undefined,
}}
>{initial}
);
}
// === Browser frame ===
function BrowserFrame({ url = 'majorlink.kr/', children, height }) {
return (
majorlink.kr
/{url.replace('majorlink.kr/', '')}
);
}
// === Phone frame ===
function PhoneFrame({ children, height = 720 }) {
return (
{/* status bar */}
{/* notch */}
{children}
);
}
// === Mobile bottom tab bar ===
function MobileTabBar({ active = 'notif' }) {
const items = [
{ k: 'home', label: '홈', icon: L.Home },
{ k: 'explore', label: '탐색', icon: L.Search },
{ k: 'notif', label: '알림', icon: L.Bell },
{ k: 'mine', label: '내 프로젝트', icon: L.Folder },
{ k: 'me', label: '마이', icon: L.User },
];
return (
{items.map(it => {
const Ico = it.icon;
const on = it.k === active;
return (
{Ico && }
{it.label}
);
})}
);
}
// === Wordmark ===
function Wordmark({ size = 'md' }) {
const big = size === 'lg';
return (
MajorLink
);
}
// Section card primitive
function Section({ title, action, children, className = '' }) {
return (
{(title || action) && (
{title &&
{title}
}
{action}
)}
{children}
);
}
// Image placeholder (gradient stripe)
function Placeholder({ label, height = 160, gradient = ['#EEF2FF', '#ECFCCB'], className = '' }) {
return (
);
}
// === Score trust note (caption + popover) ===
function ScoreTrustNote({ caption = '역할·기술·참여기간·관심분야·포트폴리오 5개 항목 기반 초기 가중치예요. 동료평가 선행연구를 참고했어요' }) {
const [open, setOpen] = useState(false);
return (
setOpen(true)}
onMouseLeave={() => setOpen(false)}>
{open && (
이 점수는 논문으로 검증된 알고리즘이 아니라, 초기 설계 단계의 가중치입니다. 동료평가 모델(CATME 등)과 프로젝트 기반 학습 선행연구를 참고했어요.
)}
);
}
// === Trust profile card (behavioral data + badges) ===
const BADGE_EMOJI = {
'완주왕': '🏅', '협업우수': '🤝', '마감준수': '📅', '창의왕': '💡',
'리더십': '🧭', '소통왕': '💬', '데이터장인': '📊', '콘텐츠왕': '🎬',
'성실참여': '✨', '신규멤버': '🌱',
};
function TrustProfileCard({ trust, title = '신뢰 프로필', dense = false }) {
if (!trust) return null;
const subs = trust.awardSubmissions || [];
const approvedAwards = subs.filter(s => s.status === 'approved').length;
const pendingAwards = subs.filter(s => s.status === 'pending').length;
const stats = [
{ label: '프로젝트 완료율', value: `${trust.completionRate}%`, color: trust.completionRate >= 90 ? '#15803D' : trust.completionRate >= 75 ? '#B45309' : '#BE123C' },
{ label: '중도이탈', value: `${trust.dropoutCount}회`, color: trust.dropoutCount === 0 ? '#15803D' : '#B45309' },
{ label: '공모전 수상', value: `${approvedAwards}회`, color: '#4F46E5', verified: true, sub: pendingAwards > 0 ? `+${pendingAwards} 승인 대기` : null },
{ label: '팀장 경험', value: `${trust.leaderCount}회`, color: '#0F766E' },
];
return (
{title.toUpperCase()}
행동 데이터
{stats.map(s => (
{s.label}
{s.verified && }
{s.value}
{s.sub &&
{s.sub}}
))}
{trust.badges && trust.badges.length > 0 && (
{trust.badges.map(b => (
{BADGE_EMOJI[b] || '🏷️'}{b}
))}
)}
);
}
// === Project Completion Index badge (only when project is complete) ===
const COMPLETION_ITEMS = [
{ key: 'output', label: '산출물 제출', max: 35, color: '#4F46E5' },
{ key: 'goal', label: '핵심기능/목표', max: 25, color: '#22C55E' },
{ key: 'schedule', label: '일정 준수', max: 15, color: '#F59E0B' },
{ key: 'roles', label: '팀원 역할 수행', max: 15, color: '#EC4899' },
{ key: 'record', label: '종료 기록', max: 10, color: '#14B8A6' },
];
function CompletionIndexBadge({ index, animKey = 0 }) {
if (!index) return null;
const total = COMPLETION_ITEMS.reduce((sum, r) => sum + (index[r.key] || 0), 0);
return (
프로젝트 완료 점수
{total} / 100
{COMPLETION_ITEMS.map((row, i) => {
const v = index[row.key] || 0;
const pct = (v / row.max) * 100;
return (
{row.label}
{v}/{row.max}
);
})}
);
}
window.MajorAtoms = {
scoreColor, useCountUp, DonutScore, ScoreBars, MajorChip, Chip, Avatar,
BrowserFrame, PhoneFrame, MobileTabBar, Wordmark, Section, Placeholder,
ScoreTrustNote, TrustProfileCard, CompletionIndexBadge,
BREAKDOWN_LABELS,
};