custom 툴발 적용

This commit is contained in:
Macbook
2026-06-03 01:09:06 +09:00
parent 76e5b12d19
commit 88fe92d8c3
8 changed files with 1110 additions and 22 deletions
+96
View File
@@ -1103,6 +1103,34 @@ html.theme-blue {
animation: flyout-in-right 0.1s ease; animation: flyout-in-right 0.1s ease;
} }
/* Custom / Custom1 — body 포털, 차트 레이어 위·항목 색상 통일 */
.tv-side-flyout--custom-overlay {
z-index: 10050;
background: var(--bg2);
isolation: isolate;
}
.tv-side-flyout--custom-overlay .tv-side-flyout-item {
color: var(--text2);
background: transparent;
}
.tv-side-flyout--custom-overlay .tv-side-flyout-item:hover,
.tv-side-flyout--custom-overlay .tv-side-flyout-item:focus-visible {
background: var(--bg3);
color: var(--text);
}
.tv-side-flyout--custom-overlay .tv-side-flyout-item.active {
background: rgba(122, 162, 247, 0.15);
color: var(--accent);
}
.tv-side-flyout--custom-overlay .tv-side-flyout-item.active:hover {
background: rgba(122, 162, 247, 0.22);
color: var(--accent);
}
.tv-side-flyout--custom-overlay .tv-side-flyout-icon,
.tv-side-flyout--custom-overlay .tv-side-flyout-title {
color: inherit;
}
@keyframes flyout-in-right { @keyframes flyout-in-right {
from { opacity: 0; transform: translateX(calc(-100% + 6px)); } from { opacity: 0; transform: translateX(calc(-100% + 6px)); }
to { opacity: 1; transform: translateX(-100%); } to { opacity: 1; transform: translateX(-100%); }
@@ -2561,6 +2589,74 @@ html.theme-blue {
border-color: rgba(122, 162, 247, 0.65); border-color: rgba(122, 162, 247, 0.65);
background: rgba(122, 162, 247, 0.22); background: rgba(122, 162, 247, 0.22);
} }
.chart-right-toolbar-btn--custom1 {
position: relative;
}
.chart-right-toolbar-custom1-badge {
position: absolute;
right: 2px;
bottom: 2px;
font-size: 8px;
font-weight: 700;
line-height: 1;
color: var(--text-muted, #787b86);
pointer-events: none;
}
.chart-right-toolbar-btn--custom1.active .chart-right-toolbar-custom1-badge {
color: var(--accent, #2962ff);
}
.chart-right-toolbar-btn--custom.active,
.chart-right-toolbar-btn--custom1.active {
color: var(--accent);
border-color: rgba(122, 162, 247, 0.65);
background: rgba(122, 162, 247, 0.22);
}
/* Custom 오버레이 설정 모달 (보조지표 설정 ibsm 스타일) */
.ccos-dialog {
max-height: 88vh;
}
.ccos-hint {
margin: 0;
padding: 8px 14px 0;
}
.ccos-body {
padding-top: 8px;
}
.ccos-item-grid {
display: grid;
gap: 8px 12px;
align-items: center;
}
.ccos-item-grid--2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ccos-item-grid--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.ccos-grid-item {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 6px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.ccos-grid-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.ccos-grid-toggle {
flex-shrink: 0;
}
.ccos-grid-label {
font-size: 12px;
color: var(--text);
line-height: 1.3;
flex: 1;
min-width: 0;
}
/* ── PaneLegend 드래그 핸들 ─────────────────────────────────────────── */ /* ── PaneLegend 드래그 핸들 ─────────────────────────────────────────── */
.pane-legend-item { .pane-legend-item {
+102 -1
View File
@@ -54,6 +54,15 @@ import {
DEFAULT_CHART_OVERLAY_VISIBILITY, DEFAULT_CHART_OVERLAY_VISIBILITY,
listChartOverlayToggleItems, listChartOverlayToggleItems,
} from './utils/chartOverlayVisibility'; } from './utils/chartOverlayVisibility';
import {
type ChartCustomOverlaySelection,
type ChartCustomOverlaySlotId,
createDefaultChartCustomOverlaySelection,
isCustomOverlaySlotActive,
resolveActiveCustomOverlaySlot,
syncChartManagerCustomOverlaysByActiveSlot,
} from './utils/chartCustomOverlay';
import ChartCustomOverlaySettingsModal from './components/ChartCustomOverlaySettingsModal';
import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi'; import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi';
import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage'; import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData'; import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
@@ -1455,6 +1464,73 @@ function App() {
[overlayVisibility], [overlayVisibility],
); );
const [customOverlaySelection, setCustomOverlaySelection] = useState<ChartCustomOverlaySelection>(
() => createDefaultChartCustomOverlaySelection(),
);
const [custom1OverlaySelection, setCustom1OverlaySelection] = useState<ChartCustomOverlaySelection>(
() => createDefaultChartCustomOverlaySelection(),
);
const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState<ChartCustomOverlaySlotId | null>(null);
const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState<ChartCustomOverlaySlotId | null>(null);
const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom');
const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1');
const syncCustomOverlaysToManager = useCallback((mgr: import('./utils/ChartManager').ChartManager) => {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
activeCustomOverlaySlot,
customOverlaySelection,
custom1OverlaySelection,
);
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleCustomOverlayActiveChange = useCallback((active: boolean) => {
const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot);
setActiveCustomOverlaySlot(nextSlot);
const mgr = managerRef.current;
if (mgr) {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
nextSlot,
customOverlaySelection,
custom1OverlaySelection,
);
}
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleCustom1OverlayActiveChange = useCallback((active: boolean) => {
const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot);
setActiveCustomOverlaySlot(nextSlot);
const mgr = managerRef.current;
if (mgr) {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
nextSlot,
customOverlaySelection,
custom1OverlaySelection,
);
}
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleSaveCustomOverlaySelection = useCallback((
slot: ChartCustomOverlaySlotId,
selection: ChartCustomOverlaySelection,
) => {
const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection;
const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection;
if (slot === 'custom') setCustomOverlaySelection(selection);
else setCustom1OverlaySelection(selection);
setCustomOverlaySettingsSlot(null);
const mgr = managerRef.current;
if (!mgr) return;
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
activeCustomOverlaySlot,
nextCustomSel,
nextCustom1Sel,
);
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
setOverlayVisibility(prev => { setOverlayVisibility(prev => {
const next = { ...prev, [key]: !prev[key] }; const next = { ...prev, [key]: !prev[key] };
@@ -2451,7 +2527,9 @@ function App() {
onCrosshair={setLegend} onCrosshair={setLegend}
onTradeOrderRequest={applyTradeFill} onTradeOrderRequest={applyTradeFill}
onDataLoaded={() => { onDataLoaded={() => {
managerRef.current?.setChartOverlayVisibility(overlayVisibilityRef.current); const mgr = managerRef.current;
mgr?.setChartOverlayVisibility(overlayVisibilityRef.current);
if (mgr) syncCustomOverlaysToManager(mgr);
syncBacktestMarkersRef.current(); syncBacktestMarkersRef.current();
}} }}
onCandlesReady={handleCandlesReady} onCandlesReady={handleCandlesReady}
@@ -2474,6 +2552,7 @@ function App() {
mgr.setPaneSeparatorOptions(chartPaneSeparator); mgr.setPaneSeparatorOptions(chartPaneSeparator);
mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat); mgr.setDisplayTimezone(displayTimezone, timeframe, chartTimeFormat);
mgr.setChartOverlayVisibility(overlayVisibilityRef.current); mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
syncCustomOverlaysToManager(mgr);
syncBacktestMarkersRef.current(); syncBacktestMarkersRef.current();
// 왼쪽 스크롤 감지 → 과거 데이터 추가 로드 // 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
mgr.subscribeVisibleLogicalRange(r => { mgr.subscribeVisibleLogicalRange(r => {
@@ -2520,8 +2599,30 @@ function App() {
paneSeparatorOptions={chartPaneSeparator} paneSeparatorOptions={chartPaneSeparator}
candleOverlayToggles={candleOverlayToggles} candleOverlayToggles={candleOverlayToggles}
onToggleCandleOverlay={handleToggleCandleOverlay} onToggleCandleOverlay={handleToggleCandleOverlay}
customOverlayActive={customOverlayActive}
onCustomOverlayActiveChange={handleCustomOverlayActiveChange}
onOpenCustomOverlaySettings={() => setCustomOverlaySettingsSlot('custom')}
custom1OverlayActive={custom1OverlayActive}
onCustom1OverlayActiveChange={handleCustom1OverlayActiveChange}
onOpenCustom1OverlaySettings={() => setCustomOverlaySettingsSlot('custom1')}
showHoverToolbar={chartHoverToolbarVisible} showHoverToolbar={chartHoverToolbarVisible}
/> />
{customOverlaySettingsSlot === 'custom' && (
<ChartCustomOverlaySettingsModal
title="Custom 표시 설정"
selection={customOverlaySelection}
onSave={sel => handleSaveCustomOverlaySelection('custom', sel)}
onCancel={() => setCustomOverlaySettingsSlot(null)}
/>
)}
{customOverlaySettingsSlot === 'custom1' && (
<ChartCustomOverlaySettingsModal
title="Custom1 표시 설정"
selection={custom1OverlaySelection}
onSave={sel => handleSaveCustomOverlaySelection('custom1', sel)}
onCancel={() => setCustomOverlaySettingsSlot(null)}
/>
)}
{isSingleLoadingMore && ( {isSingleLoadingMore && (
<div className="chart-history-loading"> <div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 14, height: 14 }} /> <div className="loading-spinner" style={{ width: 14, height: 14 }} />
@@ -0,0 +1,267 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
type ChartCustomOverlaySelection,
type CustomOverlaySectionId,
cloneChartCustomOverlaySelection,
smaMaLabel,
BB_CUSTOM_LABELS,
ICHIMOKU_CUSTOM_ROWS,
isCustomSectionAllSelected,
setCustomSectionAllSelected,
} from '../utils/chartCustomOverlay';
import { SMA_MA_COUNT, smaPlotId } from '../utils/smaConfig';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
interface ChartCustomOverlaySettingsModalProps {
title?: string;
selection: ChartCustomOverlaySelection;
onSave: (selection: ChartCustomOverlaySelection) => void;
onCancel: () => void;
}
const SECTION_META: Array<{
id: CustomOverlaySectionId;
ko: string;
en: string;
cols: 2 | 3;
}> = [
{ id: 'sma', ko: '이동평균선', en: 'Simple Moving Average (SMA)', cols: 3 },
{ id: 'bollinger', ko: '볼린저 밴드', en: 'Bollinger Bands', cols: 2 },
{ id: 'ichimoku', ko: '일목균형표', en: 'Ichimoku Cloud', cols: 3 },
{ id: 'candle', ko: '캔들차트', en: 'Candlestick', cols: 2 },
];
const ALL_SECTIONS = new Set<CustomOverlaySectionId>(SECTION_META.map(s => s.id));
const GridToggle: React.FC<{
label: string;
checked: boolean;
onChange: (v: boolean) => void;
}> = ({ label, checked, onChange }) => (
<div className="ccos-grid-item">
<label className="ibsm-toggle ccos-grid-toggle" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={checked}
onChange={e => onChange(e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
<span className="ccos-grid-label">{label}</span>
</div>
);
const ChartCustomOverlaySettingsModal: React.FC<ChartCustomOverlaySettingsModalProps> = ({
title = 'Custom 표시 설정',
selection,
onSave,
onCancel,
}) => {
const draftRef = useRef(cloneChartCustomOverlaySelection(selection));
const [, bump] = useState(0);
const refresh = useCallback(() => bump(n => n + 1), []);
const [expanded, setExpanded] = useState<Set<CustomOverlaySectionId>>(
() => new Set(ALL_SECTIONS),
);
const {
panelRef,
dragging,
onHeaderPointerDown,
headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: true });
useEffect(() => {
draftRef.current = cloneChartCustomOverlaySelection(selection);
setExpanded(new Set(ALL_SECTIONS));
refresh();
}, [selection, refresh]);
const draft = draftRef.current;
const patch = useCallback((patchFn: (d: ChartCustomOverlaySelection) => void) => {
patchFn(draftRef.current);
refresh();
}, [refresh]);
const toggleSectionExpand = useCallback((id: CustomOverlaySectionId) => {
setExpanded(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleSectionAll = useCallback((id: CustomOverlaySectionId) => {
patch(d => {
const allOn = isCustomSectionAllSelected(d, id);
setCustomSectionAllSelected(d, id, !allOn);
});
}, [patch]);
const renderSectionItems = (sectionId: CustomOverlaySectionId) => {
switch (sectionId) {
case 'sma':
return Array.from({ length: SMA_MA_COUNT }, (_, i) => {
const pid = smaPlotId(i);
return (
<GridToggle
key={pid}
label={smaMaLabel(i)}
checked={draft.smaPlots[pid] !== false}
onChange={v => patch(d => { d.smaPlots[pid] = v; })}
/>
);
});
case 'bollinger':
return (
<>
<GridToggle
label={BB_CUSTOM_LABELS.plot0}
checked={draft.bollinger.basis}
onChange={v => patch(d => { d.bollinger.basis = v; })}
/>
<GridToggle
label={BB_CUSTOM_LABELS.plot1}
checked={draft.bollinger.upper}
onChange={v => patch(d => { d.bollinger.upper = v; })}
/>
<GridToggle
label={BB_CUSTOM_LABELS.plot2}
checked={draft.bollinger.lower}
onChange={v => patch(d => { d.bollinger.lower = v; })}
/>
<GridToggle
label="백그라운드 그리기"
checked={draft.bollinger.background}
onChange={v => patch(d => { d.bollinger.background = v; })}
/>
</>
);
case 'ichimoku':
return ICHIMOKU_CUSTOM_ROWS.map(row => (
<GridToggle
key={row.key}
label={row.label}
checked={draft.ichimoku[row.key]}
onChange={v => patch(d => { d.ichimoku[row.key] = v; })}
/>
));
case 'candle':
return (
<GridToggle
label="캔들차트"
checked={draft.candle}
onChange={v => patch(d => { d.candle = v; })}
/>
);
default:
return null;
}
};
return (
<div
className="ibsm-overlay"
onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}
>
<div
ref={panelRef}
className="ibsm-dialog ibsm-dialog-wide ccos-dialog"
onMouseDown={e => e.stopPropagation()}
style={{
...panelStyle,
zIndex: 5002,
cursor: dragging ? 'grabbing' : undefined,
}}
>
<div
className="gc-popup-header ibsm-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title ibsm-header-title">{title}</span>
<div className="ibsm-header-actions">
<button type="button" className="gc-popup-close ibsm-icon-btn" onClick={onCancel} title="닫기">
</button>
</div>
</div>
<p className="ibsm-hint ccos-hint">
Custom <strong></strong> . on/off와는 ,
·.
</p>
<div className="ibsm-body ccos-body">
{SECTION_META.map(meta => {
const isExpanded = expanded.has(meta.id);
const allOn = isCustomSectionAllSelected(draft, meta.id);
return (
<div
key={meta.id}
className={`ibsm-card ibsm-main-row${allOn ? ' ibsm-main-row--active' : ''}${isExpanded ? ' ibsm-card-expanded' : ''}`}
>
<div className="ibsm-card-main ibsm-main-row-head">
<div className="ibsm-main-row-info">
<span className="ibsm-main-row-ko">{meta.ko}</span>
<span className="ibsm-main-row-en">{meta.en}</span>
</div>
<div className="ibsm-main-row-badges">
<span className={allOn ? 'ibsm-main-badge ibsm-main-badge--on' : 'ibsm-main-badge'}>
{allOn ? '전체 선택' : '일부 선택'}
</span>
</div>
<label
className="ibsm-toggle"
title={allOn ? '전체 해제' : '전체 선택'}
onClick={e => e.stopPropagation()}
>
<input
type="checkbox"
checked={allOn}
onChange={() => toggleSectionAll(meta.id)}
/>
<span className="ism-toggle-slider" />
</label>
<button
type="button"
className="ibsm-expand"
onClick={() => toggleSectionExpand(meta.id)}
aria-expanded={isExpanded}
title={isExpanded ? '접기' : '펼치기'}
>
{isExpanded ? '▲' : '▼'}
</button>
</div>
{isExpanded && (
<div className={`ibsm-card-expand ccos-item-grid ccos-item-grid--${meta.cols}`}>
{renderSectionItems(meta.id)}
</div>
)}
</div>
);
})}
</div>
<div className="ibsm-footer">
<button type="button" className="ism-btn-cancel" onClick={onCancel}>
</button>
<button
type="button"
className="ism-btn-ok"
onClick={() => onSave(cloneChartCustomOverlaySelection(draftRef.current))}
>
</button>
</div>
</div>
</div>
);
};
export default ChartCustomOverlaySettingsModal;
+146 -1
View File
@@ -3,6 +3,7 @@
* pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료) * pane 하단: 드래그 핸들 + 펼침메뉴(분리·복사·전체보기·종료)
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { IndicatorConfig } from '../types'; import type { IndicatorConfig } from '../types';
import { ChartManager } from '../utils/ChartManager'; import { ChartManager } from '../utils/ChartManager';
import { getPaneHostId } from '../utils/indicatorPaneMerge'; import { getPaneHostId } from '../utils/indicatorPaneMerge';
@@ -35,6 +36,13 @@ export interface ChartRightToolbarProps {
onToggleHidden?: (id: string) => void; onToggleHidden?: (id: string) => void;
/** 지표명 툴바 — 설정 모달 */ /** 지표명 툴바 — 설정 모달 */
onSettings?: (id: string) => void; onSettings?: (id: string) => void;
/** Custom / Custom1 오버레이 — 적용 여부 (동시 적용 불가, 설정은 슬롯별 분리) */
customOverlayActive?: boolean;
onCustomOverlayActiveChange?: (active: boolean) => void;
onOpenCustomOverlaySettings?: () => void;
custom1OverlayActive?: boolean;
onCustom1OverlayActiveChange?: (active: boolean) => void;
onOpenCustom1OverlaySettings?: () => void;
} }
const BTN_SIZE = 32; const BTN_SIZE = 32;
@@ -159,6 +167,24 @@ const IconEye = ({ hidden }: { hidden?: boolean }) => hidden ? (
</svg> </svg>
); );
const CUSTOM_MENU_ID = '__custom_overlay__';
const CUSTOM1_MENU_ID = '__custom1_overlay__';
const IconCustom = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M2 4h10M2 7h10M2 10h6"/>
<circle cx="10.5" cy="10" r="1.2" fill="currentColor" stroke="none"/>
</svg>
);
const IconCheck = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="2,7.5 5.5,11 12,3"/>
</svg>
);
const IconGear = () => ( const IconGear = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
<circle cx="7" cy="7" r="2.3"/> <circle cx="7" cy="7" r="2.3"/>
@@ -166,6 +192,62 @@ const IconGear = () => (
</svg> </svg>
); );
const CustomOverlayFlyoutMenu: React.FC<{
title: string;
flyoutPos: { top: number; left: number };
overlayActive: boolean;
onToggleActive: () => void;
onOpenSettings: () => void;
onCancelClose: () => void;
onScheduleClose: () => void;
onCloseFlyout: () => void;
}> = ({
title,
flyoutPos,
overlayActive,
onToggleActive,
onOpenSettings,
onCancelClose,
onScheduleClose,
onCloseFlyout,
}) => createPortal(
<div
className="tv-side-flyout tv-side-flyout--right-anchor tv-side-flyout--custom-overlay"
style={{ top: flyoutPos.top, left: flyoutPos.left }}
onMouseEnter={onCancelClose}
onMouseLeave={onScheduleClose}
>
<div className="tv-side-flyout-header">{title}</div>
<button
type="button"
className={`tv-side-flyout-item${overlayActive ? ' active' : ''}`}
onClick={e => {
e.stopPropagation();
onToggleActive();
onCloseFlyout();
}}
>
<span className="tv-side-flyout-icon"><IconCheck /></span>
<span className="tv-side-flyout-title">
{overlayActive ? '미적용' : '적용'}
</span>
</button>
<button
type="button"
className="tv-side-flyout-item"
onClick={e => {
e.stopPropagation();
onOpenSettings();
onCloseFlyout();
}}
>
<span className="tv-side-flyout-icon"><IconGear /></span>
<span className="tv-side-flyout-title"></span>
</button>
</div>,
document.body,
);
interface MenuItemDef { interface MenuItemDef {
key: string; key: string;
title: string; title: string;
@@ -266,6 +348,12 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
manager, indicators, chartHeight, paused = false, manager, indicators, chartHeight, paused = false,
candleOverlayToggles, candleOverlayToggles,
onToggleCandleOverlay, onToggleCandleOverlay,
customOverlayActive = false,
onCustomOverlayActiveChange,
onOpenCustomOverlaySettings,
custom1OverlayActive = false,
onCustom1OverlayActiveChange,
onOpenCustom1OverlaySettings,
onSplit, onRemove, onDuplicate, onSplit, onRemove, onDuplicate,
onExpand, onRestore, focusedId, onExpand, onRestore, focusedId,
onDragStart, onToggleHidden, onSettings, onDragStart, onToggleHidden, onSettings,
@@ -341,6 +429,10 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
const candlePane = paneLayouts.find(l => l.paneIndex === 0); const candlePane = paneLayouts.find(l => l.paneIndex === 0);
const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay); const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay);
const showCustomBtn = !!(showOverlayIcons && onCustomOverlayActiveChange && onOpenCustomOverlaySettings);
const showCustom1Btn = !!(showOverlayIcons && onCustom1OverlayActiveChange && onOpenCustom1OverlaySettings);
const customMenuOpen = openMenuId === CUSTOM_MENU_ID;
const custom1MenuOpen = openMenuId === CUSTOM1_MENU_ID;
const focusedLayoutId = focusedId const focusedLayoutId = focusedId
? resolveFocusedPaneLayoutId(focusedId, indicators) ? resolveFocusedPaneLayoutId(focusedId, indicators)
@@ -395,6 +487,33 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
{OVERLAY_ICONS[item.key]} {OVERLAY_ICONS[item.key]}
</button> </button>
))} ))}
{showCustomBtn && (
<button
type="button"
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--custom${customOverlayActive ? ' active' : ''}${customMenuOpen ? ' active' : ''}`}
title="Custom 표시"
style={{ marginTop: BTN_GAP }}
onMouseEnter={e => openFlyout(CUSTOM_MENU_ID, e.currentTarget)}
onMouseLeave={scheduleClose}
>
<IconCustom />
<span className="tv-side-expand-arrow" />
</button>
)}
{showCustom1Btn && (
<button
type="button"
className={`tv-side-btn tv-side-group-btn chart-right-toolbar-btn chart-right-toolbar-btn--custom1${custom1OverlayActive ? ' active' : ''}${custom1MenuOpen ? ' active' : ''}`}
title="Custom1 표시"
style={{ marginTop: BTN_GAP }}
onMouseEnter={e => openFlyout(CUSTOM1_MENU_ID, e.currentTarget)}
onMouseLeave={scheduleClose}
>
<IconCustom />
<span className="chart-right-toolbar-custom1-badge">1</span>
<span className="tv-side-expand-arrow" />
</button>
)}
</div> </div>
)} )}
@@ -457,7 +576,33 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
})} })}
</div> </div>
{openItem && openMenuId && (() => { {customMenuOpen && showCustomBtn && (
<CustomOverlayFlyoutMenu
title="Custom"
flyoutPos={flyoutPos}
overlayActive={customOverlayActive}
onToggleActive={() => onCustomOverlayActiveChange?.(!customOverlayActive)}
onOpenSettings={() => onOpenCustomOverlaySettings?.()}
onCancelClose={cancelClose}
onScheduleClose={scheduleClose}
onCloseFlyout={closeFlyout}
/>
)}
{custom1MenuOpen && showCustom1Btn && (
<CustomOverlayFlyoutMenu
title="Custom1"
flyoutPos={flyoutPos}
overlayActive={custom1OverlayActive}
onToggleActive={() => onCustom1OverlayActiveChange?.(!custom1OverlayActive)}
onOpenSettings={() => onOpenCustom1OverlaySettings?.()}
onCancelClose={cancelClose}
onScheduleClose={scheduleClose}
onCloseFlyout={closeFlyout}
/>
)}
{openItem && openMenuId && openMenuId !== CUSTOM_MENU_ID && openMenuId !== CUSTOM1_MENU_ID && (() => {
const menuItems = buildMenuItems(openItem, indicators, paneItems, { const menuItems = buildMenuItems(openItem, indicators, paneItems, {
focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove, focusedId, onSplit, onDuplicate, onExpand, onRestore, onRemove,
onToggleHidden, onSettings, onToggleHidden, onSettings,
+101
View File
@@ -51,6 +51,15 @@ import {
DEFAULT_CHART_OVERLAY_VISIBILITY, DEFAULT_CHART_OVERLAY_VISIBILITY,
listChartOverlayToggleItems, listChartOverlayToggleItems,
} from '../utils/chartOverlayVisibility'; } from '../utils/chartOverlayVisibility';
import {
type ChartCustomOverlaySelection,
type ChartCustomOverlaySlotId,
createDefaultChartCustomOverlaySelection,
isCustomOverlaySlotActive,
resolveActiveCustomOverlaySlot,
syncChartManagerCustomOverlaysByActiveSlot,
} from '../utils/chartCustomOverlay';
import ChartCustomOverlaySettingsModal from './ChartCustomOverlaySettingsModal';
import { saveSlot } from '../utils/backendApi'; import { saveSlot } from '../utils/backendApi';
import type { import type {
OHLCVBar, ChartType, Theme, Timeframe, OHLCVBar, ChartType, Theme, Timeframe,
@@ -672,6 +681,73 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
[overlayVisibility], [overlayVisibility],
); );
const [customOverlaySelection, setCustomOverlaySelection] = useState<ChartCustomOverlaySelection>(
() => createDefaultChartCustomOverlaySelection(),
);
const [custom1OverlaySelection, setCustom1OverlaySelection] = useState<ChartCustomOverlaySelection>(
() => createDefaultChartCustomOverlaySelection(),
);
const [activeCustomOverlaySlot, setActiveCustomOverlaySlot] = useState<ChartCustomOverlaySlotId | null>(null);
const [customOverlaySettingsSlot, setCustomOverlaySettingsSlot] = useState<ChartCustomOverlaySlotId | null>(null);
const customOverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom');
const custom1OverlayActive = isCustomOverlaySlotActive(activeCustomOverlaySlot, 'custom1');
const syncCustomOverlaysToManager = useCallback((mgr: ChartManager) => {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
activeCustomOverlaySlot,
customOverlaySelection,
custom1OverlaySelection,
);
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleCustomOverlayActiveChange = useCallback((active: boolean) => {
const nextSlot = resolveActiveCustomOverlaySlot('custom', active, activeCustomOverlaySlot);
setActiveCustomOverlaySlot(nextSlot);
const mgr = managerRef.current;
if (mgr) {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
nextSlot,
customOverlaySelection,
custom1OverlaySelection,
);
}
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleCustom1OverlayActiveChange = useCallback((active: boolean) => {
const nextSlot = resolveActiveCustomOverlaySlot('custom1', active, activeCustomOverlaySlot);
setActiveCustomOverlaySlot(nextSlot);
const mgr = managerRef.current;
if (mgr) {
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
nextSlot,
customOverlaySelection,
custom1OverlaySelection,
);
}
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleSaveCustomOverlaySelection = useCallback((
slot: ChartCustomOverlaySlotId,
selection: ChartCustomOverlaySelection,
) => {
const nextCustomSel = slot === 'custom' ? selection : customOverlaySelection;
const nextCustom1Sel = slot === 'custom1' ? selection : custom1OverlaySelection;
if (slot === 'custom') setCustomOverlaySelection(selection);
else setCustom1OverlaySelection(selection);
setCustomOverlaySettingsSlot(null);
const mgr = managerRef.current;
if (!mgr) return;
syncChartManagerCustomOverlaysByActiveSlot(
mgr,
activeCustomOverlaySlot,
nextCustomSel,
nextCustom1Sel,
);
}, [activeCustomOverlaySlot, customOverlaySelection, custom1OverlaySelection]);
const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => { const handleToggleCandleOverlay = useCallback((key: ChartOverlayToggleKey) => {
setOverlayVisibility(prev => { setOverlayVisibility(prev => {
const next = { ...prev, [key]: !prev[key] }; const next = { ...prev, [key]: !prev[key] };
@@ -937,6 +1013,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
mgr.setVolumeVisible(chartVolumeVisible); mgr.setVolumeVisible(chartVolumeVisible);
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator); if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
mgr.setChartOverlayVisibility(overlayVisibilityRef.current); mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
syncCustomOverlaysToManager(mgr);
// Time sync: 가시 시간 범위 변화 구독 // Time sync: 가시 시간 범위 변화 구독
mgr.subscribeVisibleTimeRange(r => { mgr.subscribeVisibleTimeRange(r => {
if (!r || isSyncingTimeRef.current) return; if (!r || isSyncingTimeRef.current) return;
@@ -962,6 +1039,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
const mgr = managerRef.current; const mgr = managerRef.current;
if (!mgr) return; if (!mgr) return;
mgr.setChartOverlayVisibility(overlayVisibilityRef.current); mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
syncCustomOverlaysToManager(mgr);
if (skipSyncApplyRef.current) { if (skipSyncApplyRef.current) {
skipSyncApplyRef.current = false; skipSyncApplyRef.current = false;
const fb = mgr.hasIchimoku() ? 28 : 0; const fb = mgr.hasIchimoku() ? 28 : 0;
@@ -1016,8 +1094,31 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined} onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
candleOverlayToggles={compactMode ? undefined : candleOverlayToggles} candleOverlayToggles={compactMode ? undefined : candleOverlayToggles}
onToggleCandleOverlay={compactMode ? undefined : handleToggleCandleOverlay} onToggleCandleOverlay={compactMode ? undefined : handleToggleCandleOverlay}
customOverlayActive={compactMode ? undefined : customOverlayActive}
onCustomOverlayActiveChange={compactMode ? undefined : handleCustomOverlayActiveChange}
onOpenCustomOverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom')}
custom1OverlayActive={compactMode ? undefined : custom1OverlayActive}
onCustom1OverlayActiveChange={compactMode ? undefined : handleCustom1OverlayActiveChange}
onOpenCustom1OverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom1')}
/> />
{customOverlaySettingsSlot === 'custom' && !compactMode && (
<ChartCustomOverlaySettingsModal
title="Custom 표시 설정"
selection={customOverlaySelection}
onSave={sel => handleSaveCustomOverlaySelection('custom', sel)}
onCancel={() => setCustomOverlaySettingsSlot(null)}
/>
)}
{customOverlaySettingsSlot === 'custom1' && !compactMode && (
<ChartCustomOverlaySettingsModal
title="Custom1 표시 설정"
selection={custom1OverlaySelection}
onSave={sel => handleSaveCustomOverlaySelection('custom1', sel)}
onCancel={() => setCustomOverlaySettingsSlot(null)}
/>
)}
{/* 컨텍스트 툴바 */} {/* 컨텍스트 툴바 */}
{ctxToolbar && (() => { {ctxToolbar && (() => {
const cfg = ctxToolbar.entryId !== '__main__' const cfg = ctxToolbar.entryId !== '__main__'
+18
View File
@@ -165,6 +165,12 @@ interface TradingChartProps {
/** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */ /** 캔들 오버레이(이동평균·일목·볼린저) 표시 토글 — 우측 툴바 상단 */
candleOverlayToggles?: CandleOverlayToggleItem[]; candleOverlayToggles?: CandleOverlayToggleItem[];
onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void; onToggleCandleOverlay?: (key: import('../utils/chartOverlayVisibility').ChartOverlayToggleKey) => void;
customOverlayActive?: boolean;
onCustomOverlayActiveChange?: (active: boolean) => void;
onOpenCustomOverlaySettings?: () => void;
custom1OverlayActive?: boolean;
onCustom1OverlayActiveChange?: (active: boolean) => void;
onOpenCustom1OverlaySettings?: () => void;
} }
const TradingChart: React.FC<TradingChartProps> = ({ const TradingChart: React.FC<TradingChartProps> = ({
@@ -209,6 +215,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
showCandlePaneControls = true, showCandlePaneControls = true,
candleOverlayToggles, candleOverlayToggles,
onToggleCandleOverlay, onToggleCandleOverlay,
customOverlayActive,
onCustomOverlayActiveChange,
onOpenCustomOverlaySettings,
custom1OverlayActive,
onCustom1OverlayActiveChange,
onOpenCustom1OverlaySettings,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼 const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
@@ -1609,6 +1621,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
paused={paneLegendPaused} paused={paneLegendPaused}
candleOverlayToggles={candleOverlayToggles} candleOverlayToggles={candleOverlayToggles}
onToggleCandleOverlay={onToggleCandleOverlay} onToggleCandleOverlay={onToggleCandleOverlay}
customOverlayActive={customOverlayActive}
onCustomOverlayActiveChange={onCustomOverlayActiveChange}
onOpenCustomOverlaySettings={onOpenCustomOverlaySettings}
custom1OverlayActive={custom1OverlayActive}
onCustom1OverlayActiveChange={onCustom1OverlayActiveChange}
onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings}
onSplit={onSplitIndicatorPane} onSplit={onSplitIndicatorPane}
onRemove={onRemoveIndicator} onRemove={onRemoveIndicator}
onDuplicate={onDuplicateIndicator} onDuplicate={onDuplicateIndicator}
+143 -20
View File
@@ -64,7 +64,15 @@ import {
type ChartOverlayVisibility, type ChartOverlayVisibility,
DEFAULT_CHART_OVERLAY_VISIBILITY, DEFAULT_CHART_OVERLAY_VISIBILITY,
chartOverlayKeyForType, chartOverlayKeyForType,
isMovingAverageOverlayType,
} from './chartOverlayVisibility'; } from './chartOverlayVisibility';
import {
type ChartCustomOverlaySelection,
type ChartCustomOverlaySlotId,
createDefaultChartCustomOverlaySelection,
isCustomIchimokuCloudVisible,
isCustomPlotSelected,
} from './chartCustomOverlay';
import type { PlotDef, HLineStyle } from './indicatorRegistry'; import type { PlotDef, HLineStyle } from './indicatorRegistry';
import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils'; import { mapHistogramSeriesData, histogramBarColor } from './plotColorUtils';
import { import {
@@ -171,8 +179,13 @@ function shouldShowBbBandFill(config: IndicatorConfig): boolean {
return true; return true;
} }
function attachBbBandFill(entry: IndicatorEntry, config: IndicatorConfig): void { function attachBbBandFill(
if (!shouldShowBbBandFill(config)) { entry: IndicatorEntry,
config: IndicatorConfig,
showFill?: boolean,
): void {
const show = showFill ?? shouldShowBbBandFill(config);
if (!show) {
detachBbBandFill(entry); detachBbBandFill(entry);
return; return;
} }
@@ -240,6 +253,36 @@ export class ChartManager {
private _chartOverlayVisibility: ChartOverlayVisibility = { private _chartOverlayVisibility: ChartOverlayVisibility = {
...DEFAULT_CHART_OVERLAY_VISIBILITY, ...DEFAULT_CHART_OVERLAY_VISIBILITY,
}; };
private _customOverlaySlots: Record<
ChartCustomOverlaySlotId,
{ active: boolean; selection: ChartCustomOverlaySelection }
> = {
custom: {
active: false,
selection: createDefaultChartCustomOverlaySelection(),
},
custom1: {
active: false,
selection: createDefaultChartCustomOverlaySelection(),
},
};
private _appliedCustomOverlaySlot(): ChartCustomOverlaySlotId | null {
if (this._customOverlaySlots.custom1.active) return 'custom1';
if (this._customOverlaySlots.custom.active) return 'custom';
return null;
}
private _isCustomOverlayApplied(): boolean {
return this._appliedCustomOverlaySlot() !== null;
}
private _getAppliedCustomOverlaySelection(): ChartCustomOverlaySelection {
const slot = this._appliedCustomOverlaySlot();
return slot
? this._customOverlaySlots[slot].selection
: createDefaultChartCustomOverlaySelection();
}
// ── 인디케이터 실시간 갱신 스로틀 ───────────────────────────────────────── // ── 인디케이터 실시간 갱신 스로틀 ─────────────────────────────────────────
private _indUpdateTimer: ReturnType<typeof setTimeout> | null = null; private _indUpdateTimer: ReturnType<typeof setTimeout> | null = null;
@@ -731,7 +774,10 @@ export class ChartManager {
series.setData(mapHistogramSeriesData(filtered, plotDef)); series.setData(mapHistogramSeriesData(filtered, plotDef));
seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color }); seriesMeta.push({ plotId: plotDef.id, isHistogram: true, color: plotDef.color });
} else { } else {
const isPlotVisible = !indicatorHidden && config.plotVisibility?.[plotDef.id] !== false; const isPlotVisible = !indicatorHidden
&& (this._isCustomOverlayApplied() && (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud')
? isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotDef.id)
: config.plotVisibility?.[plotDef.id] !== false);
const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane); const showPriceLabel = this.shouldShowSeriesPriceLabel(config, true, pane);
const showSeriesTitle = pane < 2 && showPriceLabel; const showSeriesTitle = pane < 2 && showPriceLabel;
series = this.chart.addSeries(LineSeries, { series = this.chart.addSeries(LineSeries, {
@@ -804,7 +850,7 @@ export class ChartManager {
this.indicators.set(config.id, entry); this.indicators.set(config.id, entry);
this.applyIndicatorStyle(config); this.applyIndicatorStyle(config);
if (config.type === 'BollingerBands' && !indicatorHidden) { if (config.type === 'BollingerBands' && !indicatorHidden) {
attachBbBandFill(entry, config); attachBbBandFill(entry, config, this._shouldShowBbBandFillForConfig(config));
entry.bbFillPrimitive?.requestRefresh(); entry.bbFillPrimitive?.requestRefresh();
} }
} catch (e) { } catch (e) {
@@ -1790,13 +1836,21 @@ export class ChartManager {
// bullishVisible/bearishVisible 가 둘 다 false 이면 DB 잘못된 저장값으로 간주하고 // bullishVisible/bearishVisible 가 둘 다 false 이면 DB 잘못된 저장값으로 간주하고
// 기본값(표시)으로 복원한다. 하나만 false 인 경우는 사용자 의도이므로 유지. // 기본값(표시)으로 복원한다. 하나만 false 인 경우는 사용자 의도이므로 유지.
const bullishVis = colors.bullishVisible !== false; let bullishVis = colors.bullishVisible !== false;
const bearishVis = colors.bearishVisible !== false; let bearishVis = colors.bearishVisible !== false;
const effectiveBullish = (!bullishVis && !bearishVis) ? true : bullishVis; if (this._isCustomOverlayApplied()) {
const effectiveBearish = (!bullishVis && !bearishVis) ? true : bearishVis; const sel = this._getAppliedCustomOverlaySelection();
bullishVis = isCustomIchimokuCloudVisible(sel, 'bullish');
bearishVis = isCustomIchimokuCloudVisible(sel, 'bearish');
} else {
const effectiveBullish = (!bullishVis && !bearishVis) ? true : bullishVis;
const effectiveBearish = (!bullishVis && !bearishVis) ? true : bearishVis;
bullishVis = effectiveBullish;
bearishVis = effectiveBearish;
}
plugin.setColors(colors.bullishColor, colors.bearishColor); plugin.setColors(colors.bullishColor, colors.bearishColor);
plugin.setVisibility(effectiveBullish, effectiveBearish); plugin.setVisibility(bullishVis, bearishVis);
if (!this._isIndicatorEffectivelyVisible(config)) { if (!this._isIndicatorEffectivelyVisible(config)) {
plugin.setCloudData([]); plugin.setCloudData([]);
@@ -2379,16 +2433,87 @@ export class ChartManager {
return config.hidden !== true; return config.hidden !== true;
} }
getChartCustomOverlayActive(slot: ChartCustomOverlaySlotId = 'custom'): boolean {
return this._customOverlaySlots[slot].active;
}
getChartCustomOverlaySelection(slot: ChartCustomOverlaySlotId = 'custom'): ChartCustomOverlaySelection {
const sel = this._customOverlaySlots[slot].selection;
return {
smaPlots: { ...sel.smaPlots },
bollinger: { ...sel.bollinger },
ichimoku: { ...sel.ichimoku },
candle: sel.candle,
};
}
setChartCustomOverlaySelection(
selection: ChartCustomOverlaySelection,
slot: ChartCustomOverlaySlotId = 'custom',
): void {
this._customOverlaySlots[slot].selection = {
smaPlots: { ...selection.smaPlots },
bollinger: { ...selection.bollinger },
ichimoku: { ...selection.ichimoku },
candle: selection.candle,
};
if (this._customOverlaySlots[slot].active) {
this.syncChartOverlayVisibility();
}
}
/** 슬롯 적용 시 다른 슬롯은 자동 미적용 (Custom ↔ Custom1 상호 배타) */
setChartCustomOverlayActive(active: boolean, slot: ChartCustomOverlaySlotId = 'custom'): void {
if (this._customOverlaySlots[slot].active === active) return;
this._customOverlaySlots[slot].active = active;
if (active) {
const other: ChartCustomOverlaySlotId = slot === 'custom' ? 'custom1' : 'custom';
this._customOverlaySlots[other].active = false;
}
this.syncChartOverlayVisibility();
}
/** custom 미적용 시 지표 설정 plotVisibility, 적용 시 custom 선택만 반영 */
private _resolveSeriesVisible(config: IndicatorConfig, plotId: string | undefined): boolean {
if (!plotId || !this._isIndicatorEffectivelyVisible(config)) return false;
if (!this._isCustomOverlayApplied()) {
return config.plotVisibility?.[plotId] !== false;
}
if (config.type === 'SMA' || config.type === 'BollingerBands' || config.type === 'IchimokuCloud') {
return isCustomPlotSelected(this._getAppliedCustomOverlaySelection(), config.type, plotId);
}
if (isMovingAverageOverlayType(config.type)) {
return config.plotVisibility?.[plotId] !== false;
}
return config.plotVisibility?.[plotId] !== false;
}
private _resolveCandleVisible(): boolean {
if (!this._chartOverlayVisibility.candle) return false;
if (!this._isCustomOverlayApplied()) return true;
return this._getAppliedCustomOverlaySelection().candle;
}
private _shouldShowBbBandFillForConfig(config: IndicatorConfig): boolean {
if (!this._isIndicatorEffectivelyVisible(config)) return false;
if (!this._isCustomOverlayApplied()) {
const bg = resolveBbBandBackground(config.bandBackground);
if (!bg.visible) return false;
if (config.plotVisibility?.plot1 === false || config.plotVisibility?.plot2 === false) return false;
return true;
}
const b = this._getAppliedCustomOverlaySelection().bollinger;
return b.background && b.upper && b.lower;
}
/** 오버레이·plotVisibility 반영해 시리즈 visible 만 갱신 (플롯 정의 없어도 전체 시리즈 대상) */ /** 오버레이·plotVisibility 반영해 시리즈 visible 만 갱신 (플롯 정의 없어도 전체 시리즈 대상) */
private _applySeriesVisibilityToEntry(entry: IndicatorEntry): void { private _applySeriesVisibilityToEntry(entry: IndicatorEntry): void {
const config = entry.config; const config = entry.config;
if (!config) return; if (!config) return;
const indicatorVisible = this._isIndicatorEffectivelyVisible(config);
entry.seriesList.forEach((series, i) => { entry.seriesList.forEach((series, i) => {
const plotId = entry.seriesMeta[i]?.plotId; const plotId = entry.seriesMeta[i]?.plotId;
const visible = indicatorVisible const visible = this._resolveSeriesVisible(config, plotId);
&& (plotId == null || config.plotVisibility?.[plotId] !== false);
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions({ visible } as any); series.applyOptions({ visible } as any);
@@ -2402,7 +2527,7 @@ export class ChartManager {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(this.mainSeries as ISeriesApi<any>).applyOptions({ (this.mainSeries as ISeriesApi<any>).applyOptions({
visible: this._chartOverlayVisibility.candle, visible: this._resolveCandleVisible(),
}); });
} catch { /* ok */ } } catch { /* ok */ }
} }
@@ -2416,8 +2541,8 @@ export class ChartManager {
if (entry.type === 'BollingerBands') { if (entry.type === 'BollingerBands') {
detachBbBandFill(entry); detachBbBandFill(entry);
if (this._isIndicatorEffectivelyVisible(config)) { if (this._shouldShowBbBandFillForConfig(config)) {
attachBbBandFill(entry, config); attachBbBandFill(entry, config, true);
entry.bbFillPrimitive?.requestRefresh(); entry.bbFillPrimitive?.requestRefresh();
} }
} }
@@ -2551,15 +2676,13 @@ export class ChartManager {
const plotDefs = config.plots ?? def?.plots ?? []; const plotDefs = config.plots ?? def?.plots ?? [];
const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p])); const plotById = Object.fromEntries(plotDefs.map((p: PlotDef) => [p.id, p]));
const indicatorVisible = this._isIndicatorEffectivelyVisible(config); const indicatorVisible = this._isIndicatorEffectivelyVisible(config);
entry.seriesList.forEach((series, i) => { entry.seriesList.forEach((series, i) => {
let plot: PlotDef | undefined; let plot: PlotDef | undefined;
const pid = entry.seriesMeta[i]?.plotId; const pid = entry.seriesMeta[i]?.plotId;
plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i]; plot = (pid ? plotById[pid] : undefined) ?? plotDefs[i];
const visible = indicatorVisible const visible = this._resolveSeriesVisible(config, pid);
&& (pid == null || config.plotVisibility?.[pid] !== false);
if (!plot) { if (!plot) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
series.applyOptions({ visible } as any); series.applyOptions({ visible } as any);
@@ -2645,8 +2768,8 @@ export class ChartManager {
if (entry.type === 'BollingerBands') { if (entry.type === 'BollingerBands') {
detachBbBandFill(entry); detachBbBandFill(entry);
if (indicatorVisible) { if (this._shouldShowBbBandFillForConfig(config)) {
attachBbBandFill(entry, config); attachBbBandFill(entry, config, true);
entry.bbFillPrimitive?.requestRefresh(); entry.bbFillPrimitive?.requestRefresh();
} }
} }
+237
View File
@@ -0,0 +1,237 @@
import { SMA_MA_COUNT, smaPlotId } from './smaConfig';
/** Custom / Custom1 — 동일 기능, 설정만 분리 (세션만, DB 미저장) */
export type ChartCustomOverlaySlotId = 'custom' | 'custom1';
export const CHART_CUSTOM_OVERLAY_SLOTS: ChartCustomOverlaySlotId[] = ['custom', 'custom1'];
/** Custom 적용 시 표시할 항목 (세션만, DB 미저장) */
export type ChartCustomOverlaySelection = {
smaPlots: Record<string, boolean>;
bollinger: {
basis: boolean;
upper: boolean;
lower: boolean;
background: boolean;
};
ichimoku: {
conversion: boolean;
base: boolean;
lagging: boolean;
spanA: boolean;
spanB: boolean;
bullishCloud: boolean;
bearishCloud: boolean;
};
candle: boolean;
};
export const BB_CUSTOM_PLOT_IDS = ['plot0', 'plot1', 'plot2'] as const;
export const BB_CUSTOM_LABELS: Record<(typeof BB_CUSTOM_PLOT_IDS)[number], string> = {
plot0: '중앙값',
plot1: '어퍼',
plot2: '로우어',
};
export function createDefaultChartCustomOverlaySelection(): ChartCustomOverlaySelection {
const smaPlots: Record<string, boolean> = {};
for (let i = 0; i < SMA_MA_COUNT; i++) {
smaPlots[smaPlotId(i)] = true;
}
return {
smaPlots,
bollinger: {
basis: true,
upper: true,
lower: true,
background: true,
},
ichimoku: {
conversion: true,
base: true,
lagging: true,
spanA: true,
spanB: true,
bullishCloud: true,
bearishCloud: true,
},
candle: true,
};
}
export function cloneChartCustomOverlaySelection(
sel: ChartCustomOverlaySelection,
): ChartCustomOverlaySelection {
return {
smaPlots: { ...sel.smaPlots },
bollinger: { ...sel.bollinger },
ichimoku: { ...sel.ichimoku },
candle: sel.candle,
};
}
export function smaMaLabel(plotIndex: number): string {
return `MA${plotIndex + 1}`;
}
export const ICHIMOKU_CUSTOM_ROWS: Array<{
key: keyof ChartCustomOverlaySelection['ichimoku'];
plotId?: string;
label: string;
}> = [
{ key: 'conversion', plotId: 'plot0', label: '전환선' },
{ key: 'base', plotId: 'plot1', label: '기준선' },
{ key: 'lagging', plotId: 'plot2', label: '후행스팬' },
{ key: 'spanA', plotId: 'plot3', label: '선행스팬1' },
{ key: 'spanB', plotId: 'plot4', label: '선행스팬2' },
{ key: 'bullishCloud', label: '상승 구름' },
{ key: 'bearishCloud', label: '하락 구름' },
];
/** 지표 설정 plotVisibility 와 무관 — custom 적용 시 이 맵만 사용 */
export function isCustomPlotSelected(
sel: ChartCustomOverlaySelection,
type: string,
plotId: string,
): boolean {
if (type === 'SMA') {
return sel.smaPlots[plotId] === true;
}
if (type === 'BollingerBands') {
if (plotId === 'plot0') return sel.bollinger.basis;
if (plotId === 'plot1') return sel.bollinger.upper;
if (plotId === 'plot2') return sel.bollinger.lower;
return false;
}
if (type === 'IchimokuCloud') {
const row = ICHIMOKU_CUSTOM_ROWS.find(r => r.plotId === plotId);
if (!row) return false;
return sel.ichimoku[row.key] === true;
}
return true;
}
export function isCustomIchimokuCloudVisible(
sel: ChartCustomOverlaySelection,
which: 'bullish' | 'bearish',
): boolean {
return which === 'bullish'
? sel.ichimoku.bullishCloud
: sel.ichimoku.bearishCloud;
}
export type CustomOverlaySectionId = 'sma' | 'bollinger' | 'ichimoku' | 'candle';
export function isCustomSectionAllSelected(
sel: ChartCustomOverlaySelection,
section: CustomOverlaySectionId,
): boolean {
switch (section) {
case 'sma':
return Array.from({ length: SMA_MA_COUNT }, (_, i) =>
sel.smaPlots[smaPlotId(i)] !== false,
).every(Boolean);
case 'bollinger':
return sel.bollinger.basis
&& sel.bollinger.upper
&& sel.bollinger.lower
&& sel.bollinger.background;
case 'ichimoku':
return ICHIMOKU_CUSTOM_ROWS.every(r => sel.ichimoku[r.key]);
case 'candle':
return sel.candle;
default:
return false;
}
}
export function setCustomSectionAllSelected(
sel: ChartCustomOverlaySelection,
section: CustomOverlaySectionId,
on: boolean,
): void {
switch (section) {
case 'sma':
for (let i = 0; i < SMA_MA_COUNT; i++) {
sel.smaPlots[smaPlotId(i)] = on;
}
break;
case 'bollinger':
sel.bollinger.basis = on;
sel.bollinger.upper = on;
sel.bollinger.lower = on;
sel.bollinger.background = on;
break;
case 'ichimoku':
for (const row of ICHIMOKU_CUSTOM_ROWS) {
sel.ichimoku[row.key] = on;
}
break;
case 'candle':
sel.candle = on;
break;
default:
break;
}
}
export type ChartCustomOverlaySlotState = {
active: boolean;
selection: ChartCustomOverlaySelection;
};
/** UI·Manager 공통: 한 번에 하나의 슬롯만 적용 (다른 슬롯 적용 시 기존 슬롯 자동 해제) */
export function isCustomOverlaySlotActive(
activeSlot: ChartCustomOverlaySlotId | null,
slot: ChartCustomOverlaySlotId,
): boolean {
return activeSlot === slot;
}
export function resolveActiveCustomOverlaySlot(
slot: ChartCustomOverlaySlotId,
turnOn: boolean,
current: ChartCustomOverlaySlotId | null,
): ChartCustomOverlaySlotId | null {
if (turnOn) return slot;
return current === slot ? null : current;
}
type CustomOverlayManagerApi = {
setChartCustomOverlaySelection(
selection: ChartCustomOverlaySelection,
slot: ChartCustomOverlaySlotId,
): void;
setChartCustomOverlayActive(active: boolean, slot: ChartCustomOverlaySlotId): void;
};
/** React → ChartManager: 두 슬롯 선택값 동기화 후 적용 중인 슬롯만 활성화 */
export function syncChartManagerCustomOverlays(
mgr: CustomOverlayManagerApi,
custom: ChartCustomOverlaySlotState,
custom1: ChartCustomOverlaySlotState,
): void {
mgr.setChartCustomOverlaySelection(custom.selection, 'custom');
mgr.setChartCustomOverlaySelection(custom1.selection, 'custom1');
mgr.setChartCustomOverlayActive(false, 'custom');
mgr.setChartCustomOverlayActive(false, 'custom1');
if (custom1.active) {
mgr.setChartCustomOverlayActive(true, 'custom1');
} else if (custom.active) {
mgr.setChartCustomOverlayActive(true, 'custom');
}
}
/** 적용 슬롯 하나만 — Custom 적용 중 Custom1 적용 시 Custom 자동 미적용 */
export function syncChartManagerCustomOverlaysByActiveSlot(
mgr: CustomOverlayManagerApi,
activeSlot: ChartCustomOverlaySlotId | null,
customSelection: ChartCustomOverlaySelection,
custom1Selection: ChartCustomOverlaySelection,
): void {
syncChartManagerCustomOverlays(
mgr,
{ active: activeSlot === 'custom', selection: customSelection },
{ active: activeSlot === 'custom1', selection: custom1Selection },
);
}