/** * 그리드형 n×n 레이아웃 선택 (TradingView 스타일 팝업) — 알림 목록·가상매매 공용 */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { gridPresetsByGroup, getTradeNotificationGridPreset, type TradeNotificationGridPreset, type TradeNotificationGridPresetId, } from '../../utils/tradeNotificationGridPresets'; export type GridLayoutPickerVariant = 'tnl' | 'vtd'; interface Props { value: TradeNotificationGridPresetId; onChange: (id: TradeNotificationGridPresetId) => void; disabled?: boolean; /** CSS 클래스 접두사 — tnl(알림목록) | vtd(가상매매) */ variant?: GridLayoutPickerVariant; /** 팝업 정렬 (헤더 좌측 배치 시 left) */ popoverAlign?: 'left' | 'right'; } function cls(variant: GridLayoutPickerVariant, suffix: string): string { return `${variant}-grid-layout-${suffix}`; } /** 20×20 와이어프레임 아이콘 */ const LayoutIcon: React.FC<{ preset: TradeNotificationGridPreset }> = ({ preset }) => { const s = 20; const g = 1; const cells: Array<{ x: number; y: number; w: number; h: number }> = []; switch (preset.id) { case '1x1': cells.push({ x: 0, y: 0, w: s, h: s }); break; case '2x1': cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: s }); break; case '1x2': cells.push({ x: 0, y: 0, w: s, h: 9 }, { x: 0, y: 10, w: s, h: 9 }); break; case '3x1': cells.push( { x: 0, y: 0, w: 6, h: s }, { x: 7, y: 0, w: 6, h: s }, { x: 14, y: 0, w: 6, h: s }, ); break; case '1x3': cells.push( { x: 0, y: 0, w: s, h: 6 }, { x: 0, y: 7, w: s, h: 6 }, { x: 0, y: 14, w: s, h: 6 }, ); break; case '2x1-31': cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: 9 }, { x: 10, y: 10, w: 9, h: 9 }); break; case '2x2': cells.push( { x: 0, y: 0, w: 9, h: 9 }, { x: 10, y: 0, w: 9, h: 9 }, { x: 0, y: 10, w: 9, h: 9 }, { x: 10, y: 10, w: 9, h: 9 }, ); break; case '4x1': for (let i = 0; i < 4; i++) cells.push({ x: i * 5, y: 0, w: 4, h: s }); break; case '1x4': for (let i = 0; i < 4; i++) cells.push({ x: 0, y: i * 5, w: s, h: 4 }); break; case '2x1-211': cells.push( { x: 0, y: 0, w: 9, h: 9 }, { x: 10, y: 0, w: 9, h: 9 }, { x: 0, y: 10, w: 9, h: 9 }, { x: 10, y: 10, w: 9, h: 9 }, ); break; case '5x1': for (let i = 0; i < 5; i++) cells.push({ x: i * 4, y: 0, w: 3, h: s }); break; case '3x2': for (let c = 0; c < 3; c++) { for (let r = 0; r < 2; r++) { cells.push({ x: c * 7, y: r * 10, w: 6, h: 9 }); } } break; case '2x3': for (let c = 0; c < 2; c++) { for (let r = 0; r < 3; r++) { cells.push({ x: c * 10, y: r * 7, w: 9, h: 6 }); } } break; case '4x2': for (let c = 0; c < 4; c++) { for (let r = 0; r < 2; r++) { cells.push({ x: c * 5, y: r * 10, w: 4, h: 9 }); } } break; case '2x4': for (let c = 0; c < 2; c++) { for (let r = 0; r < 4; r++) { cells.push({ x: c * 10, y: r * 5, w: 9, h: 4 }); } } break; default: cells.push({ x: 0, y: 0, w: s, h: s }); } return ( {cells.map((c, i) => ( ))} ); }; const POPOVER_WIDTH = 320; const GridLayoutPicker: React.FC = ({ value, onChange, disabled = false, variant = 'tnl', popoverAlign = 'right', }) => { const [open, setOpen] = useState(false); const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null); const rootRef = useRef(null); const popoverRef = useRef(null); const active = getTradeNotificationGridPreset(value); const groups = [...gridPresetsByGroup().entries()].sort((a, b) => a[0] - b[0]); const updatePopoverPos = useCallback(() => { const el = rootRef.current; if (!el) return; const rect = el.getBoundingClientRect(); const width = Math.min(POPOVER_WIDTH, window.innerWidth - 48); const left = popoverAlign === 'left' ? rect.left : rect.right - width; setPopoverPos({ top: rect.bottom + 8, left: Math.max(8, Math.min(left, window.innerWidth - width - 8)), }); }, [popoverAlign]); useEffect(() => { if (!open) { setPopoverPos(null); return; } updatePopoverPos(); window.addEventListener('resize', updatePopoverPos); window.addEventListener('scroll', updatePopoverPos, true); return () => { window.removeEventListener('resize', updatePopoverPos); window.removeEventListener('scroll', updatePopoverPos, true); }; }, [open, updatePopoverPos]); useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { const t = e.target as Node; if (rootRef.current?.contains(t) || popoverRef.current?.contains(t)) return; setOpen(false); }; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); }; document.addEventListener('mousedown', onDoc); document.addEventListener('keydown', onKey); return () => { document.removeEventListener('mousedown', onDoc); document.removeEventListener('keydown', onKey); }; }, [open]); const popoverClass = [ cls(variant, 'popover'), cls(variant, 'popover--portal'), popoverAlign === 'left' ? cls(variant, 'popover--align-left') : '', ].filter(Boolean).join(' '); const popover = open && popoverPos ? (

그리드 배치

{groups.map(([group, presets]) => (
{group}
{presets.map(preset => ( ))}
))}
) : null; return (
{popover && createPortal(popover, document.body)}
); }; export default GridLayoutPicker;