/** * ChartMagnifier * * 차트 위에 떠 있는 돋보기(루페) 오버레이. * - 헤더 드래그로 이동 * - 8방향 핸들로 리사이즈 * - 내부 canvas 에 차트 픽셀을 확대 복사 (rAF 루프) * - X/Y 동시 줌 배율 조절 * - Y축 전용 줌(Y-Zoom): 수직 방향만 추가 확대 → 미세한 Y값 변화도 파악 가능 * - LIVE 지시등: 캔버스 픽셀 변화 감지 시 점멸 */ import React, { useRef, useEffect, useCallback, useState } from 'react'; interface ChartMagnifierProps { /** LWC canvas 들이 들어있는 chart-container div */ containerRef: React.RefObject; /** position:relative 부모 (tv-chart-wrap) — 위치 기준점 */ wrapperRef: React.RefObject; 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 HANDLES: { id: ResizeDir; cursor: string; style: React.CSSProperties }[] = [ { id:'nw', cursor:'nw-resize', style:{ top:-5, left:-5 } }, { id:'n', cursor:'n-resize', style:{ top:-5, left:'50%', transform:'translateX(-50%)' } }, { id:'ne', cursor:'ne-resize', style:{ top:-5, right:-5 } }, { id:'e', cursor:'e-resize', style:{ top:'50%', right:-5, transform:'translateY(-50%)' } }, { id:'se', cursor:'se-resize', style:{ bottom:-5, right:-5 } }, { id:'s', cursor:'s-resize', style:{ bottom:-5, left:'50%',transform:'translateX(-50%)' } }, { id:'sw', cursor:'sw-resize', style:{ bottom:-5, left:-5 } }, { id:'w', cursor:'w-resize', style:{ top:'50%', left:-5, transform:'translateY(-50%)' } }, ]; const ChartMagnifier: React.FC = ({ containerRef, wrapperRef, enabled, onClose, }) => { const canvasRef = useRef(null); const tempCvRef = useRef(null); // Y-Zoom 용 임시 캔버스 const rafRef = useRef(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(null); const liveTimerRef = useRef | 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 캔버스 레이어 순서대로 복사 const layers = Array.from(container.querySelectorAll('canvas')); 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: 수직 방향 추가 확대 ─────────────────────────────────────────── // Y축의 미세한 움직임을 파악할 수 있도록 중앙 기준으로 수직만 재확대 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); // 중앙 ±(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; 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]); // enabled 시 wrapper 위치 기준으로 초기 위치 설정 useEffect(() => { if (!enabled) return; const p = initPos(); posRef.current = p; setPos(p); }, [enabled, initPos]); // rAF 루프 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) => { e.preventDefault(); 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; 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(() => { 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 onHandleMove = useCallback((e: React.PointerEvent) => { const rs = resizeStart.current; if (!rs) return; const { dir, mx, my, px, py, pw, ph } = rs; 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 adjustYZoom = useCallback((delta: number) => { const cur = yZoomRef.current; const idx = Y_STEPS.findIndex(s => s >= cur - 0.01); const next = Y_STEPS[Math.max(0, Math.min(Y_STEPS.length - 1, idx + delta))]; yZoomRef.current = next; setYZoom(next); }, []); if (!enabled) return null; return (
{/* ── 헤더 ──────────────────────────────────────────────── */}
{/* 아이콘 + 제목 */} 확대경 {/* LIVE 지시등 */} LIVE {/* XY Zoom 컨트롤 */}
{xyZoom}×
{/* Y-Zoom 컨트롤 */}
Y{yZoom}×
{/* 닫기 */}
{/* ── 확대 canvas ───────────────────────────────────────── */} {/* ── 리사이즈 핸들 (8방향) ─────────────────────────────── */} {HANDLES.map(h => (
onHandleDown(e, h.id)} onPointerMove={onHandleMove} onPointerUp={onHandleUp} /> ))}
); }; export default ChartMagnifier;