전략평가 돋보기 기능 추가
This commit is contained in:
@@ -5630,8 +5630,10 @@ html.desktop-client .tmb-logo-version {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
.chart-magnifier:active { cursor: grabbing; }
|
.chart-magnifier-header:active { cursor: grabbing; }
|
||||||
|
.chart-magnifier:active { cursor: default; }
|
||||||
/* 리사이즈 핸들 위에서는 grab 커서 대신 방향 커서 표시 */
|
/* 리사이즈 핸들 위에서는 grab 커서 대신 방향 커서 표시 */
|
||||||
.chart-magnifier-handle { cursor: inherit; }
|
.chart-magnifier-handle { cursor: inherit; }
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
* - LIVE 지시등: 캔버스 픽셀 변화 감지 시 점멸
|
* - LIVE 지시등: 캔버스 픽셀 변화 감지 시 점멸
|
||||||
*/
|
*/
|
||||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
interface ChartMagnifierProps {
|
interface ChartMagnifierProps {
|
||||||
/** LWC canvas 들이 들어있는 chart-container div */
|
/** 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%)' } },
|
{ 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<ChartMagnifierProps> = ({
|
const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
||||||
containerRef, wrapperRef, enabled, onClose,
|
containerRef, wrapperRef, enabled, onClose,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -136,9 +145,7 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Y-Zoom: 수직 방향 추가 확대 ───────────────────────────────────────────
|
// ── Y-Zoom: 수직 방향 추가 확대 ───────────────────────────────────────────
|
||||||
// Y축의 미세한 움직임을 파악할 수 있도록 중앙 기준으로 수직만 재확대
|
|
||||||
if (yz > 1) {
|
if (yz > 1) {
|
||||||
// 임시 캔버스 초기화 (한 번만 생성)
|
|
||||||
let tmp = tempCvRef.current;
|
let tmp = tempCvRef.current;
|
||||||
if (!tmp || tmp.width !== canvas.width || tmp.height !== canvas.height) {
|
if (!tmp || tmp.width !== canvas.width || tmp.height !== canvas.height) {
|
||||||
tmp = document.createElement('canvas');
|
tmp = document.createElement('canvas');
|
||||||
@@ -150,24 +157,20 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
tCtx.clearRect(0, 0, tmp.width, tmp.height);
|
tCtx.clearRect(0, 0, tmp.width, tmp.height);
|
||||||
tCtx.drawImage(canvas, 0, 0);
|
tCtx.drawImage(canvas, 0, 0);
|
||||||
|
|
||||||
// 중앙 ±(1/yz) 영역만 잘라 전체 높이로 늘림
|
|
||||||
const srcYH = Math.round(canvas.height / yz);
|
const srcYH = Math.round(canvas.height / yz);
|
||||||
const srcYT = Math.round((canvas.height - srcYH) / 2);
|
const srcYT = Math.round((canvas.height - srcYH) / 2);
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.drawImage(tmp, 0, srcYT, canvas.width, srcYH, 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.fillStyle = 'rgba(255,200,50,0.6)';
|
||||||
ctx.font = `bold ${Math.round(10 * dpr)}px monospace`;
|
ctx.font = `bold ${Math.round(10 * dpr)}px monospace`;
|
||||||
ctx.fillText(`Y×${yz}`, 4 * dpr, (canvasH - 4) * dpr);
|
ctx.fillText(`Y×${yz}`, 4 * dpr, (canvasH - 4) * dpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 테두리 ─────────────────────────────────────────────────────────────────
|
|
||||||
ctx.strokeStyle = 'rgba(255, 200, 50, 0.35)';
|
ctx.strokeStyle = 'rgba(255, 200, 50, 0.35)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1);
|
ctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1);
|
||||||
|
|
||||||
// ── LIVE 감지: 중앙 세로 라인 픽셀 샘플링으로 변화 감지 ─────────────────
|
|
||||||
try {
|
try {
|
||||||
const midX = Math.floor(canvas.width / 2);
|
const midX = Math.floor(canvas.width / 2);
|
||||||
const sample = ctx.getImageData(midX, 0, 1, canvas.height).data;
|
const sample = ctx.getImageData(midX, 0, 1, canvas.height).data;
|
||||||
@@ -179,7 +182,7 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
diff += Math.abs(sample[i+1] - prev[i+1]);
|
diff += Math.abs(sample[i+1] - prev[i+1]);
|
||||||
diff += Math.abs(sample[i+2] - prev[i+2]);
|
diff += Math.abs(sample[i+2] - prev[i+2]);
|
||||||
}
|
}
|
||||||
if (diff > 30) { // 임계값: 미세 잡음 무시
|
if (diff > 30) {
|
||||||
setIsLive(true);
|
setIsLive(true);
|
||||||
if (liveTimerRef.current) clearTimeout(liveTimerRef.current);
|
if (liveTimerRef.current) clearTimeout(liveTimerRef.current);
|
||||||
liveTimerRef.current = setTimeout(() => setIsLive(false), 600);
|
liveTimerRef.current = setTimeout(() => setIsLive(false), 600);
|
||||||
@@ -189,7 +192,6 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
} catch { /* getImageData 실패 무시 */ }
|
} catch { /* getImageData 실패 무시 */ }
|
||||||
}, [containerRef, wrapperRef]);
|
}, [containerRef, wrapperRef]);
|
||||||
|
|
||||||
// enabled 시 wrapper 위치 기준으로 초기 위치 설정
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const p = initPos();
|
const p = initPos();
|
||||||
@@ -197,7 +199,6 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
setPos(p);
|
setPos(p);
|
||||||
}, [enabled, initPos]);
|
}, [enabled, initPos]);
|
||||||
|
|
||||||
// rAF 루프
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) { cancelAnimationFrame(rafRef.current); return; }
|
if (!enabled) { cancelAnimationFrame(rafRef.current); return; }
|
||||||
const loop = () => { renderFrame(); rafRef.current = requestAnimationFrame(loop); };
|
const loop = () => { renderFrame(); rafRef.current = requestAnimationFrame(loop); };
|
||||||
@@ -205,23 +206,26 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
return () => cancelAnimationFrame(rafRef.current);
|
return () => cancelAnimationFrame(rafRef.current);
|
||||||
}, [enabled, renderFrame]);
|
}, [enabled, renderFrame]);
|
||||||
|
|
||||||
// ── 드래그 (헤더) ─────────────────────────────────────────────────────────
|
// ── 드래그 (헤더만) ───────────────────────────────────────────────────────
|
||||||
const dragStart = useRef<{ mx: number; my: number; px: number; py: number } | null>(null);
|
const dragStart = useRef<{ mx: number; my: number; px: number; py: number } | null>(null);
|
||||||
|
|
||||||
const onHeaderDown = useCallback((e: React.PointerEvent) => {
|
const onHeaderDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
dragStart.current = { mx: e.clientX, my: e.clientY, px: posRef.current.x, py: posRef.current.y };
|
dragStart.current = { mx: e.clientX, my: e.clientY, px: posRef.current.x, py: posRef.current.y };
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onHeaderMove = useCallback((e: React.PointerEvent) => {
|
const onHeaderMove = useCallback((e: React.PointerEvent) => {
|
||||||
if (!dragStart.current) return;
|
if (!dragStart.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
const dx = e.clientX - dragStart.current.mx;
|
const dx = e.clientX - dragStart.current.mx;
|
||||||
const dy = e.clientY - dragStart.current.my;
|
const dy = e.clientY - dragStart.current.my;
|
||||||
const vw = window.innerWidth;
|
const vw = window.innerWidth;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
const { w, h } = sizeRef.current;
|
const { w, h } = sizeRef.current;
|
||||||
// 헤더가 항상 화면 안에 있도록 최소 마진만 유지 (위아래 자유 이동)
|
|
||||||
const np = {
|
const np = {
|
||||||
x: Math.max(-(w - 60), Math.min(vw - 60, dragStart.current.px + dx)),
|
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)),
|
y: Math.max(-(h - HEADER_H), Math.min(vh - HEADER_H, dragStart.current.py + dy)),
|
||||||
@@ -230,7 +234,11 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
setPos(np);
|
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<{
|
const resizeStart = useRef<{
|
||||||
@@ -239,7 +247,8 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const onHandleDown = useCallback((e: React.PointerEvent, dir: ResizeDir) => {
|
const onHandleDown = useCallback((e: React.PointerEvent, dir: ResizeDir) => {
|
||||||
e.preventDefault(); e.stopPropagation();
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
resizeStart.current = {
|
resizeStart.current = {
|
||||||
dir, mx: e.clientX, my: e.clientY,
|
dir, mx: e.clientX, my: e.clientY,
|
||||||
px: posRef.current.x, py: posRef.current.y,
|
px: posRef.current.x, py: posRef.current.y,
|
||||||
@@ -249,56 +258,70 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onHandleMove = useCallback((e: React.PointerEvent) => {
|
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 { 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;
|
let nx = px, ny = py, nw = pw, nh = ph;
|
||||||
if (dir.includes('e')) { nw = Math.max(MIN_W, pw + dx); }
|
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('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('s')) { nh = Math.max(MIN_H, ph + dy); }
|
||||||
if (dir.includes('n')) { nh = Math.max(MIN_H, ph - dy); ny = py + ph - nh; }
|
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));
|
nx = Math.max(-(nw - 60), Math.min(window.innerWidth - 60, nx));
|
||||||
ny = Math.max(-(nh - HEADER_H), Math.min(window.innerHeight - HEADER_H, ny));
|
ny = Math.max(-(nh - HEADER_H), Math.min(window.innerHeight - HEADER_H, ny));
|
||||||
posRef.current = { x: nx, y: ny };
|
posRef.current = { x: nx, y: ny };
|
||||||
sizeRef.current = { w: nw, h: nh };
|
sizeRef.current = { w: nw, h: nh };
|
||||||
setPos ({ x: nx, y: ny });
|
setPos ({ x: nx, y: ny });
|
||||||
setSize({ w: nw, h: nh });
|
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 adjustYZoom = useCallback((delta: number) => {
|
||||||
const cur = yZoomRef.current;
|
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))];
|
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;
|
if (!enabled) return null;
|
||||||
|
|
||||||
return (
|
const magnifier = (
|
||||||
<div
|
<div
|
||||||
className="chart-magnifier"
|
className="chart-magnifier"
|
||||||
|
data-no-chart-pan
|
||||||
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
||||||
onPointerDown={onHeaderDown}
|
onPointerDown={stopChartPointer}
|
||||||
onPointerMove={onHeaderMove}
|
|
||||||
onPointerUp={onHeaderUp}
|
|
||||||
>
|
>
|
||||||
{/* ── 헤더 ──────────────────────────────────────────────── */}
|
|
||||||
<div
|
<div
|
||||||
className="chart-magnifier-header"
|
className="chart-magnifier-header"
|
||||||
|
onPointerDown={onHeaderDown}
|
||||||
|
onPointerMove={onHeaderMove}
|
||||||
|
onPointerUp={onHeaderUp}
|
||||||
>
|
>
|
||||||
{/* 아이콘 + 제목 */}
|
|
||||||
<svg className="chart-magnifier-icon" width="13" height="13" viewBox="0 0 13 13"
|
<svg className="chart-magnifier-icon" width="13" height="13" viewBox="0 0 13 13"
|
||||||
fill="none" stroke="currentColor" strokeWidth="1.8">
|
fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||||
<circle cx="5.5" cy="5.5" r="4"/>
|
<circle cx="5.5" cy="5.5" r="4"/>
|
||||||
@@ -308,41 +331,56 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
</svg>
|
</svg>
|
||||||
<span className="chart-magnifier-title">확대경</span>
|
<span className="chart-magnifier-title">확대경</span>
|
||||||
|
|
||||||
{/* LIVE 지시등 */}
|
|
||||||
<span
|
<span
|
||||||
className={`chart-magnifier-live${isLive ? ' live-on' : ''}`}
|
className={`chart-magnifier-live${isLive ? ' live-on' : ''}`}
|
||||||
title="캔버스 실시간 갱신 감지"
|
title="캔버스 실시간 갱신 감지"
|
||||||
>LIVE</span>
|
>LIVE</span>
|
||||||
|
|
||||||
{/* XY Zoom 컨트롤 */}
|
|
||||||
<div className="chart-magnifier-zoom" title="X·Y 동시 확대">
|
<div className="chart-magnifier-zoom" title="X·Y 동시 확대">
|
||||||
<button className="chart-magnifier-zoom-btn" onPointerDown={e => e.stopPropagation()}
|
<button
|
||||||
onClick={() => adjustXyZoom(-1)}>−</button>
|
type="button"
|
||||||
|
className="chart-magnifier-zoom-btn"
|
||||||
|
onPointerDown={stopChartPointer}
|
||||||
|
onClick={e => { stopChartPointer(e); adjustXyZoom(-1); }}
|
||||||
|
>−</button>
|
||||||
<span className="chart-magnifier-zoom-val">{xyZoom}×</span>
|
<span className="chart-magnifier-zoom-val">{xyZoom}×</span>
|
||||||
<button className="chart-magnifier-zoom-btn" onPointerDown={e => e.stopPropagation()}
|
<button
|
||||||
onClick={() => adjustXyZoom(1)}>+</button>
|
type="button"
|
||||||
|
className="chart-magnifier-zoom-btn"
|
||||||
|
onPointerDown={stopChartPointer}
|
||||||
|
onClick={e => { stopChartPointer(e); adjustXyZoom(1); }}
|
||||||
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Y-Zoom 컨트롤 */}
|
|
||||||
<div className="chart-magnifier-yzoom" title="Y축 전용 확대 (미세 변화 감지용)">
|
<div className="chart-magnifier-yzoom" title="Y축 전용 확대 (미세 변화 감지용)">
|
||||||
<button className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
|
<button
|
||||||
onPointerDown={e => e.stopPropagation()} onClick={() => adjustYZoom(-1)}>−</button>
|
type="button"
|
||||||
|
className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
|
||||||
|
onPointerDown={stopChartPointer}
|
||||||
|
onClick={e => { stopChartPointer(e); adjustYZoom(-1); }}
|
||||||
|
>−</button>
|
||||||
<span className="chart-magnifier-zoom-val">
|
<span className="chart-magnifier-zoom-val">
|
||||||
<span style={{ fontSize: 9, opacity: 0.7 }}>Y</span>{yZoom}×
|
<span style={{ fontSize: 9, opacity: 0.7 }}>Y</span>{yZoom}×
|
||||||
</span>
|
</span>
|
||||||
<button className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
|
<button
|
||||||
onPointerDown={e => e.stopPropagation()} onClick={() => adjustYZoom(1)}>+</button>
|
type="button"
|
||||||
|
className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
|
||||||
|
onPointerDown={stopChartPointer}
|
||||||
|
onClick={e => { stopChartPointer(e); adjustYZoom(1); }}
|
||||||
|
>+</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 닫기 */}
|
<button
|
||||||
<button className="chart-magnifier-close" onPointerDown={e => e.stopPropagation()}
|
type="button"
|
||||||
onClick={onClose} title="닫기">×</button>
|
className="chart-magnifier-close"
|
||||||
|
onPointerDown={stopChartPointer}
|
||||||
|
onClick={e => { stopChartPointer(e); onClose(); }}
|
||||||
|
title="닫기"
|
||||||
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── 확대 canvas ───────────────────────────────────────── */}
|
|
||||||
<canvas ref={canvasRef} className="chart-magnifier-canvas" />
|
<canvas ref={canvasRef} className="chart-magnifier-canvas" />
|
||||||
|
|
||||||
{/* ── 리사이즈 핸들 (8방향) ─────────────────────────────── */}
|
|
||||||
{HANDLES.map(h => (
|
{HANDLES.map(h => (
|
||||||
<div key={h.id} className="chart-magnifier-handle"
|
<div key={h.id} className="chart-magnifier-handle"
|
||||||
style={{ ...h.style, cursor: h.cursor, position: 'absolute', width: 10, height: 10 }}
|
style={{ ...h.style, cursor: h.cursor, position: 'absolute', width: 10, height: 10 }}
|
||||||
@@ -353,6 +391,8 @@ const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createPortal(magnifier, document.body);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ChartMagnifier;
|
export default ChartMagnifier;
|
||||||
|
|||||||
@@ -1142,7 +1142,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
|||||||
const onPanPointerDown = (e: PointerEvent) => {
|
const onPanPointerDown = (e: PointerEvent) => {
|
||||||
if (!canPanRef.current || e.button !== 0) return;
|
if (!canPanRef.current || e.button !== 0) return;
|
||||||
const target = e.target as HTMLElement | null;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const pt = isChartPlotPointer(e.clientX, e.clientY);
|
const pt = isChartPlotPointer(e.clientX, e.clientY);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
const [signals, setSignals] = useState<BacktestSignal[]>([]);
|
const [signals, setSignals] = useState<BacktestSignal[]>([]);
|
||||||
const [backtestRunning, setBacktestRunning] = useState(false);
|
const [backtestRunning, setBacktestRunning] = useState(false);
|
||||||
const [backtestError, setBacktestError] = useState<string | null>(null);
|
const [backtestError, setBacktestError] = useState<string | null>(null);
|
||||||
|
const [magnifierActive, setMagnifierActive] = useState(false);
|
||||||
const marketBtnRef = useRef<HTMLButtonElement>(null);
|
const marketBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const managerRef = useRef<ChartManager | null>(null);
|
const managerRef = useRef<ChartManager | null>(null);
|
||||||
const backtestGenRef = useRef(0);
|
const backtestGenRef = useRef(0);
|
||||||
@@ -460,7 +461,24 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
<span className="seval-chart-backtest-busy">시그널 계산 중…</span>
|
<span className="seval-chart-backtest-busy">시그널 계산 중…</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="seval-chart-hint">캔들 클릭 · 세로 영역 드래그 · ← → 키 · 하단 툴바로 분석 봉 이동</span>
|
<div className="seval-chart-toolbar-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`seval-chart-magnifier-btn${magnifierActive ? ' seval-chart-magnifier-btn--active' : ''}`}
|
||||||
|
title={magnifierActive ? '확대경 끄기' : '확대경 켜기'}
|
||||||
|
aria-label={magnifierActive ? '확대경 끄기' : '확대경 켜기'}
|
||||||
|
aria-pressed={magnifierActive}
|
||||||
|
disabled={loading || bars.length === 0}
|
||||||
|
onClick={() => setMagnifierActive(v => !v)}
|
||||||
|
>
|
||||||
|
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden>
|
||||||
|
<circle cx="6.5" cy="6.5" r="4.5" />
|
||||||
|
<line x1="10" y1="10" x2="13.5" y2="13.5" />
|
||||||
|
<line x1="6.5" y1="4" x2="6.5" y2="9" />
|
||||||
|
<line x1="4" y1="6.5" x2="9" y2="6.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="btd-analysis-main">
|
<div className="btd-analysis-main">
|
||||||
@@ -506,6 +524,8 @@ const StrategyEvaluationChart: React.FC<Props> = ({
|
|||||||
showHoverToolbar={false}
|
showHoverToolbar={false}
|
||||||
showChartRightToolbar={false}
|
showChartRightToolbar={false}
|
||||||
showCandlePaneControls={false}
|
showCandlePaneControls={false}
|
||||||
|
magnifierEnabled={magnifierActive}
|
||||||
|
onMagnifierClose={() => setMagnifierActive(false)}
|
||||||
resolveInitialDisplayCount={resolveInitialDisplayCount}
|
resolveInitialDisplayCount={resolveInitialDisplayCount}
|
||||||
/>
|
/>
|
||||||
{chartMgr && (
|
{chartMgr && (
|
||||||
|
|||||||
@@ -244,10 +244,44 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seval-chart-hint {
|
.seval-chart-toolbar-actions {
|
||||||
font-size: 0.66rem;
|
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);
|
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) */
|
/* 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);
|
border-color: rgba(25, 118, 210, 0.45);
|
||||||
background: rgba(25, 118, 210, 0.08);
|
background: rgba(25, 118, 210, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
|
||||||
.seval-chart-hint {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user