From 8eb6e4d47b50b4af7ce0067f53014d06c6aff0ea Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 16 Jun 2026 14:04:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=84=EB=9E=B5=ED=8F=89=EA=B0=80=20?= =?UTF-8?q?=EB=8F=8B=EB=B3=B4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 4 +- frontend/src/components/ChartMagnifier.tsx | 142 +++++++++++------- frontend/src/components/TradingChart.tsx | 2 +- .../StrategyEvaluationChart.tsx | 22 ++- frontend/src/styles/strategyEvaluation.css | 46 ++++-- 5 files changed, 153 insertions(+), 63 deletions(-) diff --git a/frontend/src/App.css b/frontend/src/App.css index 7b8de62..54818ca 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -5630,8 +5630,10 @@ html.desktop-client .tmb-logo-version { gap: 6px; padding: 0 8px; flex-shrink: 0; + cursor: grab; } -.chart-magnifier:active { cursor: grabbing; } +.chart-magnifier-header:active { cursor: grabbing; } +.chart-magnifier:active { cursor: default; } /* 리사이즈 핸들 위에서는 grab 커서 대신 방향 커서 표시 */ .chart-magnifier-handle { cursor: inherit; } diff --git a/frontend/src/components/ChartMagnifier.tsx b/frontend/src/components/ChartMagnifier.tsx index 80c2230..f9e3007 100644 --- a/frontend/src/components/ChartMagnifier.tsx +++ b/frontend/src/components/ChartMagnifier.tsx @@ -10,6 +10,7 @@ * - LIVE 지시등: 캔버스 픽셀 변화 감지 시 점멸 */ import React, { useRef, useEffect, useCallback, useState } from 'react'; +import { createPortal } from 'react-dom'; interface ChartMagnifierProps { /** LWC canvas 들이 들어있는 chart-container div */ @@ -42,6 +43,14 @@ const HANDLES: { id: ResizeDir; cursor: string; style: React.CSSProperties }[] = { id:'w', cursor:'w-resize', style:{ top:'50%', left:-5, transform:'translateY(-50%)' } }, ]; +function nearestStepIndex(steps: number[], cur: number): number { + let best = 0; + for (let i = 1; i < steps.length; i += 1) { + if (Math.abs(steps[i] - cur) < Math.abs(steps[best] - cur)) best = i; + } + return best; +} + const ChartMagnifier: React.FC = ({ containerRef, wrapperRef, enabled, onClose, }) => { @@ -136,9 +145,7 @@ const ChartMagnifier: React.FC = ({ } // ── Y-Zoom: 수직 방향 추가 확대 ─────────────────────────────────────────── - // Y축의 미세한 움직임을 파악할 수 있도록 중앙 기준으로 수직만 재확대 if (yz > 1) { - // 임시 캔버스 초기화 (한 번만 생성) let tmp = tempCvRef.current; if (!tmp || tmp.width !== canvas.width || tmp.height !== canvas.height) { tmp = document.createElement('canvas'); @@ -150,24 +157,20 @@ const ChartMagnifier: React.FC = ({ tCtx.clearRect(0, 0, tmp.width, tmp.height); tCtx.drawImage(canvas, 0, 0); - // 중앙 ±(1/yz) 영역만 잘라 전체 높이로 늘림 const srcYH = Math.round(canvas.height / yz); const srcYT = Math.round((canvas.height - srcYH) / 2); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(tmp, 0, srcYT, canvas.width, srcYH, 0, 0, canvas.width, canvas.height); - // Y-Zoom 배율 워터마크 ctx.fillStyle = 'rgba(255,200,50,0.6)'; ctx.font = `bold ${Math.round(10 * dpr)}px monospace`; ctx.fillText(`Y×${yz}`, 4 * dpr, (canvasH - 4) * dpr); } - // ── 테두리 ───────────────────────────────────────────────────────────────── ctx.strokeStyle = 'rgba(255, 200, 50, 0.35)'; ctx.lineWidth = 1; ctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1); - // ── LIVE 감지: 중앙 세로 라인 픽셀 샘플링으로 변화 감지 ───────────────── try { const midX = Math.floor(canvas.width / 2); const sample = ctx.getImageData(midX, 0, 1, canvas.height).data; @@ -179,7 +182,7 @@ const ChartMagnifier: React.FC = ({ diff += Math.abs(sample[i+1] - prev[i+1]); diff += Math.abs(sample[i+2] - prev[i+2]); } - if (diff > 30) { // 임계값: 미세 잡음 무시 + if (diff > 30) { setIsLive(true); if (liveTimerRef.current) clearTimeout(liveTimerRef.current); liveTimerRef.current = setTimeout(() => setIsLive(false), 600); @@ -189,7 +192,6 @@ const ChartMagnifier: React.FC = ({ } catch { /* getImageData 실패 무시 */ } }, [containerRef, wrapperRef]); - // enabled 시 wrapper 위치 기준으로 초기 위치 설정 useEffect(() => { if (!enabled) return; const p = initPos(); @@ -197,7 +199,6 @@ const ChartMagnifier: React.FC = ({ setPos(p); }, [enabled, initPos]); - // rAF 루프 useEffect(() => { if (!enabled) { cancelAnimationFrame(rafRef.current); return; } const loop = () => { renderFrame(); rafRef.current = requestAnimationFrame(loop); }; @@ -205,23 +206,26 @@ const ChartMagnifier: React.FC = ({ return () => cancelAnimationFrame(rafRef.current); }, [enabled, renderFrame]); - // ── 드래그 (헤더) ───────────────────────────────────────────────────────── + // ── 드래그 (헤더만) ─────────────────────────────────────────────────────── const dragStart = useRef<{ mx: number; my: number; px: number; py: number } | null>(null); const onHeaderDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; e.preventDefault(); + e.stopPropagation(); dragStart.current = { mx: e.clientX, my: e.clientY, px: posRef.current.x, py: posRef.current.y }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }, []); const onHeaderMove = useCallback((e: React.PointerEvent) => { if (!dragStart.current) return; + e.preventDefault(); + e.stopPropagation(); const dx = e.clientX - dragStart.current.mx; const dy = e.clientY - dragStart.current.my; const vw = window.innerWidth; const vh = window.innerHeight; const { w, h } = sizeRef.current; - // 헤더가 항상 화면 안에 있도록 최소 마진만 유지 (위아래 자유 이동) const np = { x: Math.max(-(w - 60), Math.min(vw - 60, dragStart.current.px + dx)), y: Math.max(-(h - HEADER_H), Math.min(vh - HEADER_H, dragStart.current.py + dy)), @@ -230,7 +234,11 @@ const ChartMagnifier: React.FC = ({ setPos(np); }, []); - const onHeaderUp = useCallback(() => { dragStart.current = null; }, []); + const onHeaderUp = useCallback((e: React.PointerEvent) => { + if (!dragStart.current) return; + e.stopPropagation(); + dragStart.current = null; + }, []); // ── 리사이즈 핸들 ────────────────────────────────────────────────────────── const resizeStart = useRef<{ @@ -239,7 +247,8 @@ const ChartMagnifier: React.FC = ({ } | null>(null); const onHandleDown = useCallback((e: React.PointerEvent, dir: ResizeDir) => { - e.preventDefault(); e.stopPropagation(); + e.preventDefault(); + e.stopPropagation(); resizeStart.current = { dir, mx: e.clientX, my: e.clientY, px: posRef.current.x, py: posRef.current.y, @@ -249,56 +258,70 @@ const ChartMagnifier: React.FC = ({ }, []); const onHandleMove = useCallback((e: React.PointerEvent) => { - const rs = resizeStart.current; if (!rs) return; + const rs = resizeStart.current; + if (!rs) return; + e.preventDefault(); + e.stopPropagation(); const { dir, mx, my, px, py, pw, ph } = rs; - const dx = e.clientX - mx; const dy = e.clientY - my; + const dx = e.clientX - mx; + const dy = e.clientY - my; let nx = px, ny = py, nw = pw, nh = ph; if (dir.includes('e')) { nw = Math.max(MIN_W, pw + dx); } if (dir.includes('w')) { nw = Math.max(MIN_W, pw - dx); nx = px + pw - nw; } if (dir.includes('s')) { nh = Math.max(MIN_H, ph + dy); } if (dir.includes('n')) { nh = Math.max(MIN_H, ph - dy); ny = py + ph - nh; } - // 리사이즈 시 뷰포트 경계 안에서만 클램프 (헤더 마진 확보) nx = Math.max(-(nw - 60), Math.min(window.innerWidth - 60, nx)); ny = Math.max(-(nh - HEADER_H), Math.min(window.innerHeight - HEADER_H, ny)); posRef.current = { x: nx, y: ny }; sizeRef.current = { w: nw, h: nh }; setPos ({ x: nx, y: ny }); setSize({ w: nw, h: nh }); - }, [wrapperRef]); - - const onHandleUp = useCallback(() => { resizeStart.current = null; }, []); - - // ── XY-Zoom 조절 ────────────────────────────────────────────────────────── - const adjustXyZoom = useCallback((delta: number) => { - const cur = xyZoomRef.current; - const idx = XY_STEPS.findIndex(s => s >= cur - 0.01); - const next = XY_STEPS[Math.max(0, Math.min(XY_STEPS.length - 1, idx + delta))]; - xyZoomRef.current = next; setXyZoom(next); }, []); - // ── Y-Zoom 조절 ─────────────────────────────────────────────────────────── + const onHandleUp = useCallback((e: React.PointerEvent) => { + e.stopPropagation(); + resizeStart.current = null; + }, []); + + const stopChartPointer = useCallback((e: React.PointerEvent | React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }, []); + + // ── XY-Zoom / Y-Zoom 조절 ───────────────────────────────────────────────── + const adjustXyZoom = useCallback((delta: number) => { + const cur = xyZoomRef.current; + const idx = nearestStepIndex(XY_STEPS, cur); + const next = XY_STEPS[Math.max(0, Math.min(XY_STEPS.length - 1, idx + delta))]; + if (next === cur) return; + xyZoomRef.current = next; + setXyZoom(next); + }, []); + const adjustYZoom = useCallback((delta: number) => { const cur = yZoomRef.current; - const idx = Y_STEPS.findIndex(s => s >= cur - 0.01); + const idx = nearestStepIndex(Y_STEPS, cur); const next = Y_STEPS[Math.max(0, Math.min(Y_STEPS.length - 1, idx + delta))]; - yZoomRef.current = next; setYZoom(next); + if (next === cur) return; + yZoomRef.current = next; + setYZoom(next); }, []); if (!enabled) return null; - return ( + const magnifier = (
- {/* ── 헤더 ──────────────────────────────────────────────── */}
- {/* 아이콘 + 제목 */} @@ -308,41 +331,56 @@ const ChartMagnifier: React.FC = ({ 확대경 - {/* LIVE 지시등 */} LIVE - {/* XY Zoom 컨트롤 */}
- + {xyZoom}× - +
- {/* Y-Zoom 컨트롤 */}
- + Y{yZoom}× - +
- {/* 닫기 */} - +
- {/* ── 확대 canvas ───────────────────────────────────────── */} - {/* ── 리사이즈 핸들 (8방향) ─────────────────────────────── */} {HANDLES.map(h => (
= ({ ))}
); + + return createPortal(magnifier, document.body); }; export default ChartMagnifier; diff --git a/frontend/src/components/TradingChart.tsx b/frontend/src/components/TradingChart.tsx index bd7fe9f..a10cf0e 100644 --- a/frontend/src/components/TradingChart.tsx +++ b/frontend/src/components/TradingChart.tsx @@ -1142,7 +1142,7 @@ const TradingChart: React.FC = ({ const onPanPointerDown = (e: PointerEvent) => { if (!canPanRef.current || e.button !== 0) return; const target = e.target as HTMLElement | null; - if (target?.closest('[data-no-chart-pan], .chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) { + if (target?.closest('[data-no-chart-pan], .chart-magnifier, .chart-right-toolbar, .pane-legend-item, .pane-drag-handle, .pane-btn, .candle-pane-controls')) { return; } const pt = isChartPlotPointer(e.clientX, e.clientY); diff --git a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx index df64f38..9724add 100644 --- a/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx +++ b/frontend/src/components/strategyEvaluation/StrategyEvaluationChart.tsx @@ -116,6 +116,7 @@ const StrategyEvaluationChart: React.FC = ({ const [signals, setSignals] = useState([]); const [backtestRunning, setBacktestRunning] = useState(false); const [backtestError, setBacktestError] = useState(null); + const [magnifierActive, setMagnifierActive] = useState(false); const marketBtnRef = useRef(null); const managerRef = useRef(null); const backtestGenRef = useRef(0); @@ -460,7 +461,24 @@ const StrategyEvaluationChart: React.FC = ({ 시그널 계산 중… )}
- 캔들 클릭 · 세로 영역 드래그 · ← → 키 · 하단 툴바로 분석 봉 이동 +
+ +
@@ -506,6 +524,8 @@ const StrategyEvaluationChart: React.FC = ({ showHoverToolbar={false} showChartRightToolbar={false} showCandlePaneControls={false} + magnifierEnabled={magnifierActive} + onMagnifierClose={() => setMagnifierActive(false)} resolveInitialDisplayCount={resolveInitialDisplayCount} /> {chartMgr && ( diff --git a/frontend/src/styles/strategyEvaluation.css b/frontend/src/styles/strategyEvaluation.css index bde0ca2..713e45d 100644 --- a/frontend/src/styles/strategyEvaluation.css +++ b/frontend/src/styles/strategyEvaluation.css @@ -244,10 +244,44 @@ flex-shrink: 0; } -.seval-chart-hint { - font-size: 0.66rem; +.seval-chart-toolbar-actions { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.seval-chart-magnifier-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + border: 1px solid var(--btd-divider, var(--se-border)); + border-radius: 6px; + background: var(--btd-surface, var(--se-input-bg)); color: var(--se-text-muted); - white-space: nowrap; + cursor: pointer; + flex-shrink: 0; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} + +.seval-chart-magnifier-btn:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 45%, var(--se-border)); + color: var(--btd-gold, var(--se-gold)); + background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 10%, var(--se-input-bg)); +} + +.seval-chart-magnifier-btn--active { + border-color: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 55%, var(--se-border)); + color: var(--btd-gold, var(--se-gold)); + background: color-mix(in srgb, var(--btd-gold, var(--se-gold)) 18%, var(--se-input-bg)); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--btd-gold, var(--se-gold)) 25%, transparent); +} + +.seval-chart-magnifier-btn:disabled { + opacity: 0.4; + cursor: not-allowed; } /* btd-analysis-canvas-wrap--live: tv-chart-wrap 높이 (backtestDashboard.css) */ @@ -1621,9 +1655,3 @@ html.theme-light .seval-analyze-btn:hover:not(:disabled), border-color: rgba(25, 118, 210, 0.45); background: rgba(25, 118, 210, 0.08); } - -@media (max-width: 960px) { - .seval-chart-hint { - display: none; - } -}