// Chart primitives for the KIS dashboard — Streamlit home_view.py 1:1 port to mobile. // All charts: SVG, dark-mode native, KRX colors (red↑ blue↓), no external libs. // // All three charts (ValueChart, GainChart, PLBarsChart) share a tap-tooltip // pattern: the user touches the chart and a small floating card shows the // value at that point. Used to be always-on text labels (which overlapped // with 12+ months on the bar chart) — replaced everywhere per user request. // // — 3 lines: invested (blue), cost basis (orange dotted), market value (red) // — gain ₩ (red) + margin % (purple dotted), dual axis, ₩0/0% baseline // — monthly or yearly delta bars, red↑ blue↓ // — segmented control [1주 1달 3달 6달 1년] // // All accept a `height` prop. Width auto-fits container via viewBox + preserveAspectRatio. const { useState, useMemo, useRef, useCallback } = React; // ─── shared y-axis tick generator ───────────────────────────── function niceTicks(min, max, count = 4) { if (max === min) { max = min + 1; } const range = max - min; const rough = range / (count - 1); const pow = Math.pow(10, Math.floor(Math.log10(rough))); const norm = rough / pow; let step; if (norm < 1.5) step = 1 * pow; else if (norm < 3) step = 2 * pow; else if (norm < 7) step = 5 * pow; else step = 10 * pow; const lo = Math.floor(min / step) * step; const hi = Math.ceil(max / step) * step; const ticks = []; for (let v = lo; v <= hi + step / 2; v += step) ticks.push(v); return ticks; } function fmtKRWAxis(v) { const abs = Math.abs(v); if (abs >= 1e8) return `${(v/1e8).toFixed(abs >= 1e9 ? 0 : 1)}억`; if (abs >= 1e4) return `${Math.round(v/1e4)}만`; return v.toLocaleString(); } // Full-precision KRW for tooltips — every won shown with thousands separators. // (Axis labels stay abbreviated via fmtKRWAxis to avoid overflow; tooltips are // where the user taps for the exact figure.) function fmtKRWFull(v) { return Math.round(v).toLocaleString(); } // X-axis date helpers. Prefer the backend's REAL per-anchor dates // (dateSeries[period], ISO "YYYY-MM-DD") — the series are NOT one-point-per- // calendar-day; the backend spreads n points evenly across the actual data // span, so synthesizing dates from "generatedAt − k days" was wrong (off after // weekends, wildly wrong for accounts younger than the period). Fall back to // that synthesis only when dates are absent (old payloads / empty data). function makeDateFns(dates, n, period, generatedAt) { const hasDates = Array.isArray(dates) && dates.length === n && n > 0; const isoAtIdx = (i) => { if (hasDates) return dates[i]; const base = generatedAt ? new Date(generatedAt) : new Date(); base.setDate(base.getDate() - (n - 1 - i)); return `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, "0")}-${String(base.getDate()).padStart(2, "0")}`; }; const short = (iso) => { if (!iso) return ""; const p = iso.split("-"); return (period === "1w" || period === "1m") ? `${+p[1]}/${+p[2]}` : `${p[0].slice(2)}.${p[1]}`; }; const full = (iso) => (iso ? iso.replace(/-/g, ".") : ""); return { isoAtIdx, short, full }; } // ─── Tap-tooltip hook ───────────────────────────────────────── // Computes the nearest data index from a pointer x (touch or mouse) and // returns: [idx, handlers, clear]. Pass `n` = data length. // // The chart renders
wrapping the SVG plus // the tooltip. handlers spread onto the wrapper. The tooltip stays // visible until the user pans off the chart (or moves to another point). function useChartTip(n) { const [idx, setIdx] = useState(null); const update = useCallback((e) => { const wrap = e.currentTarget; const rect = wrap.getBoundingClientRect(); const cx = e.touches && e.touches.length ? e.touches[0].clientX : (e.clientX != null ? e.clientX : null); if (cx == null) return; const frac = Math.max(0, Math.min(1, (cx - rect.left) / Math.max(1, rect.width))); const i = Math.min(n - 1, Math.max(0, Math.round(frac * (n - 1)))); setIdx(i); }, [n]); const handlers = useMemo(() => ({ onTouchStart: update, onTouchMove: update, onMouseDown: update, onMouseMove: (e) => { if (e.buttons) update(e); }, onMouseLeave: () => setIdx(null), onTouchEnd: () => { /* keep showing for the last touched point */ }, }), [update]); return [idx, handlers, () => setIdx(null)]; } // ─── Floating tooltip card ──────────────────────────────────── // Positioned absolutely inside the chart wrapper. `xPct` is the horizontal // position (0-100) and `top` is a CSS top in px. Auto-clamps so it doesn't // hang off the right edge of narrow phone screens. function ChartTip({ xPct, top, label, lines }) { if (xPct == null) return null; // Clamp so the tooltip box stays within the chart bounds. const left = Math.max(2, Math.min(98, xPct)); return (
60 ? 100 : (left < 30 ? 0 : 50)}%)`, background: "var(--surface-elevated)", border: "1px solid var(--border)", borderRadius: 8, padding: "6px 10px", pointerEvents: "none", whiteSpace: "nowrap", zIndex: 5, boxShadow: "0 4px 14px rgba(25,31,40,0.12)", fontFamily: "var(--font-num)", }}> {label && (
{label}
)} {lines.map((ln, i) => (
{ln.text}
))}
); } // ─── Period toggle (1주/1달/3달/6달/1년) ───────────────────────────── function PeriodToggle({ value, onChange, periods = ["1w", "1m", "3m", "6m", "1y"] }) { const labels = { "1w": "1주", "1m": "1달", "3m": "3달", "6m": "6달", "1y": "1년" }; return (
{periods.map(p => ( ))}
); } // ─── ValueChart: 3-line value vs invested vs cost basis ───────────── // red = market value (the "answer") // blue = net invested // orange = cost basis of currently-held shares function ValueChart({ valueSeries, costSeries, investedSeries, period, dates, generatedAt, height = 200 }) { const w = 320, h = height, padL = 4, padR = 4, padT = 16, padB = 38; const inner = { x: padL, y: padT, w: w - padL - padR, h: h - padT - padB }; const n = (valueSeries || []).length; const [tipIdx, tipHandlers] = useChartTip(n); // Real per-anchor dates from the backend (dateSeries[period]); fall back to // generatedAt synthesis only if absent. const { isoAtIdx, short: fmtDateShort, full: fmtDateFull } = makeDateFns(dates, n, period, generatedAt); if (!valueSeries || n === 0) { return (
자산 데이터 없음
); } const all = [...valueSeries, ...(costSeries || []), ...(investedSeries || [])]; const min = Math.min(...all); const max = Math.max(...all); const ticks = niceTicks(min, max, 3); const yMin = Math.min(min, ticks[0]); const yMax = Math.max(max, ticks[ticks.length - 1]); const xAt = (i, len) => inner.x + (i / Math.max(1, len - 1)) * inner.w; const yAt = (v) => inner.y + (1 - (v - yMin) / Math.max(1, yMax - yMin)) * inner.h; const path = (arr) => arr.map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i, arr.length).toFixed(2)} ${yAt(v).toFixed(2)}` ).join(" "); const areaPath = `${path(valueSeries)} L${xAt(n - 1, n).toFixed(2)} ${yAt(yMin).toFixed(2)} L${xAt(0, n).toFixed(2)} ${yAt(yMin).toFixed(2)} Z`; const lastV = valueSeries[n - 1]; const tipXPct = tipIdx != null ? (xAt(tipIdx, n) / w) * 100 : null; return (
{ticks.map(t => ( {fmtKRWAxis(t)} ))} {investedSeries && ( )} {costSeries && ( )} {/* Tap indicator: vertical line + dot at touched x. */} {tipIdx != null && ( <> )} {/* X-axis date labels: 4 ticks evenly spaced including start and end */} {n >= 2 && (() => { const tickCount = 4; const ticks = []; for (let k = 0; k < tickCount; k++) { const idx = Math.round((k * (n - 1)) / (tickCount - 1)); const x = xAt(idx, n); const anchor = k === 0 ? "start" : k === tickCount - 1 ? "end" : "middle"; ticks.push( {fmtDateShort(isoAtIdx(idx))} ); } return ticks; })()} 시가 원가 투입 {tipIdx != null && ( )}
); } // ─── GainChart: gain ₩ + margin % dual axis ───────────── function GainChart({ gainSeries, marginSeries, period, dates, generatedAt, height = 180 }) { const w = 320, h = height, padL = 4, padR = 4, padT = 14, padB = 38; const inner = { x: padL, y: padT, w: w - padL - padR, h: h - padT - padB }; const n = (gainSeries || []).length; const [tipIdx, tipHandlers] = useChartTip(n); const { isoAtIdx, short: fmtDateShort, full: fmtDateFull } = makeDateFns(dates, n, period, generatedAt); if (!gainSeries || n === 0) { return (
이익 데이터 없음
); } const gMin = Math.min(0, ...gainSeries); const gMax = Math.max(0, ...gainSeries); const mMin = Math.min(0, ...marginSeries) / 100; const mMax = Math.max(0, ...marginSeries) / 100; function aligned(lo, hi, otherLo, otherHi) { const lFrac = hi > lo ? (-lo / (hi - lo)) : 0.5; const rFrac = otherHi > otherLo ? (-otherLo / (otherHi - otherLo)) : 0.5; const zf = Math.min(0.95, Math.max(0.05, Math.max(lFrac, rFrac))); const span = Math.max(hi / Math.max(1e-9, 1 - zf), -lo / Math.max(1e-9, zf)); return [-span * zf, span * (1 - zf)]; } const [gLo, gHi] = aligned(gMin, gMax, mMin, mMax); const [mLo, mHi] = aligned(mMin, mMax, gMin, gMax); const yAtG = (v) => inner.y + (1 - (v - gLo) / Math.max(1, gHi - gLo)) * inner.h; const yAtM = (v) => inner.y + (1 - (v - mLo) / Math.max(0.0001, mHi - mLo)) * inner.h; const xAt = (i, len) => inner.x + (i / Math.max(1, len - 1)) * inner.w; const pathG = gainSeries.map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i, n).toFixed(2)} ${yAtG(v).toFixed(2)}`).join(" "); const pathM = marginSeries.map((v, i) => `${i === 0 ? "M" : "L"}${xAt(i, n).toFixed(2)} ${yAtM(v / 100).toFixed(2)}`).join(" "); const zeroY = yAtG(0); const tipXPct = tipIdx != null ? (xAt(tipIdx, n) / w) * 100 : null; return (
{/* '0' annotation on the shared baseline so a mid-height point is mappable. */} 0 이익 ₩ 마진 % {/* top = max, just above baseline = min, so both axes have two refs. */} {fmtKRWAxis(gMax)} {(mMax).toFixed(1)}% {gMin < 0 && ( {fmtKRWAxis(gMin)} )} {mMin < 0 && ( {(mMin * 100).toFixed(1)}% )} {tipIdx != null && ( <> )} {/* X-axis date labels: 4 ticks evenly spaced */} {n >= 2 && (() => { const tickCount = 4; const ticks = []; for (let k = 0; k < tickCount; k++) { const idx = Math.round((k * (n - 1)) / (tickCount - 1)); const x = xAt(idx, n); const anchor = k === 0 ? "start" : k === tickCount - 1 ? "end" : "middle"; ticks.push( {fmtDateShort(isoAtIdx(idx))} ); } return ticks; })()} 총수익 수익률 {tipIdx != null && ( )}
); } // ─── PLBarsChart: monthly/yearly delta bars (red↑ blue↓) ───────────── // Tap a bar → tooltip with the period label and ₩ value. Bar value labels // removed (used to overlap with 12+ months). function PLBarsChart({ data, xKey, valueKey = "value", height = 160, labelFmt }) { const n = (data || []).length; const [tipIdx, tipHandlers] = useChartTip(n); if (!data || n === 0) { return (
P/L 내역 없음
); } const w = 320, h = height, padL = 28, padR = 8, padT = 12, padB = 28; const inner = { x: padL, y: padT, w: w - padL - padR, h: h - padT - padB }; const vals = data.map(d => d[valueKey]); const min = Math.min(0, ...vals); const max = Math.max(0, ...vals); const ticks = niceTicks(min, max, 3); const yMin = Math.min(min, ticks[0]); const yMax = Math.max(max, ticks[ticks.length - 1]); const xAt = (i) => inner.x + (i + 0.5) * (inner.w / n); const yAt = (v) => inner.y + (1 - (v - yMin) / Math.max(1, yMax - yMin)) * inner.h; const zeroY = yAt(0); const barW = (inner.w / n) * 0.7; // Thin x-axis labels when there are many bars: target ~6 visible labels // so 12 months don't overlap. The tooltip has the full label. const labelStride = Math.max(1, Math.ceil(n / 6)); const tipXPct = tipIdx != null ? (xAt(tipIdx) / w) * 100 : null; return (
{/* Y gridlines */} {ticks.map(t => ( {fmtKRWAxis(t)} ))} {data.map((d, i) => { const v = d[valueKey]; const cx = xAt(i); const y = yAt(v); const isPos = v >= 0; const isHi = i === tipIdx; return ( {/* X-axis tick — show only every labelStride'th to avoid overlap. Always show first + last for context. */} {(i % labelStride === 0 || i === n - 1) && ( {xKey === "month" ? d[xKey].slice(5) : d[xKey]} )} ); })} {tipIdx != null && ( = 0 ? "+" : "−"}₩${fmtKRWFull(Math.abs(data[tipIdx][valueKey]))}`), color: data[tipIdx][valueKey] >= 0 ? "var(--up)" : "var(--down)", }]} /> )}
); } Object.assign(window, { ValueChart, GainChart, PLBarsChart, PeriodToggle, niceTicks, fmtKRWAxis, fmtKRWFull });