보조지표 툴바추가

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;
+15 -322
View File
@@ -15,26 +15,15 @@ import { createPortal } from 'react-dom';
import type { MouseEventParams, Time } from 'lightweight-charts';
import type { IndicatorConfig } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
import { getGlobalParamKeys, getPlotParamKeys } from '../utils/indicatorSettingsLayout';
import { formatBbLegendLabel } from '../utils/bollingerConfig';
import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag';
import { getPaneHostId } from '../utils/indicatorPaneMerge';
import {
buildPaneItems,
resolvePaneItemLayout,
type PaneItem,
} from './paneLegendLayout';
// ── 타입 ─────────────────────────────────────────────────────────────────────
interface PaneItem {
id: string;
paneIndex: number;
label: string;
plotColors: string[];
hidden?: boolean;
/** 화면에 보이는 sub-pane 위치 (orphan pane 인덱스와 분리) */
layoutTopY?: number;
layoutHeight?: number;
}
const MIN_SUB_PANE_HEIGHT = 12;
interface PaneCapture {
dataUrl: string;
cssWidth: number;
@@ -66,18 +55,8 @@ export interface PaneLegendProps {
onReorder?: (fromId: string, insertBeforeId: string | null) => void;
/** 보조지표 pane 병합 */
onMerge?: (fromId: string, intoId: string) => void;
/** 병합 pane 분리 */
onSplit?: (hostId: string) => void;
/** 지표 단독 전체화면 확장 */
onExpand?: (id: string) => void;
/** 지표 제거 (X 버튼) */
onRemove?: (id: string) => void;
/** 보조지표 pane 복사 */
onDuplicate?: (id: string) => void;
/** 현재 단독 전체화면 중인 지표 id */
focusedId?: string | null;
/** 전체화면 → 전체 보기 복원 */
onRestore?: () => void;
/** 드래그 핸들 — ChartRightToolbar 에서 호출 */
dragHandleRef?: React.MutableRefObject<((id: string, e: React.PointerEvent) => void) | null>;
/** 크로스헤어 이동 시 보조지표 수치 표시 */
crosshairInfoVisible?: boolean;
}
@@ -159,138 +138,7 @@ function computeShiftY(
return 0;
}
function makeLabel(config: IndicatorConfig): string {
if (config.type === 'BollingerBands') {
return formatBbLegendLabel(config.params ?? {});
}
const name = getIndicatorChartTitle(config.type);
const def = getIndicatorDef(config.type);
const params = (config.params ?? def?.defaultParams ?? {}) as Record<string, number | string | boolean>;
const plots = config.plots ?? def?.plots ?? [];
const orderedKeys: string[] = [];
for (let i = 0; i < plots.length; i++) {
for (const k of getPlotParamKeys(config.type, plots[i].id, i, plots, params)) {
if (!orderedKeys.includes(k)) orderedKeys.push(k);
}
}
for (const k of getGlobalParamKeys(config.type, params, plots)) {
if (!orderedKeys.includes(k) && typeof params[k] === 'number') orderedKeys.push(k);
}
const nums = orderedKeys
.map(k => params[k])
.filter((v): v is number => typeof v === 'number')
.slice(0, 3);
return nums.length ? `${name} ${nums.join(', ')}` : name;
}
function applyPaneLayoutToItem(
item: PaneItem,
layout: { topY: number; height: number },
overwrite = false,
): void {
if (!overwrite && item.layoutTopY != null) return;
item.layoutTopY = layout.topY;
item.layoutHeight = layout.height;
}
function buildPaneItems(
manager: ChartManager,
indicators: IndicatorConfig[],
): PaneItem[] {
const wantedIds = new Set(
indicators
.filter(ind => {
const def = getIndicatorDef(ind.type);
return def && !def.overlay && !def.returnsMarkers;
})
.map(ind => ind.id),
);
const configById = new Map(indicators.map(ind => [ind.id, ind]));
// ChartManager 에 실제 로드된 지표만 — React state 만 있고 pane 미부착인 항목 제외
const items: PaneItem[] = [];
const seenIds = new Set<string>();
for (const info of manager.getIndicatorPaneInfo()) {
if (!wantedIds.has(info.id) || seenIds.has(info.id)) continue;
seenIds.add(info.id);
const ind = configById.get(info.id) ?? info.config;
const def = getIndicatorDef(ind.type);
if (!def || def.overlay || def.returnsMarkers) continue;
let chartPane = info.paneIndex;
if (ind.mergedWith) {
const host = manager.getIndicatorPaneIndex(ind.mergedWith);
if (host >= 2) chartPane = host;
}
items.push({
id: info.id,
paneIndex: chartPane,
label: makeLabel(ind),
plotColors: info.plotColors.map(c => c.slice(0, 7)),
hidden: ind.hidden === true,
});
}
if (items.length === 0) return [];
items.sort((a, b) => a.paneIndex - b.paneIndex);
for (const item of items) {
const lay = manager.getIndicatorPaneScreenLayout(item.id);
if (lay) applyPaneLayoutToItem(item, lay, true);
}
items.sort((a, b) => {
const ay = a.layoutTopY ?? 1e9;
const by = b.layoutTopY ?? 1e9;
if (ay !== by) return ay - by;
return a.paneIndex - b.paneIndex;
});
return items.filter(
item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT,
);
}
/** 렌더 시점 pane 좌표 — 시리즈 DOM 기준만 사용 (stale paneIndex 폴백 제거) */
function resolvePaneItemLayout(
manager: ChartManager,
item: PaneItem,
): { topY: number; height: number } | null {
return manager.getIndicatorPaneScreenLayout(item.id);
}
// ── 아이콘 SVG ────────────────────────────────────────────────────────────────
/** ⤡ 전체화면 확장 — 네 꼭지점 바깥 방향 화살표 */
const IconExpand = () => (
<svg width="13" height="13" 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="13" height="13" 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 IconMerge = () => (
<svg width="28" height="28" viewBox="0 0 28 28" fill="none"
stroke="currentColor" strokeWidth="2.2" strokeLinecap="round">
@@ -299,37 +147,11 @@ const IconMerge = () => (
</svg>
);
/** 그래프 복사 — 겹친 사각형 */
const IconCopy = () => (
<svg width="13" height="13" 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="13" height="13" 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="11" height="11" 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 PaneLegend: React.FC<PaneLegendProps> = ({
manager, indicators, containerEl, paused = false,
onHoverName, onLeaveName, onDoubleClick,
onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore,
onReorder, onMerge, dragHandleRef,
crosshairInfoVisible = true,
}) => {
const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
@@ -350,8 +172,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const containerElRef = useRef(containerEl);
const onReorderRef = useRef(onReorder);
const onMergeRef = useRef(onMerge);
const onSplitRef = useRef(onSplit);
const onDuplicateRef = useRef(onDuplicate);
const indicatorsRef = useRef(indicators);
const [portalHost, setPortalHost] = useState<HTMLElement | null>(null);
@@ -373,8 +193,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
useEffect(() => { containerElRef.current = containerEl; }, [containerEl]);
useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
useEffect(() => { onMergeRef.current = onMerge; }, [onMerge]);
useEffect(() => { onSplitRef.current = onSplit; }, [onSplit]);
useEffect(() => { onDuplicateRef.current = onDuplicate; }, [onDuplicate]);
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
const syncPaneItems = useCallback(() => {
@@ -587,10 +405,15 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
finishDrag,
);
document.addEventListener('keydown', handleKeyDown);
}, []);
}, [manager]);
useEffect(() => {
if (!dragHandleRef) return;
dragHandleRef.current = (id, e) => handleDragHandlePointerDown(e, id);
return () => { dragHandleRef.current = null; };
}, [dragHandleRef, handleDragHandlePointerDown]);
// ── 렌더 ─────────────────────────────────────────────────────────────────
const canReorder = !!onReorder && paneItems.length > 1;
const isDragging = dragState !== null;
const dropLineY = isDragging && dragState!.dropMode === 'reorder'
? getDropLineY(dragState!.dropBeforeIndex)
@@ -625,23 +448,13 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
const pl = resolvePaneItemLayout(manager, item);
if (!pl) return null;
const paneBottomY = pl.topY + pl.height;
const itemVals = item.hidden ? [] : (values[item.id] ?? []);
const itemHostId = getPaneHostId(item.id, indicators);
const samePaneItems = paneItems.filter(p => p.paneIndex === item.paneIndex);
const stackIdx = samePaneItems.findIndex(p => p.id === item.id);
const isBeingDragged = isDragging && itemHostId === draggingHostId;
const isFocused = focusedId === item.id;
const isMergedPane = samePaneItems.length > 1;
const showSplitBtn = isMergedPane && item.id === itemHostId && !!onSplit;
const PANE_BTN_STEP = 24;
const paneBtnRight = (slot: number) => 28 + slot * PANE_BTN_STEP;
const hoverScreenX = containerRect.left;
const hoverScreenY = containerRect.top + pl.topY;
let btnSlot = 0;
const closeSlot = btnSlot++;
const copySlot = onDuplicate ? btnSlot++ : -1;
const splitSlot = showSplitBtn ? btnSlot++ : -1;
const isPaneHidden = item.hidden === true;
const bgColor = PANE_BG_COLORS[itemIdx % PANE_BG_COLORS.length];
@@ -711,126 +524,6 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
</span>
))}
</div>
{/* ── 우측 상단: 분리 / 복사 / 닫기 ─────────────────────── */}
{showSplitBtn && splitSlot >= 0 && (
<button
className="pane-btn pane-btn-split"
style={{
position: 'absolute',
right: paneBtnRight(splitSlot),
top: pl.topY + 5,
zIndex: 505,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
onSplitRef.current?.(itemHostId);
}}
title="보조지표 분리"
>
<IconSplit />
</button>
)}
{copySlot >= 0 && (
<button
className="pane-btn pane-btn-copy"
style={{
position: 'absolute',
right: paneBtnRight(copySlot),
top: pl.topY + 5,
zIndex: 505,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
onDuplicateRef.current?.(item.id);
}}
title="보조지표 복사"
>
<IconCopy />
</button>
)}
{onRemove && (
<button
className="pane-btn pane-btn-close"
style={{
position: 'absolute',
right: paneBtnRight(closeSlot),
top: pl.topY + 5,
zIndex: 504,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => { e.stopPropagation(); onRemove(item.id); }}
title="지표 제거"
>
<IconClose />
</button>
)}
{/* ── 좌측 하단: 전체화면 버튼 (위) + 드래그 핸들 (아래) ── */}
{/* 드래그 핸들: pane 하단에서 30px 위 */}
{canReorder && (
<div
className="pane-drag-handle"
style={{
position: 'absolute',
left: 4,
top: paneBottomY - 30,
zIndex: 510,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onPointerDown={e => handleDragHandlePointerDown(e, item.id)}
onMouseEnter={onLeaveName}
title="드래그하여 순서 변경"
>
<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>
</div>
)}
{/* 전체화면 / 복원 버튼: 드래그 핸들 위에 배치 */}
{(onExpand || (isFocused && onRestore)) && (
<button
className={`pane-btn pane-btn-expand${isFocused ? ' active' : ''}`}
style={{
position: 'absolute',
left: 4,
top: paneBottomY - 56,
zIndex: 504,
opacity: isBeingDragged ? 0 : undefined,
transform: shiftY ? `translateY(${shiftY}px)` : undefined,
transition: shiftTrans,
pointerEvents: 'auto',
}}
onClick={e => {
e.stopPropagation();
if (isFocused) onRestore?.();
else onExpand?.(item.id);
}}
title={isFocused ? '전체 보기로 복원' : '이 지표만 보기'}
>
{isFocused ? <IconRestore /> : <IconExpand />}
</button>
)}
</React.Fragment>
);
})}
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import type { ChartManager } from '../utils/ChartManager';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
import { classifyPaneBoundary } from '../types/chartPaneSeparator';
import { plotColorCss } from '../utils/plotColorUtils';
interface Props {
@@ -8,12 +9,29 @@ interface Props {
options: ChartPaneSeparatorOptions;
}
function applyLineDash(ctx: CanvasRenderingContext2D, style: ChartPaneSeparatorOptions['lineStyle']) {
function applyLineDash(ctx: CanvasRenderingContext2D, style: PaneSeparatorStyle['lineStyle']) {
if (style === 'dashed') ctx.setLineDash([6, 4]);
else if (style === 'dotted') ctx.setLineDash([2, 3]);
else ctx.setLineDash([]);
}
function strokeBoundary(
ctx: CanvasRenderingContext2D,
cssW: number,
y: number,
style: PaneSeparatorStyle,
) {
if (!style.visible) return;
ctx.strokeStyle = plotColorCss(style.color);
ctx.lineWidth = style.width;
applyLineDash(ctx, style.lineStyle);
const lineY = y + style.width / 2;
ctx.beginPath();
ctx.moveTo(0, lineY);
ctx.lineTo(cssW, lineY);
ctx.stroke();
}
const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -37,24 +55,22 @@ const PaneSeparatorOverlay: React.FC<Props> = ({ manager, options }) => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
if (!options.visible) return;
const layouts = manager.getPaneLayouts()
.filter(l => l.height > 8)
.sort((a, b) => a.topY - b.topY);
if (layouts.length < 2) return;
ctx.strokeStyle = plotColorCss(options.color);
ctx.lineWidth = options.width;
applyLineDash(ctx, options.lineStyle);
for (let i = 0; i < layouts.length - 1; i++) {
const y = layouts[i].topY + layouts[i].height;
const lineY = y + options.width / 2;
ctx.beginPath();
ctx.moveTo(0, lineY);
ctx.lineTo(cssW, lineY);
ctx.stroke();
const lower = layouts[i];
const upper = layouts[i + 1];
const kind = classifyPaneBoundary(lower.paneIndex, upper.paneIndex);
if (kind === 'other') continue;
const style = kind === 'mainToIndicator'
? options.mainToIndicator
: options.indicatorToIndicator;
const y = lower.topY + lower.height;
strokeBoundary(ctx, cssW, y, style);
}
}, [manager, options]);
+89 -65
View File
@@ -37,7 +37,7 @@ import {
CHART_LEGEND_SETTING_ITEMS,
type ChartLegendVisibility,
} from '../types/chartLegend';
import type { ChartPaneSeparatorOptions } from '../types/chartPaneSeparator';
import type { ChartPaneSeparatorOptions, PaneSeparatorStyle } from '../types/chartPaneSeparator';
import {
LINE_STYLE_LABELS,
LINE_STYLE_OPTIONS,
@@ -661,6 +661,72 @@ const GeneralPanel: React.FC<{
);
};
function PaneSeparatorStyleFields({
style,
onChange,
}: {
style: PaneSeparatorStyle;
onChange: (next: PaneSeparatorStyle) => void;
}) {
return (
<>
<SettingRow label="구분선 표시">
<label className="stg-toggle">
<input
type="checkbox"
checked={style.visible}
onChange={e => onChange({ ...style, visible: e.target.checked })}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
{style.visible && (
<>
<SettingRow label="선 색상">
<div className="stg-color-row">
<input
type="color"
className="stg-color"
value={style.color.startsWith('#') ? style.color.slice(0, 7) : '#2a2e3a'}
onChange={e => onChange({ ...style, color: e.target.value })}
/>
<span className="stg-color-label">{style.color}</span>
</div>
</SettingRow>
<SettingRow label="선 굵기">
<select
className="stg-select"
value={style.width}
onChange={e => onChange({
...style,
width: Number(e.target.value) as PaneSeparatorStyle['width'],
})}
>
{LINE_WIDTH_OPTIONS.map(w => (
<option key={w} value={w}>{w}px</option>
))}
</select>
</SettingRow>
<SettingRow label="선 유형">
<select
className="stg-select"
value={style.lineStyle}
onChange={e => onChange({
...style,
lineStyle: e.target.value as PaneSeparatorStyle['lineStyle'],
})}
>
{LINE_STYLE_OPTIONS.map(s => (
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
))}
</select>
</SettingRow>
</>
)}
</>
);
}
interface ChartPanelProps {
chartRealtimeSource?: string;
onChartRealtimeSource?: (v: string) => void;
@@ -820,70 +886,28 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
{chartPaneSeparator && onChartPaneSeparatorChange && (
<SettingSection title="차트 영역 구분선">
<SettingRow
label="구분선 표시"
desc="캔들·거래량·보조지표 pane 사이에 구분선을 표시합니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={chartPaneSeparator.visible}
onChange={e => onChartPaneSeparatorChange({
...chartPaneSeparator,
visible: e.target.checked,
})}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
{chartPaneSeparator.visible && (
<>
<SettingRow label="선 색상" desc="pane 사이 구분선 색상입니다.">
<div className="stg-color-row">
<input
type="color"
className="stg-color"
value={chartPaneSeparator.color.startsWith('#')
? chartPaneSeparator.color.slice(0, 7)
: '#2a2e3a'}
onChange={e => onChartPaneSeparatorChange({
...chartPaneSeparator,
color: e.target.value,
})}
/>
<span className="stg-color-label">{chartPaneSeparator.color}</span>
</div>
</SettingRow>
<SettingRow label="선 굵기" desc="구분선 두께(px)입니다.">
<select
className="stg-select"
value={chartPaneSeparator.width}
onChange={e => onChartPaneSeparatorChange({
...chartPaneSeparator,
width: Number(e.target.value) as ChartPaneSeparatorOptions['width'],
})}
>
{LINE_WIDTH_OPTIONS.map(w => (
<option key={w} value={w}>{w}px</option>
))}
</select>
</SettingRow>
<SettingRow label="선 스타일" desc="실선·점선·도트 중 선택합니다.">
<select
className="stg-select"
value={chartPaneSeparator.lineStyle}
onChange={e => onChartPaneSeparatorChange({
...chartPaneSeparator,
lineStyle: e.target.value as ChartPaneSeparatorOptions['lineStyle'],
})}
>
{LINE_STYLE_OPTIONS.map(s => (
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
))}
</select>
</SettingRow>
</>
)}
<div className="stg-subsection-label">
·
<span className="stg-subsection-desc">(·) pane </span>
</div>
<PaneSeparatorStyleFields
style={chartPaneSeparator.mainToIndicator}
onChange={mainToIndicator => onChartPaneSeparatorChange({
...chartPaneSeparator,
mainToIndicator,
})}
/>
<div className="stg-subsection-label stg-subsection-label--spaced">
<span className="stg-subsection-desc"> pane </span>
</div>
<PaneSeparatorStyleFields
style={chartPaneSeparator.indicatorToIndicator}
onChange={indicatorToIndicator => onChartPaneSeparatorChange({
...chartPaneSeparator,
indicatorToIndicator,
})}
/>
</SettingSection>
)}
+35 -10
View File
@@ -46,6 +46,7 @@ function canApplyChartBars(
}
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
import ChartRightToolbar from './ChartRightToolbar';
import CandlePaneTimeAxis from './CandlePaneTimeAxis';
import ChartHoverToolbar from './ChartHoverToolbar';
import ChartMagnifier from './ChartMagnifier';
@@ -225,6 +226,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevIndicatorsListRef = useRef<IndicatorConfig[]>(indicators);
const indicatorSyncInFlightRef = useRef(false);
const [paneLegendPaused, setPaneLegendPaused] = useState(false);
const [chartBodyHeight, setChartBodyHeight] = useState(0);
const pendingIndicatorResyncRef = useRef(false);
const indicatorReloadGenRef = useRef(0);
const prevMarket = useRef<string>(market);
@@ -242,6 +244,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
const paneDragRef = useRef<((id: string, e: React.PointerEvent) => void) | null>(null);
useEffect(() => {
if (barsMarket === market) {
@@ -259,6 +262,16 @@ const TradingChart: React.FC<TradingChartProps> = ({
chartVisibleRef.current = chartVisible;
}, [chartVisible]);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => setChartBodyHeight(el.clientHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [chartMgr]);
const toggleCandleOnly = useCallback(() => {
setCandleOnlyMode(v => !v);
}, []);
@@ -1360,10 +1373,27 @@ const TradingChart: React.FC<TradingChartProps> = ({
style={{ flex: 1, position: 'relative', minHeight: 0 }}
onPointerMove={handleWrapperMouseMove}
>
<div
ref={containerRef}
className="chart-container"
/>
<div className="chart-main-row">
<div
ref={containerRef}
className="chart-container"
/>
{chartMgr && !candleOnlyMode && showPaneLegend && (
<ChartRightToolbar
manager={chartMgr}
indicators={indicators}
chartHeight={chartBodyHeight}
paused={paneLegendPaused}
onSplit={onSplitIndicatorPane}
onRemove={onRemoveIndicator}
onDuplicate={onDuplicateIndicator}
onExpand={onExpandIndicator}
onRestore={onRestoreIndicators}
focusedId={focusedIndicatorId}
onDragStart={(id, e) => paneDragRef.current?.(id, e)}
/>
)}
</div>
{ctxMenu && (
<ChartContextMenu
x={ctxMenu.x}
@@ -1423,12 +1453,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })}
onReorder={onReorderIndicators}
onMerge={onMergeIndicators}
onSplit={onSplitIndicatorPane}
onExpand={onExpandIndicator}
onRemove={onRemoveIndicator}
onDuplicate={onDuplicateIndicator}
focusedId={focusedIndicatorId}
onRestore={onRestoreIndicators}
dragHandleRef={paneDragRef}
crosshairInfoVisible={crosshairInfoVisible}
/>
)}
+123
View File
@@ -0,0 +1,123 @@
/**
* PaneLegend / ChartRightToolbar 공통 — pane 항목·좌표 계산
*/
import type { IndicatorConfig } from '../types';
import { ChartManager } from '../utils/ChartManager';
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
import { getGlobalParamKeys, getPlotParamKeys } from '../utils/indicatorSettingsLayout';
import { formatBbLegendLabel } from '../utils/bollingerConfig';
export interface PaneItem {
id: string;
paneIndex: number;
label: string;
plotColors: string[];
hidden?: boolean;
layoutTopY?: number;
layoutHeight?: number;
}
export const MIN_SUB_PANE_HEIGHT = 12;
function makeLabel(config: IndicatorConfig): string {
if (config.type === 'BollingerBands') {
return formatBbLegendLabel(config.params ?? {});
}
const name = getIndicatorChartTitle(config.type);
const def = getIndicatorDef(config.type);
const params = (config.params ?? def?.defaultParams ?? {}) as Record<string, number | string | boolean>;
const plots = config.plots ?? def?.plots ?? [];
const orderedKeys: string[] = [];
for (let i = 0; i < plots.length; i++) {
for (const k of getPlotParamKeys(config.type, plots[i].id, i, plots, params)) {
if (!orderedKeys.includes(k)) orderedKeys.push(k);
}
}
for (const k of getGlobalParamKeys(config.type, params, plots)) {
if (!orderedKeys.includes(k) && typeof params[k] === 'number') orderedKeys.push(k);
}
const nums = orderedKeys
.map(k => params[k])
.filter((v): v is number => typeof v === 'number')
.slice(0, 3);
return nums.length ? `${name} ${nums.join(', ')}` : name;
}
function applyPaneLayoutToItem(
item: PaneItem,
layout: { topY: number; height: number },
overwrite = false,
): void {
if (!overwrite && item.layoutTopY != null) return;
item.layoutTopY = layout.topY;
item.layoutHeight = layout.height;
}
export function buildPaneItems(
manager: ChartManager,
indicators: IndicatorConfig[],
): PaneItem[] {
const wantedIds = new Set(
indicators
.filter(ind => {
const def = getIndicatorDef(ind.type);
return def && !def.overlay && !def.returnsMarkers;
})
.map(ind => ind.id),
);
const configById = new Map(indicators.map(ind => [ind.id, ind]));
const items: PaneItem[] = [];
const seenIds = new Set<string>();
for (const info of manager.getIndicatorPaneInfo()) {
if (!wantedIds.has(info.id) || seenIds.has(info.id)) continue;
seenIds.add(info.id);
const ind = configById.get(info.id) ?? info.config;
const def = getIndicatorDef(ind.type);
if (!def || def.overlay || def.returnsMarkers) continue;
let chartPane = info.paneIndex;
if (ind.mergedWith) {
const host = manager.getIndicatorPaneIndex(ind.mergedWith);
if (host >= 2) chartPane = host;
}
items.push({
id: info.id,
paneIndex: chartPane,
label: makeLabel(ind),
plotColors: info.plotColors.map(c => c.slice(0, 7)),
hidden: ind.hidden === true,
});
}
if (items.length === 0) return [];
items.sort((a, b) => a.paneIndex - b.paneIndex);
for (const item of items) {
const lay = manager.getIndicatorPaneScreenLayout(item.id);
if (lay) applyPaneLayoutToItem(item, lay, true);
}
items.sort((a, b) => {
const ay = a.layoutTopY ?? 1e9;
const by = b.layoutTopY ?? 1e9;
if (ay !== by) return ay - by;
return a.paneIndex - b.paneIndex;
});
return items.filter(
item => item.layoutTopY != null && (item.layoutHeight ?? 0) > MIN_SUB_PANE_HEIGHT,
);
}
export function resolvePaneItemLayout(
manager: ChartManager,
item: PaneItem,
): { topY: number; height: number } | null {
return manager.getIndicatorPaneScreenLayout(item.id);
}