Files
goldenChart/frontend/src/components/common/GridLayoutPicker.tsx
T
2026-06-08 21:09:02 +09:00

276 lines
8.0 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.
/**
* 그리드형 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 (
<svg width="22" height="22" viewBox="0 0 20 20" aria-hidden className="grid-preset-icon-svg">
{cells.map((c, i) => (
<rect
key={i}
x={c.x}
y={c.y}
width={c.w}
height={c.h}
rx={0.5}
fill="none"
stroke="currentColor"
strokeWidth={g}
/>
))}
</svg>
);
};
const POPOVER_WIDTH = 320;
const GridLayoutPicker: React.FC<Props> = ({
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<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(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 ? (
<div
ref={popoverRef}
className={popoverClass}
role="dialog"
aria-label="그리드 배치"
style={{
position: 'fixed',
top: popoverPos.top,
left: popoverPos.left,
right: 'auto',
zIndex: 10000,
width: Math.min(POPOVER_WIDTH, window.innerWidth - 48),
}}
>
<p className={cls(variant, 'popover-title')}>그리드 배치</p>
{groups.map(([group, presets]) => (
<div key={group} className={cls(variant, 'group')}>
<span className={cls(variant, 'group-lbl')}>{group}</span>
<div className={cls(variant, 'group-row')}>
{presets.map(preset => (
<button
key={preset.id}
type="button"
className={[
`${variant}-grid-preset-btn`,
value === preset.id ? `${variant}-grid-preset-btn--on` : '',
].filter(Boolean).join(' ')}
title={`${preset.label} (${preset.cols}열)`}
aria-label={`${preset.label} 배치`}
aria-pressed={value === preset.id}
onClick={() => {
onChange(preset.id);
setOpen(false);
}}
>
<LayoutIcon preset={preset} />
</button>
))}
</div>
</div>
))}
</div>
) : null;
return (
<div className={cls(variant, 'picker')} ref={rootRef}>
<button
type="button"
className={[
cls(variant, 'trigger'),
open ? cls(variant, 'trigger--open') : '',
disabled ? cls(variant, 'trigger--disabled') : '',
].filter(Boolean).join(' ')}
title={`그리드 배치: ${active.label}`}
aria-label="그리드 배치 선택"
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={() => !disabled && setOpen(o => !o)}
>
<LayoutIcon preset={active} />
</button>
{popover && createPortal(popover, document.body)}
</div>
);
};
export default GridLayoutPicker;