// viz.jsx — 공유 시각화 컴포넌트 // 시계 다이얼, 트리맵, 레이스 차트, 점 타임라인, 목표 링 등 const { useState: useVizState, useMemo: useVizMemo, useRef: useVizRef, useEffect: useVizEffect } = React; /* ──────────────────────────────────────────────────────────── ClockDial — 24시간 시계 다이얼 jobs: [{ t: Date, label, color, key }] ──────────────────────────────────────────────────────────── */ function ClockDial({ now, jobs = [], size = 220 }) { const cx_ = size / 2, cy = size / 2; const rOuter = size / 2 - 6; const rInner = rOuter - 8; const rTick = rOuter - 4; const rJob = rInner - 14; const angleOf = (date) => { const baseDate = new Date(date); const h = baseDate.getHours() + baseDate.getMinutes()/60; return (h / 24) * 2 * Math.PI - Math.PI/2; }; const angleNow = angleOf(now); const nowX = cx_ + (rInner - 6) * Math.cos(angleNow); const nowY = cy + (rInner - 6) * Math.sin(angleNow); // hours: 6, 12, 18 labels const labels = [ { h: 0, txt: '0' }, { h: 6, txt: '6' }, { h: 12, txt: '12' }, { h: 18, txt: '18' }, ]; const [hover, setHover] = useVizState(null); return (
{/* outer ring */} {/* hour ticks every 1h */} {Array.from({length: 24}, (_, i) => { const a = (i/24)*2*Math.PI - Math.PI/2; const major = i % 6 === 0; const x1 = cx_ + (rTick - (major ? 6 : 3)) * Math.cos(a); const y1 = cy + (rTick - (major ? 6 : 3)) * Math.sin(a); const x2 = cx_ + rTick * Math.cos(a); const y2 = cy + rTick * Math.sin(a); return ; })} {/* labels */} {labels.map(l => { const a = (l.h/24)*2*Math.PI - Math.PI/2; const x = cx_ + (rTick - 18) * Math.cos(a); const y = cy + (rTick - 18) * Math.sin(a); return ( {l.txt} ); })} {/* job arcs/markers */} {jobs.map((j, i) => { const a = angleOf(j.t); const x = cx_ + rJob * Math.cos(a); const y = cy + rJob * Math.sin(a); const isFuture = (+new Date(j.t) > +new Date(now)); return ( setHover(i)} onMouseLeave={() => setHover(null)} onTouchStart={() => setHover(i)} style={{ cursor: 'pointer' }}> {/* line from inner ring to job marker */} ); })} {/* now hand */} {hover != null && (
{jobs[hover].label}
{fmtTimeShort(new Date(jobs[hover].t))} · {relTime(new Date(jobs[hover].t))}
)}
); } /* ──────────────────────────────────────────────────────────── Treemap — 사각형 자산 분포 slices: [{ label, value, color, market, sym? }] ──────────────────────────────────────────────────────────── */ function Treemap({ slices, width = 600, height = 320, onCellClick }) { // squarified treemap (simple) const total = slices.reduce((a, b) => a + b.value, 0) || 1; const sorted = [...slices].sort((a, b) => b.value - a.value); // Build using simple slice-and-dice with row balancing const cells = useVizMemo(() => { const result = []; const layout = (items, x, y, w, h) => { if (items.length === 0) return; if (items.length === 1) { result.push({ ...items[0], x, y, w, h }); return; } const sum = items.reduce((a, b) => a + b.value, 0); // split into 2 groups let acc = 0, split = 0; for (let i = 0; i < items.length; i++) { acc += items[i].value; if (acc >= sum / 2) { split = i + 1; break; } } split = Math.max(1, Math.min(items.length - 1, split)); const g1 = items.slice(0, split); const g2 = items.slice(split); const r1 = g1.reduce((a,b)=>a+b.value,0) / sum; if (w >= h) { const w1 = w * r1; layout(g1, x, y, w1, h); layout(g2, x + w1, y, w - w1, h); } else { const h1 = h * r1; layout(g1, x, y, w, h1); layout(g2, x, y + h1, w, h - h1); } }; layout(sorted, 0, 0, width, height); return result; }, [sorted, width, height]); return (
{cells.map((c, i) => { const pct = (c.value / total) * 100; const showLabel = c.w > 60 && c.h > 36; const showVal = c.w > 80 && c.h > 56; return ( onCellClick?.(c)} style={{ cursor: onCellClick ? 'pointer' : 'default' }}> {c.label} — {pct.toFixed(1)}% {showLabel && ( <> {c.label} {pct.toFixed(1)}% {showVal && ( {fmtKRW(c.value)}원 )} )} ); })}
); } /* ──────────────────────────────────────────────────────────── GoalRing — 목표 진행 큰 링 (트랙) target: { current, goal, currency, label, prev, mode } ──────────────────────────────────────────────────────────── */ function GoalRing({ current, goal, prev = 0, label, sub, currency = 'KRW', size = 180, color = 'var(--accent)' }) { const range = goal - prev; const progress = Math.max(0, Math.min(1, (current - prev) / (range || 1))); const pct = progress * 100; const r = (size - 14) / 2; const c = 2 * Math.PI * r; const offset = c * (1 - progress); const fmt = (n) => currency === 'KRW' ? fmtKRW(n) + '원' : '$' + fmtUSD(n); const reached = current >= goal; return (
{/* track */} {/* progress */} {/* tick at 0 */} {/* tick at goal (top) */} {reached && }
{label &&
{label}
}
{Math.round(pct)}%
{fmt(current)}
/ {fmt(goal)}
); } /* ──────────────────────────────────────────────────────────── GoalLadder — 사다리: 다음 5개 목표 ──────────────────────────────────────────────────────────── */ function GoalLadder({ current, goals, currency = 'KRW' }) { // goals: [number...] ascending const fmt = (n) => currency === 'KRW' ? fmtKRW(n) + '원' : '$' + fmtUSD(n); const reachedIdx = goals.findIndex(g => current < g); const targetIdx = reachedIdx < 0 ? goals.length - 1 : reachedIdx; return (
{goals.map((g, i) => { const isReached = current >= g; const isCurrent = i === targetIdx && !isReached; return (
{fmt(g)} {isReached && } {isCurrent && ( )}
); })}
); } /* ──────────────────────────────────────────────────────────── DotTimeline — 가로축 시간, 세로축 심각도, 컬러 점 items: [{ t: Date, sev: 'CRITICAL'|'ERROR'|'WARN'|'INFO', id }] ──────────────────────────────────────────────────────────── */ function DotTimeline({ items, now, range = '24h', height = 120, onDotClick }) { const sevLanes = ['CRITICAL', 'ERROR', 'WARN', 'INFO']; const sevColor = { CRITICAL: 'var(--critical)', ERROR: 'var(--buy)', WARN: 'var(--warn)', INFO: 'var(--info)', }; const hours = { '24h': 24, '7d': 24*7, '30d': 24*30 }[range] || 24; const ms = hours * 3600 * 1000; const tEnd = +new Date(now); const tStart = tEnd - ms; const visible = items.filter(p => +p.t >= tStart && +p.t <= tEnd); const padL = 56, padR = 12, padT = 8, padB = 24; // ticks const ticks = useVizMemo(() => { if (range === '24h') { const arr = []; for (let h = 0; h <= 24; h += 4) { const t = tEnd - h * 3600 * 1000; arr.push({ t, label: `${h}h` }); } return arr.reverse(); } else if (range === '7d') { const arr = []; for (let d = 0; d <= 7; d += 1) { const t = tEnd - d * 24*3600*1000; arr.push({ t, label: `${d}d` }); } return arr.reverse(); } else { const arr = []; for (let d = 0; d <= 30; d += 5) { const t = tEnd - d * 24*3600*1000; arr.push({ t, label: `${d}d` }); } return arr.reverse(); } }, [range, tEnd]); return (
{/* lanes */} {sevLanes.map((s, i) => { const y = padT + ((i + 0.5) / sevLanes.length) * (height - padT - padB); return ( {s} ); })} {/* x-axis ticks */} {ticks.map((tk, i) => { const x = padL + ((tk.t - tStart) / ms) * (800 - padL - padR); return ( {tk.label} ); })} {/* now line */} now {/* dots */} {visible.map((p) => { const x = padL + ((+p.t - tStart) / ms) * (800 - padL - padR); const laneIdx = sevLanes.indexOf(p.sev); if (laneIdx < 0) return null; const y = padT + ((laneIdx + 0.5) / sevLanes.length) * (height - padT - padB); return ( onDotClick?.(p)}> {p.msg} ); })}
); } /* ──────────────────────────────────────────────────────────── RaceChart — 두 라인 누적 비교 차트 data: [{ d: dayIndex, strategy, baseline }] ──────────────────────────────────────────────────────────── */ function RaceChart({ trace, h = 80, sCol = 'var(--buy)', bCol = 'var(--text-3)', fullWidth = true }) { if (!trace || trace.length < 2) return null; const W = 240; const all = trace.flatMap(d => [d.s, d.b]); const min = Math.min(0, ...all); const max = Math.max(...all); const range = (max - min) || 1; const X = (i) => (i / (trace.length - 1)) * W; const Y = (v) => h - ((v - min) / range) * h; const sPath = trace.map((d, i) => (i === 0 ? `M${X(i).toFixed(2)},${Y(d.s).toFixed(2)}` : `L${X(i).toFixed(2)},${Y(d.s).toFixed(2)}`)).join(' '); const bPath = trace.map((d, i) => (i === 0 ? `M${X(i).toFixed(2)},${Y(d.b).toFixed(2)}` : `L${X(i).toFixed(2)},${Y(d.b).toFixed(2)}`)).join(' '); const zeroY = Y(0); const last = trace[trace.length - 1]; return ( {/* zero baseline */} {/* end markers */} ); } /* ──────────────────────────────────────────────────────────── ToolHistogram — 도구 호출 빈도 막대 counts: { tool_name: number } ──────────────────────────────────────────────────────────── */ function ToolHistogram({ counts, max }) { const entries = Object.entries(counts).sort((a, b) => b[1] - a[1]); const m = max || Math.max(...entries.map(e => e[1]), 1); return (
{entries.map(([k, v]) => (
{k}
{v}
))}
); } Object.assign(window, { ClockDial, Treemap, GoalRing, GoalLadder, DotTimeline, RaceChart, ToolHistogram, });