// components.jsx — 공통 컴포넌트 // shared utilities + visual primitives const { useState, useEffect, useRef, useMemo, useCallback } = React; /* ─── helpers ──────────────────────────────────────────────── */ const cx = (...xs) => xs.filter(Boolean).join(' '); const fmtKRW = (v) => { // "1,234,567" or short "1.2억", "8,400만" if (v == null) return '—'; const abs = Math.abs(v); if (abs >= 1e8) return (v / 1e8).toFixed(2).replace(/\.?0+$/,'') + '억'; if (abs >= 1e4) return (v / 1e4).toFixed(0) + '만'; return v.toLocaleString('ko-KR'); }; const fmtKRWFull = (v) => v == null ? '—' : v.toLocaleString('ko-KR'); const fmtUSD = (v) => v == null ? '—' : v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const fmtPct = (v, digits = 2) => (v >= 0 ? '+' : '') + v.toFixed(digits) + '%'; const fmtSigned = (v) => (v >= 0 ? '+' : '') + v.toLocaleString('ko-KR'); const relTime = (d) => { // d: future or past Date; returns "3시간 14분 후" / "어제 18:30" etc. const now = new Date(); const diff = (d - now) / 1000; // s const abs = Math.abs(diff); const suffix = diff >= 0 ? '후' : '전'; if (abs < 60) return `${Math.floor(abs)}초 ${suffix}`; if (abs < 3600) { const m = Math.floor(abs / 60); return `${m}분 ${suffix}`; } if (abs < 86400) { const h = Math.floor(abs / 3600); const m = Math.floor((abs % 3600) / 60); return m > 0 ? `${h}시간 ${m}분 ${suffix}` : `${h}시간 ${suffix}`; } const days = Math.floor(abs / 86400); return `${days}일 ${suffix}`; }; const fmtAbsTime = (d, tz = 'KST') => { // "2026-05-22 18:30 KST" const pad = (n) => String(n).padStart(2, '0'); const Y = d.getFullYear(), M = pad(d.getMonth()+1), D = pad(d.getDate()); const h = pad(d.getHours()), m = pad(d.getMinutes()); return `${Y}-${M}-${D} ${h}:${m} ${tz}`; }; const fmtTimeShort = (d) => { const pad = (n) => String(n).padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; }; const fmtTimeFull = (d) => { const pad = (n) => String(n).padStart(2, '0'); return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; }; /* ─── Badges ──────────────────────────────────────────────── */ function ActionBadge({ action, size = 'md' }) { // BUY / SELL / HOLD / UNCERTAIN const meta = { BUY: { label: '매수', sym: '▲', color: 'var(--buy)', soft: 'var(--buy-soft)' }, SELL: { label: '매도', sym: '▼', color: 'var(--sell)', soft: 'var(--sell-soft)' }, HOLD: { label: '관망', sym: '◆', color: 'var(--hold)', soft: 'var(--hold-soft)' }, UNCERTAIN: { label: '검토', sym: '⚠', color: 'var(--uncertain)', soft: 'var(--uncertain-soft)' }, }[action] || {}; const styleVars = { '--c': meta.color, '--cs': meta.soft, }; const cls = size === 'sm' ? 'badge badge-sm' : size === 'lg' ? 'badge badge-lg' : 'badge'; return ( {meta.sym} {action} {meta.label} ); } function MarketBadge({ market }) { const meta = { KR: { label: 'KR', c: 'var(--m-kr)' }, NASDAQ: { label: 'NDAQ', c: 'var(--m-us)' }, NYSE: { label: 'NYSE', c: 'var(--m-us)' }, AMEX: { label: 'AMEX', c: 'var(--m-us)' }, }[market] || { label: market, c: 'var(--text-3)' }; return {meta.label}; } function SeverityBadge({ level }) { const meta = { CRITICAL: { c: 'var(--critical)', soft: 'var(--critical-soft)' }, ERROR: { c: 'var(--buy)', soft: 'var(--buy-soft)' }, WARN: { c: 'var(--warn)', soft: 'var(--warn-soft)' }, INFO: { c: 'var(--info)', soft: 'var(--info-soft)' }, }[level] || {}; return {level}; } /* ─── DeltaChip ───────────────────────────────────────────── */ function DeltaChip({ value, suffix = '%', size = 'md', showArrow = true }) { const positive = value >= 0; const c = positive ? 'var(--buy)' : 'var(--sell)'; const cs = positive ? 'var(--buy-soft)' : 'var(--sell-soft)'; const arrow = positive ? '▲' : '▼'; return ( {showArrow && {arrow}} {positive ? '+' : ''}{value.toFixed(2)}{suffix} ); } /* ─── IconButton ──────────────────────────────────────────── */ function IconButton({ children, label, onClick, active }) { return ( ); } /* ─── Segmented (radio) ───────────────────────────────────── */ function Segmented({ value, onChange, options, size = 'md' }) { return (
{options.map(opt => { const v = typeof opt === 'string' ? opt : opt.value; const l = typeof opt === 'string' ? opt : opt.label; return ( ); })}
); } /* ─── Sparkline ───────────────────────────────────────────── */ function Sparkline({ data, w = 80, h = 24, color = 'var(--text-2)', fill, strokeWidth = 1.5, fullWidth }) { if (!data || data.length < 2) return null; const W = fullWidth ? 400 : w; const min = Math.min(...data), max = Math.max(...data); const range = max - min || 1; const pts = data.map((v, i) => { const x = (i / (data.length - 1)) * W; const y = h - ((v - min) / range) * h; return [x, y]; }); const d = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(2)},${p[1].toFixed(2)}` : `L${p[0].toFixed(2)},${p[1].toFixed(2)}`)).join(' '); const dFill = fill ? `${d} L${W},${h} L0,${h} Z` : null; const sty = fullWidth ? { display: 'block', width: '100%', height: h } : { display: 'block', overflow: 'visible' }; const gradId = `sp-${Math.random().toString(36).slice(2, 8)}`; return ( {fill && ( )} {dFill && } ); } /* ─── GaugeRing ───────────────────────────────────────────── */ function GaugeRing({ pct, color = 'var(--accent)', size = 56, thick = 5, big = false, label }) { const r = (size - thick) / 2; const c = 2 * Math.PI * r; const clamped = Math.max(0, Math.min(100, pct)); const offset = c * (1 - clamped / 100); return ( {big ? ( <> {Math.round(clamped)}% {label && ( {label} )} ) : ( {Math.round(clamped)} )} ); } /* ─── SentimentBadge ──────────────────────────────────────── */ function SentimentBadge({ sentiment }) { const meta = { POSITIVE: { label: 'POS', c: 'var(--buy)' }, NEGATIVE: { label: 'NEG', c: 'var(--sell)' }, NEUTRAL: { label: 'NEU', c: 'var(--text-3)' }, NOT_DETECTED: { label: 'N/D', c: 'var(--text-4)' }, SHORT_NEWS: { label: 'SHORT', c: 'var(--info)' }, }[sentiment] || { label: sentiment, c: 'var(--text-3)' }; return ( {meta.label} ); } function sentimentColor(s) { return { POSITIVE: 'var(--buy)', NEGATIVE: 'var(--sell)', NEUTRAL: 'var(--text-3)', NOT_DETECTED: 'var(--text-4)', SHORT_NEWS: 'var(--info)', }[s] || 'var(--text-3)'; } /* ─── LineChart (with markers) ─────────────────────────────── */ function LineChart({ data, // [{t: Date, v: number, kr?: number, us?: number}] height = 200, showMarkers = true, markers = [], // [{t, type:'BUY'|'SELL', symbol}] showKRUS = false, ariaLabel = '', }) { const containerRef = useRef(null); const [w, setW] = useState(640); const [hover, setHover] = useState(null); useEffect(() => { if (!containerRef.current) return; const ro = new ResizeObserver((entries) => { const cw = entries[0].contentRect.width; setW(Math.max(200, cw)); }); ro.observe(containerRef.current); return () => ro.disconnect(); }, []); if (!data || data.length === 0) return
; const padTop = 16, padBot = 24, padX = 8; const innerW = w - padX * 2; const innerH = height - padTop - padBot; const ts = data.map(d => +d.t); const tMin = ts[0], tMax = ts[ts.length - 1]; const tRange = tMax - tMin || 1; const series = showKRUS ? [ { key: 'kr', color: 'var(--m-kr)', label: 'KR', values: data.map(d => d.kr) }, { key: 'us', color: 'var(--m-us)', label: 'US', values: data.map(d => d.us) }, ] : [ { key: 'v', color: 'var(--accent)', label: 'TOTAL', values: data.map(d => d.v) }, ]; const allVals = series.flatMap(s => s.values).filter(v => v != null); const vMin = Math.min(...allVals); const vMax = Math.max(...allVals); const vPad = (vMax - vMin) * 0.08 || 1; const yMin = vMin - vPad, yMax = vMax + vPad; const yRange = yMax - yMin || 1; const xAt = (t) => padX + ((+t - tMin) / tRange) * innerW; const yAt = (v) => padTop + (1 - (v - yMin) / yRange) * innerH; // 5 horizontal grid lines const gridLines = [0, 0.25, 0.5, 0.75, 1].map(p => { const v = yMin + p * yRange; return { y: padTop + (1 - p) * innerH, v }; }); // pointer move const onMove = (e) => { const rect = e.currentTarget.getBoundingClientRect(); const x = e.clientX - rect.left; const tFrac = (x - padX) / innerW; const idx = Math.max(0, Math.min(data.length - 1, Math.round(tFrac * (data.length - 1)))); setHover(idx); }; const onLeave = () => setHover(null); return (
onMove(e.touches[0])} onTouchEnd={onLeave}> {/* gridlines */} {gridLines.map((g, i) => ( ))} {/* series */} {series.map(s => { const pts = data.map((d, i) => { const v = s.values[i]; if (v == null) return null; return [xAt(d.t), yAt(v)]; }).filter(Boolean); const d = pts.map((p, i) => (i === 0 ? `M${p[0].toFixed(2)},${p[1].toFixed(2)}` : `L${p[0].toFixed(2)},${p[1].toFixed(2)}`)).join(' '); const dFill = `${d} L${pts[pts.length-1][0].toFixed(2)},${padTop + innerH} L${pts[0][0].toFixed(2)},${padTop + innerH} Z`; return ( ); })} {/* markers */} {showMarkers && markers.map((m, i) => { const x = xAt(+m.t); // place marker just above/below mid line const idx = data.findIndex(d => +d.t >= +m.t); if (idx < 0) return null; const v = data[idx].v ?? data[idx].kr ?? data[idx].us; if (v == null) return null; const y = yAt(v); const isBuy = m.type === 'BUY'; const offset = isBuy ? -16 : 16; const c = isBuy ? 'var(--buy)' : 'var(--sell)'; return ( {isBuy ? '▲' : '▼'} ); })} {/* hover crosshair */} {hover != null && ( {series.map(s => { const v = s.values[hover]; if (v == null) return null; return ( ); })} )} {/* hover tooltip */} {hover != null && (
{fmtAbsTime(new Date(data[hover].t))}
{series.map(s => (
{s.label} {fmtKRWFull(Math.round(s.values[hover]))}
))}
)}
); } /* ─── PercentBar (for action distribution) ─────────────────── */ function ActionBar({ counts, total }) { // counts: { BUY, SELL, HOLD, UNCERTAIN } const t = total || Object.values(counts).reduce((a,b)=>a+b,0) || 1; const order = ['BUY', 'SELL', 'HOLD', 'UNCERTAIN']; const colors = { BUY: 'var(--buy)', SELL: 'var(--sell)', HOLD: 'var(--hold)', UNCERTAIN: 'var(--uncertain)' }; return (
{order.map(k => { const v = counts[k] || 0; if (v === 0) return null; const pct = (v / t) * 100; return (
); })}
); } /* ─── Toggle (theme) ───────────────────────────────────────── */ function ThemeToggle({ theme, onChange }) { return ( ); } /* ─── PaperBadge — globally visible "모의" mode chip ────────── */ function PaperBadge() { return PAPER MODE; } /* ─── JSONViewer ───────────────────────────────────────────── */ function JSONViewer({ data, collapsed = false }) { const [open, setOpen] = useState(!collapsed); const str = useMemo(() => JSON.stringify(data, null, 2), [data]); const onCopy = (e) => { e.stopPropagation(); navigator.clipboard?.writeText(str); }; return (
{open &&
{syntaxHighlight(str)}
}
); } function syntaxHighlight(json) { // server-friendly highlight by wrapping spans const parts = []; const regex = /"(\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(\.\d+)?([eE][+-]?\d+)?/g; let last = 0; let key = 0; json.replace(regex, (m, _g1, _g2, _g3, _g4, _g5, idx) => { if (idx > last) parts.push({json.slice(last, idx)}); let cls = 'jv-num'; if (m.startsWith('"')) cls = m.endsWith(':') ? 'jv-key' : 'jv-str'; else if (m === 'true' || m === 'false') cls = 'jv-bool'; else if (m === 'null') cls = 'jv-null'; parts.push({m}); last = idx + m.length; return m; }); if (last < json.length) parts.push({json.slice(last)}); return parts; } /* ─── Export ───────────────────────────────────────────────── */ Object.assign(window, { cx, fmtKRW, fmtKRWFull, fmtUSD, fmtPct, fmtSigned, relTime, fmtAbsTime, fmtTimeShort, fmtTimeFull, ActionBadge, MarketBadge, SeverityBadge, DeltaChip, IconButton, Segmented, Sparkline, LineChart, ActionBar, ThemeToggle, PaperBadge, JSONViewer, GaugeRing, SentimentBadge, sentimentColor, });