Files
goldenChart/frontend/src/components/ChartMagnifier.tsx
T
2026-06-16 23:45:57 +09:00

443 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ChartMagnifier
*
* 차트 위에 떠 있는 돋보기(루페) 오버레이.
* - 헤더 드래그로 이동
* - 8방향 핸들로 리사이즈
* - 내부 canvas 에 차트 픽셀을 확대 복사 (rAF 루프)
* - X/Y 동시 줌 배율 조절
* - Y축 전용 줌(Y-Zoom): 수직 방향만 추가 확대 → 미세한 Y값 변화도 파악 가능
* - LIVE 지시등: 캔버스 픽셀 변화 감지 시 점멸
*/
import React, { useRef, useEffect, useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
interface ChartMagnifierProps {
/** LWC canvas 들이 들어있는 chart-container div */
containerRef: React.RefObject<HTMLDivElement>;
/** position:relative 부모 (tv-chart-wrap) — 위치 기준점 */
wrapperRef: React.RefObject<HTMLDivElement>;
enabled: boolean;
onClose: () => void;
}
const HEADER_H = 30;
const MIN_W = 180;
const MIN_H = 120 + HEADER_H;
const INIT_W = 320;
const INIT_H = 240 + HEADER_H;
const XY_STEPS: number[] = [1.5, 2, 2.5, 3, 4, 5, 6, 8];
const Y_STEPS: number[] = [1, 1.5, 2, 3, 5, 8, 12];
type ResizeDir = 'nw'|'n'|'ne'|'e'|'se'|'s'|'sw'|'w';
const HANDLE_SIZE = 14;
const HANDLE_OFFSET = -Math.round(HANDLE_SIZE / 2);
const HANDLES: { id: ResizeDir; cursor: string; style: React.CSSProperties }[] = [
{ id:'nw', cursor:'nw-resize', style:{ top:HANDLE_OFFSET, left:HANDLE_OFFSET } },
{ id:'n', cursor:'n-resize', style:{ top:HANDLE_OFFSET, left:'50%', transform:'translateX(-50%)' } },
{ id:'ne', cursor:'ne-resize', style:{ top:HANDLE_OFFSET, right:HANDLE_OFFSET } },
{ id:'e', cursor:'e-resize', style:{ top:'50%', right:HANDLE_OFFSET, transform:'translateY(-50%)' } },
{ id:'se', cursor:'se-resize', style:{ bottom:HANDLE_OFFSET, right:HANDLE_OFFSET } },
{ id:'s', cursor:'s-resize', style:{ bottom:HANDLE_OFFSET, left:'50%', transform:'translateX(-50%)' } },
{ id:'sw', cursor:'sw-resize', style:{ bottom:HANDLE_OFFSET, left:HANDLE_OFFSET } },
{ id:'w', cursor:'w-resize', style:{ top:'50%', left:HANDLE_OFFSET, 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;
}
/** LWC chart-container + 래퍼 오버레이(매매 시그널 라벨·드로잉) 캔버스 — DOM 순서 유지 */
function collectMagnifierChartLayers(
container: HTMLElement,
wrapper: HTMLElement,
): HTMLCanvasElement[] {
const containerLayers = Array.from(container.querySelectorAll<HTMLCanvasElement>('canvas'));
const containerSet = new Set(containerLayers);
const overlayLayers = Array.from(wrapper.querySelectorAll<HTMLCanvasElement>('canvas'))
.filter(c => !containerSet.has(c) && !c.classList.contains('chart-magnifier-canvas'));
return [...containerLayers, ...overlayLayers];
}
const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
containerRef, wrapperRef, enabled, onClose,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const tempCvRef = useRef<HTMLCanvasElement | null>(null); // Y-Zoom 용 임시 캔버스
const rafRef = useRef<number>(0);
// ── position / size / xy-zoom / y-zoom ────────────────────────────────────
// position: fixed 기준 — 뷰포트 좌표 (wrapper 밖으로도 이동 가능)
const initPos = useCallback(() => {
const wr = wrapperRef.current?.getBoundingClientRect();
return wr ? { x: wr.left + 40, y: wr.top + 40 } : { x: 80, y: 80 };
}, [wrapperRef]);
const [pos, setPos] = useState(() => ({ x: 80, y: 80 }));
const [size, setSize] = useState({ w: INIT_W, h: INIT_H });
const [xyZoom, setXyZoom] = useState(2.5);
const [yZoom, setYZoom] = useState(1);
/** LIVE 지시등: true = 직전 프레임 대비 픽셀 변화 있음 */
const [isLive, setIsLive] = useState(false);
const posRef = useRef({ x: 80, y: 80 });
const sizeRef = useRef({ w: INIT_W, h: INIT_H });
const xyZoomRef = useRef(2.5);
const yZoomRef = useRef(1);
// 픽셀 변화 감지용: 이전 프레임의 중앙 수직 라인 픽셀 데이터
const prevRowRef = useRef<Uint8ClampedArray | null>(null);
const liveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// ── rAF: 차트 캔버스 픽셀을 확대하여 magnifier canvas 에 복사 ──────────────
const renderFrame = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
const wrapper = wrapperRef.current;
if (!canvas || !container || !wrapper) return;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return;
const { x, y } = posRef.current;
const { w, h } = sizeRef.current;
const zm = xyZoomRef.current;
const yz = yZoomRef.current;
const canvasH = h - HEADER_H;
const dpr = window.devicePixelRatio || 1;
// canvas 해상도 유지
const tW = Math.round(w * dpr);
const tH = Math.round(canvasH * dpr);
if (canvas.width !== tW || canvas.height !== tH) {
canvas.width = tW;
canvas.height = tH;
canvas.style.width = w + 'px';
canvas.style.height = canvasH + 'px';
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
// 샘플링 중심 — position:fixed 이므로 pos가 이미 뷰포트 절대 좌표
const centerX = x + w / 2;
const centerY = y + HEADER_H + canvasH / 2;
// 원본 영역 (xy-zoom 적용)
const srcW = w / zm;
const srcH = canvasH / zm;
const srcL = centerX - srcW / 2;
const srcT = centerY - srcH / 2;
// position:fixed → pos 자체가 뷰포트 좌표이므로 wrapper 오프셋 불필요
const ssrcL = srcL;
const ssrcT = srcT;
// LWC 캔버스 + 매매 시그널 라벨·드로잉 오버레이 (tv-chart-wrap 자식) 순서대로 복사
const layers = collectMagnifierChartLayers(container, wrapper);
for (const lc of layers) {
const cRect = lc.getBoundingClientRect();
const csX = (ssrcL - cRect.left) * dpr;
const csY = (ssrcT - cRect.top ) * dpr;
const csW = srcW * dpr;
const csH = srcH * dpr;
const x0 = Math.max(0, csX);
const y0 = Math.max(0, csY);
const x1 = Math.min(lc.width, csX + csW);
const y1 = Math.min(lc.height, csY + csH);
if (x1 <= x0 || y1 <= y0) continue;
const dstX = ((x0 - csX) / csW) * canvas.width;
const dstY = ((y0 - csY) / csH) * canvas.height;
const dstW = ((x1 - x0) / csW) * canvas.width;
const dstH = ((y1 - y0) / csH) * canvas.height;
try { ctx.drawImage(lc, x0, y0, x1-x0, y1-y0, dstX, dstY, dstW, dstH); }
catch { /* cross-origin 무시 */ }
}
// ── Y-Zoom: 수직 방향 추가 확대 ───────────────────────────────────────────
if (yz > 1) {
let tmp = tempCvRef.current;
if (!tmp || tmp.width !== canvas.width || tmp.height !== canvas.height) {
tmp = document.createElement('canvas');
tmp.width = canvas.width;
tmp.height = canvas.height;
tempCvRef.current = tmp;
}
const tCtx = tmp.getContext('2d')!;
tCtx.clearRect(0, 0, tmp.width, tmp.height);
tCtx.drawImage(canvas, 0, 0);
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);
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);
try {
const midX = Math.floor(canvas.width / 2);
const sample = ctx.getImageData(midX, 0, 1, canvas.height).data;
const prev = prevRowRef.current;
if (prev && prev.length === sample.length) {
let diff = 0;
for (let i = 0; i < sample.length; i += 4) {
diff += Math.abs(sample[i] - prev[i]);
diff += Math.abs(sample[i+1] - prev[i+1]);
diff += Math.abs(sample[i+2] - prev[i+2]);
}
if (diff > 30) {
setIsLive(true);
if (liveTimerRef.current) clearTimeout(liveTimerRef.current);
liveTimerRef.current = setTimeout(() => setIsLive(false), 600);
}
}
prevRowRef.current = new Uint8ClampedArray(sample);
} catch { /* getImageData 실패 무시 */ }
}, [containerRef, wrapperRef]);
useEffect(() => {
if (!enabled) return;
const p = initPos();
posRef.current = p;
setPos(p);
}, [enabled, initPos]);
useEffect(() => {
if (!enabled) { cancelAnimationFrame(rafRef.current); return; }
const loop = () => { renderFrame(); rafRef.current = requestAnimationFrame(loop); };
rafRef.current = requestAnimationFrame(loop);
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)),
};
posRef.current = np;
setPos(np);
}, []);
const onHeaderUp = useCallback((e: React.PointerEvent) => {
if (!dragStart.current) return;
e.stopPropagation();
dragStart.current = null;
}, []);
// ── 리사이즈 핸들 ──────────────────────────────────────────────────────────
const resizeStart = useRef<{
dir: ResizeDir; mx: number; my: number;
px: number; py: number; pw: number; ph: number;
} | null>(null);
const onHandleDown = useCallback((e: React.PointerEvent, dir: ResizeDir) => {
e.preventDefault();
e.stopPropagation();
resizeStart.current = {
dir, mx: e.clientX, my: e.clientY,
px: posRef.current.x, py: posRef.current.y,
pw: sizeRef.current.w, ph: sizeRef.current.h,
};
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const applyResize = useCallback((clientX: number, clientY: number) => {
const rs = resizeStart.current;
if (!rs) return;
const { dir, mx, my, px, py, pw, ph } = rs;
const dx = clientX - mx;
const dy = 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 });
}, []);
const onHandleMove = useCallback((e: React.PointerEvent) => {
if (!resizeStart.current) return;
e.preventDefault();
e.stopPropagation();
applyResize(e.clientX, e.clientY);
}, [applyResize]);
const onHandleUp = useCallback((e: React.PointerEvent) => {
e.stopPropagation();
resizeStart.current = null;
}, []);
useEffect(() => {
if (!enabled) return;
const onWindowMove = (e: PointerEvent) => {
if (!resizeStart.current) return;
e.preventDefault();
applyResize(e.clientX, e.clientY);
};
const onWindowUp = () => {
resizeStart.current = null;
};
window.addEventListener('pointermove', onWindowMove, { passive: false });
window.addEventListener('pointerup', onWindowUp);
window.addEventListener('pointercancel', onWindowUp);
return () => {
window.removeEventListener('pointermove', onWindowMove);
window.removeEventListener('pointerup', onWindowUp);
window.removeEventListener('pointercancel', onWindowUp);
};
}, [enabled, applyResize]);
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 = nearestStepIndex(Y_STEPS, cur);
const next = Y_STEPS[Math.max(0, Math.min(Y_STEPS.length - 1, idx + delta))];
if (next === cur) return;
yZoomRef.current = next;
setYZoom(next);
}, []);
if (!enabled) return null;
const magnifier = (
<div
className="chart-magnifier"
data-no-chart-pan
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
onPointerDown={stopChartPointer}
>
<div
className="chart-magnifier-header"
onPointerDown={onHeaderDown}
onPointerMove={onHeaderMove}
onPointerUp={onHeaderUp}
>
<svg className="chart-magnifier-icon" width="13" height="13" viewBox="0 0 13 13"
fill="none" stroke="currentColor" strokeWidth="1.8">
<circle cx="5.5" cy="5.5" r="4"/>
<line x1="8.8" y1="8.8" x2="12.5" y2="12.5"/>
<line x1="5.5" y1="3.5" x2="5.5" y2="7.5"/>
<line x1="3.5" y1="5.5" x2="7.5" y2="5.5"/>
</svg>
<span className="chart-magnifier-title">확대경</span>
<span
className={`chart-magnifier-live${isLive ? ' live-on' : ''}`}
title="캔버스 실시간 갱신 감지"
>LIVE</span>
<div className="chart-magnifier-zoom" title="X·Y 동시 확대">
<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>
<button
type="button"
className="chart-magnifier-zoom-btn"
onPointerDown={stopChartPointer}
onClick={e => { stopChartPointer(e); adjustXyZoom(1); }}
>+</button>
</div>
<div className="chart-magnifier-yzoom" title="Y축 전용 확대 (미세 변화 감지용)">
<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 style={{ fontSize: 9, opacity: 0.7 }}>Y</span>{yZoom}×
</span>
<button
type="button"
className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
onPointerDown={stopChartPointer}
onClick={e => { stopChartPointer(e); adjustYZoom(1); }}
>+</button>
</div>
<button
type="button"
className="chart-magnifier-close"
onPointerDown={stopChartPointer}
onClick={e => { stopChartPointer(e); onClose(); }}
title="닫기"
>×</button>
</div>
<div className="chart-magnifier-body">
<canvas ref={canvasRef} className="chart-magnifier-canvas" />
</div>
{HANDLES.map(h => (
<div
key={h.id}
className={`chart-magnifier-handle chart-magnifier-handle--${h.id}`}
style={{ ...h.style, cursor: h.cursor }}
onPointerDown={e => onHandleDown(e, h.id)}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
/>
))}
</div>
);
return createPortal(magnifier, document.body);
};
export default ChartMagnifier;