보조지표 툴바추가

This commit is contained in:
Macbook
2026-05-29 23:51:13 +09:00
parent 095b0d7db7
commit 6332d198b1
11 changed files with 846 additions and 436 deletions
@@ -0,0 +1,339 @@
/**
* 차트 우측 세로 툴바 — 캔들·보조지표 pane 과 높이 동기화.
* pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료)
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { IndicatorConfig } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { getPaneHostId } from '../utils/indicatorPaneMerge';
import { buildPaneItems, resolvePaneItemLayout } from './paneLegendLayout';
export interface ChartRightToolbarProps {
manager: ChartManager;
indicators: IndicatorConfig[];
chartHeight: number;
paused?: boolean;
onSplit?: (hostId: string) => void;
onRemove?: (id: string) => void;
onDuplicate?: (id: string) => void;
onExpand?: (id: string) => void;
onRestore?: () => void;
focusedId?: string | null;
onDragStart?: (id: string, e: React.PointerEvent) => void;
}
const BTN_SIZE = 32;
const BTN_GAP = 2;
const IconCopy = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="4.5" y="1.5" width="8" height="8" rx="1"/>
<path d="M1.5 4.5v8h8"/>
</svg>
);
const IconSplit = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1,7 5,3 5,11"/>
<polyline points="13,7 9,3 9,11"/>
<line x1="5" y1="7" x2="9" y2="7"/>
</svg>
);
const IconClose = () => (
<svg width="12" height="12" viewBox="0 0 11 11" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="1.5" y1="1.5" x2="9.5" y2="9.5"/>
<line x1="9.5" y1="1.5" x2="1.5" y2="9.5"/>
</svg>
);
const IconExpand = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1,9 1,13 5,13"/>
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
<polyline points="13,5 13,1 9,1"/>
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
</svg>
);
const IconRestore = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="5,13 1,13 1,9"/>
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
<polyline points="9,1 13,1 13,5"/>
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
</svg>
);
const IconDrag = () => (
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor">
<circle cx="2.5" cy="2.5" r="1.5"/>
<circle cx="7.5" cy="2.5" r="1.5"/>
<circle cx="2.5" cy="7" r="1.5"/>
<circle cx="7.5" cy="7" r="1.5"/>
<circle cx="2.5" cy="11.5" r="1.5"/>
<circle cx="7.5" cy="11.5" r="1.5"/>
</svg>
);
const IconMore = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
<circle cx="7" cy="3" r="1.3"/>
<circle cx="7" cy="7" r="1.3"/>
<circle cx="7" cy="11" r="1.3"/>
</svg>
);
interface MenuItemDef {
key: string;
title: string;
icon: React.ReactNode;
active?: boolean;
danger?: boolean;
onClick: () => void;
}
function buildMenuItems(
item: ReturnType<typeof buildPaneItems>[number],
indicators: IndicatorConfig[],
paneItems: ReturnType<typeof buildPaneItems>,
opts: {
focusedId?: string | null;
onSplit?: (hostId: string) => void;
onDuplicate?: (id: string) => void;
onExpand?: (id: string) => void;
onRestore?: () => void;
onRemove?: (id: string) => void;
onDone?: () => void;
},
): MenuItemDef[] {
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
const itemHostId = getPaneHostId(item.id, indicators);
const isMergedPane = samePaneItems.length > 1;
const showSplit = isMergedPane && item.id === itemHostId && !!opts.onSplit;
const isFocused = opts.focusedId === item.id;
const showExpand = !!(opts.onExpand || (isFocused && opts.onRestore));
const done = opts.onDone ?? (() => {});
const items: MenuItemDef[] = [];
if (showSplit) {
items.push({
key: 'split',
title: '보조지표 분리',
icon: <IconSplit />,
onClick: () => { opts.onSplit?.(itemHostId); done(); },
});
}
if (opts.onDuplicate) {
items.push({
key: 'copy',
title: '보조지표 복사',
icon: <IconCopy />,
onClick: () => { opts.onDuplicate?.(item.id); done(); },
});
}
if (showExpand) {
items.push({
key: 'expand',
title: isFocused ? '전체 보기로 복원' : '이 지표만 보기',
icon: isFocused ? <IconRestore /> : <IconExpand />,
active: isFocused,
onClick: () => {
if (isFocused) opts.onRestore?.();
else opts.onExpand?.(item.id);
done();
},
});
}
if (opts.onRemove) {
items.push({
key: 'remove',
title: '지표 제거',
icon: <IconClose />,
danger: true,
onClick: () => { opts.onRemove?.(item.id); done(); },
});
}
return items;
}
const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
manager, indicators, chartHeight, paused = false,
onSplit, onRemove, onDuplicate,
onExpand, onRestore, focusedId,
onDragStart,
}) => {
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
const [paneItems, setPaneItems] = useState<ReturnType<typeof buildPaneItems>>([]);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [flyoutPos, setFlyoutPos] = useState({ top: 0, left: 0 });
const indicatorsRef = useRef(indicators);
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const sync = useCallback(() => {
setPaneLayouts(manager.getPaneLayouts());
setPaneItems(buildPaneItems(manager, indicatorsRef.current));
}, [manager]);
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
useEffect(() => {
if (paused) {
setPaneItems([]);
return;
}
sync();
const unsub = manager.subscribePaneLayout(() => sync());
return unsub;
}, [manager, paused, sync, indicators]);
useEffect(() => () => {
if (openTimer.current) clearTimeout(openTimer.current);
if (closeTimer.current) clearTimeout(closeTimer.current);
}, []);
const closeFlyout = useCallback(() => {
setOpenMenuId(null);
if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; }
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
}, []);
const openFlyout = useCallback((itemId: string, el: HTMLElement) => {
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
if (openTimer.current) clearTimeout(openTimer.current);
openTimer.current = setTimeout(() => {
const rect = el.getBoundingClientRect();
setFlyoutPos({ top: rect.top, left: rect.left - 4 });
setOpenMenuId(itemId);
}, 80);
}, []);
const scheduleClose = useCallback(() => {
if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; }
closeTimer.current = setTimeout(() => setOpenMenuId(null), 200);
}, []);
const cancelClose = useCallback(() => {
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
}, []);
if (paused || chartHeight <= 0) return null;
const innerHeight = paneLayouts.reduce(
(max, l) => Math.max(max, l.topY + l.height),
chartHeight,
);
const allowDrag = !!(onDragStart && paneItems.length > 1);
const clusterHeight = (allowDrag ? BTN_SIZE + BTN_GAP : 0) + BTN_SIZE;
const openItem = openMenuId
? paneItems.find(p => p.id === openMenuId)
: null;
return (
<aside className="chart-right-toolbar" aria-label="차트 우측 도구">
<div
className="chart-right-toolbar-inner"
style={{ height: Math.max(innerHeight, chartHeight) }}
>
{paneLayouts.map(layout => (
<div
key={layout.paneIndex}
className="chart-right-toolbar-segment"
style={{ top: layout.topY, height: layout.height }}
/>
))}
{paneItems.map(item => {
const pl = resolvePaneItemLayout(manager, item);
if (!pl) return null;
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
const stackIdx = samePaneItems.findIndex(p => p.id === item.id);
const paneBottom = pl.topY + pl.height;
const clusterTop = paneBottom - 4 - clusterHeight - stackIdx * (clusterHeight + 4);
const menuItems = buildMenuItems(item, indicators, paneItems, {
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
});
if (menuItems.length === 0 && !allowDrag) return null;
return (
<div
key={item.id}
className="chart-right-toolbar-cluster"
style={{ top: clusterTop }}
>
{allowDrag && (
<button
type="button"
className="tv-side-btn chart-right-toolbar-btn chart-right-toolbar-btn--drag"
title="드래그하여 순서 변경"
onPointerDown={e => {
e.stopPropagation();
onDragStart?.(item.id, e);
}}
>
<IconDrag />
</button>
)}
{menuItems.length > 0 && (
<button
type="button"
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--menu${openMenuId === item.id ? ' active' : ''}`}
title="보조지표 메뉴"
onMouseEnter={e => openFlyout(item.id, e.currentTarget)}
onMouseLeave={scheduleClose}
>
<IconMore />
<span className="tv-side-expand-arrow" />
</button>
)}
</div>
);
})}
</div>
{openItem && openMenuId && (() => {
const menuItems = buildMenuItems(openItem, indicators, paneItems, {
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
onDone: closeFlyout,
});
if (menuItems.length === 0) return null;
return (
<div
className="tv-side-flyout tv-side-flyout--right-anchor"
style={{ top: flyoutPos.top, left: flyoutPos.left }}
onMouseEnter={cancelClose}
onMouseLeave={scheduleClose}
>
<div className="tv-side-flyout-header">{openItem.label}</div>
{menuItems.map(mi => (
<button
key={mi.key}
type="button"
className={`tv-side-flyout-item${mi.active ? ' active' : ''}${mi.danger ? ' tv-side-flyout-item--danger' : ''}`}
onClick={e => { e.stopPropagation(); mi.onClick(); }}
>
<span className="tv-side-flyout-icon">{mi.icon}</span>
<span className="tv-side-flyout-title">{mi.title}</span>
</button>
))}
</div>
);
})()}
</aside>
);
};
export default ChartRightToolbar;