// 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 (
);
}
/* ─── 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 (
);
}
/* ─── 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 (
{/* 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,
});