Files
goldenChart/frontend/src/components/ChartMagnifier.tsx
T
2026-05-23 15:11:48 +09:00

359 lines
16 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';
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 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<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 캔버스 레이어 순서대로 복사
const layers = Array.from(container.querySelectorAll<HTMLCanvasElement>('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 (
<div
className="chart-magnifier"
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
onPointerDown={onHeaderDown}
onPointerMove={onHeaderMove}
onPointerUp={onHeaderUp}
>
{/* ── 헤더 ──────────────────────────────────────────────── */}
<div
className="chart-magnifier-header"
>
{/* 아이콘 + 제목 */}
<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>
{/* LIVE 지시등 */}
<span
className={`chart-magnifier-live${isLive ? ' live-on' : ''}`}
title="캔버스 실시간 갱신 감지"
>LIVE</span>
{/* XY Zoom 컨트롤 */}
<div className="chart-magnifier-zoom" title="X·Y 동시 확대">
<button className="chart-magnifier-zoom-btn" onPointerDown={e => e.stopPropagation()}
onClick={() => adjustXyZoom(-1)}></button>
<span className="chart-magnifier-zoom-val">{xyZoom}×</span>
<button className="chart-magnifier-zoom-btn" onPointerDown={e => e.stopPropagation()}
onClick={() => adjustXyZoom(1)}>+</button>
</div>
{/* Y-Zoom 컨트롤 */}
<div className="chart-magnifier-yzoom" title="Y축 전용 확대 (미세 변화 감지용)">
<button className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
onPointerDown={e => e.stopPropagation()} onClick={() => adjustYZoom(-1)}></button>
<span className="chart-magnifier-zoom-val">
<span style={{ fontSize: 9, opacity: 0.7 }}>Y</span>{yZoom}×
</span>
<button className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
onPointerDown={e => e.stopPropagation()} onClick={() => adjustYZoom(1)}>+</button>
</div>
{/* 닫기 */}
<button className="chart-magnifier-close" onPointerDown={e => e.stopPropagation()}
onClick={onClose} title="닫기">×</button>
</div>
{/* ── 확대 canvas ───────────────────────────────────────── */}
<canvas ref={canvasRef} className="chart-magnifier-canvas" />
{/* ── 리사이즈 핸들 (8방향) ─────────────────────────────── */}
{HANDLES.map(h => (
<div key={h.id} className="chart-magnifier-handle"
style={{ ...h.style, cursor: h.cursor, position: 'absolute', width: 10, height: 10 }}
onPointerDown={e => onHandleDown(e, h.id)}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
/>
))}
</div>
);
};
export default ChartMagnifier;