// 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 (
{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 (
);
}
/* ────────────────────────────────────────────────────────────
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 (
{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 (
);
}
/* ────────────────────────────────────────────────────────────
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 (
);
}
/* ────────────────────────────────────────────────────────────
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]) => (
))}
);
}
Object.assign(window, {
ClockDial, Treemap, GoalRing, GoalLadder, DotTimeline, RaceChart, ToolHistogram,
});