보조지표 툴바추가
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user