359 lines
16 KiB
TypeScript
359 lines
16 KiB
TypeScript
/**
|
||
* 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;
|