163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
/**
|
|
* LayoutPicker
|
|
* TradingView 스타일 레이아웃 선택 팝업.
|
|
*/
|
|
import React, { useEffect, useRef } from 'react';
|
|
import type { LayoutDef, SyncOptions } from '../utils/layoutTypes';
|
|
import { LAYOUTS } from '../utils/layoutTypes';
|
|
|
|
// grid-template-areas 문자열을 파싱해 셀 rect 배열 반환
|
|
function parseAreas(def: LayoutDef, bw: number, bh: number, pad: number) {
|
|
const areaGrid: string[][] = def.areas
|
|
.split('"')
|
|
.map(s => s.trim())
|
|
.filter(Boolean)
|
|
.map(row => row.split(/\s+/).filter(Boolean));
|
|
|
|
if (areaGrid.length === 0) return [];
|
|
|
|
const rowCount = areaGrid.length;
|
|
const colCount = areaGrid[0]?.length ?? 1;
|
|
const colW = bw / colCount;
|
|
const rowH = bh / rowCount;
|
|
|
|
const cellMap = new Map<string, { c1: number; r1: number; c2: number; r2: number }>();
|
|
for (let ri = 0; ri < areaGrid.length; ri++) {
|
|
for (let ci = 0; ci < (areaGrid[ri]?.length ?? 0); ci++) {
|
|
const name = areaGrid[ri][ci];
|
|
if (!name || name === '.') continue;
|
|
const existing = cellMap.get(name);
|
|
if (!existing) {
|
|
cellMap.set(name, { c1: ci, r1: ri, c2: ci + 1, r2: ri + 1 });
|
|
} else {
|
|
cellMap.set(name, {
|
|
c1: Math.min(existing.c1, ci),
|
|
r1: Math.min(existing.r1, ri),
|
|
c2: Math.max(existing.c2, ci + 1),
|
|
r2: Math.max(existing.r2, ri + 1),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return Array.from(cellMap.values()).map(r => ({
|
|
x: pad + r.c1 * colW,
|
|
y: pad + r.r1 * rowH,
|
|
w: (r.c2 - r.c1) * colW,
|
|
h: (r.r2 - r.r1) * rowH,
|
|
}));
|
|
}
|
|
|
|
// ── 레이아웃 미니 아이콘 (SVG) ────────────────────────────────────────────────
|
|
const LayoutIcon: React.FC<{ def: LayoutDef; size?: number }> = ({ def, size = 34 }) => {
|
|
const S = size;
|
|
const PAD = 2;
|
|
const BW = S - PAD * 2;
|
|
const BH = S - PAD * 2;
|
|
const rects = parseAreas(def, BW, BH, PAD);
|
|
|
|
return (
|
|
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} fill="none">
|
|
<rect x={PAD} y={PAD} width={BW} height={BH} rx="2"
|
|
stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
|
{rects.map((r, i) => (
|
|
<rect key={i}
|
|
x={r.x + 1} y={r.y + 1}
|
|
width={r.w - 2} height={r.h - 2}
|
|
rx="1"
|
|
stroke="currentColor" strokeWidth="1"
|
|
fill="currentColor" fillOpacity="0.15"
|
|
/>
|
|
))}
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
// ── 토글 스위치 ──────────────────────────────────────────────────────────────
|
|
const Toggle: React.FC<{ checked: boolean; onChange: () => void }> = ({ checked, onChange }) => (
|
|
<button
|
|
className={`lp-toggle${checked ? ' on' : ''}`}
|
|
onClick={onChange}
|
|
aria-pressed={checked}
|
|
/>
|
|
);
|
|
|
|
// 행 그룹: 같은 count 단위로 묶음
|
|
const ROW_GROUPS = [1, 2, 3, 4, 5, 6, 8];
|
|
|
|
// ── Main Component ───────────────────────────────────────────────────────────
|
|
interface LayoutPickerProps {
|
|
currentId: string;
|
|
syncOptions: SyncOptions;
|
|
onSelect: (def: LayoutDef) => void;
|
|
onSyncChange: (key: keyof SyncOptions, value: boolean) => void;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const LayoutPicker: React.FC<LayoutPickerProps> = ({
|
|
currentId, syncOptions, onSelect, onSyncChange, onClose,
|
|
}) => {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
};
|
|
setTimeout(() => document.addEventListener('mousedown', handler), 80);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div ref={ref} className="lp-popup">
|
|
{/* 레이아웃 그리드 */}
|
|
<div className="lp-grid-section">
|
|
{ROW_GROUPS.map(count => {
|
|
const group = LAYOUTS.filter(l => l.count === count);
|
|
if (group.length === 0) return null;
|
|
return (
|
|
<div key={count} className="lp-row">
|
|
<span className="lp-row-num">{count}</span>
|
|
<div className="lp-row-icons">
|
|
{group.map(def => (
|
|
<button
|
|
key={def.id}
|
|
className={`lp-icon-btn${currentId === def.id ? ' active' : ''}`}
|
|
title={def.label}
|
|
onClick={() => { onSelect(def); }}
|
|
>
|
|
<LayoutIcon def={def} size={34} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="lp-divider" />
|
|
|
|
{/* SYNC IN LAYOUT */}
|
|
<div className="lp-sync-section">
|
|
<div className="lp-sync-label">SYNC IN LAYOUT</div>
|
|
{([
|
|
['symbol', 'Symbol'],
|
|
['interval', 'Interval'],
|
|
['crosshair', 'Crosshair'],
|
|
['time', 'Time'],
|
|
['dateRange', 'Date range'],
|
|
] as [keyof SyncOptions, string][]).map(([key, label]) => (
|
|
<div key={key} className="lp-sync-row">
|
|
<span className="lp-sync-name">{label}</span>
|
|
<Toggle
|
|
checked={syncOptions[key]}
|
|
onChange={() => onSyncChange(key, !syncOptions[key])}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default LayoutPicker;
|