저장팝업 텍스트 색상 수정
This commit is contained in:
+198
-3
@@ -1105,6 +1105,7 @@ html.theme-blue {
|
|||||||
|
|
||||||
/* 패널 본체 */
|
/* 패널 본체 */
|
||||||
.ind-panel {
|
.ind-panel {
|
||||||
|
position: relative;
|
||||||
width: min(520px, 95vw);
|
width: min(520px, 95vw);
|
||||||
max-height: calc(100vh - 48px);
|
max-height: calc(100vh - 48px);
|
||||||
background: var(--bg2);
|
background: var(--bg2);
|
||||||
@@ -1173,19 +1174,60 @@ html.theme-blue {
|
|||||||
.ind-panel-close:hover { background: var(--bg4); color: var(--text); }
|
.ind-panel-close:hover { background: var(--bg4); color: var(--text); }
|
||||||
|
|
||||||
/* 카테고리 탭 */
|
/* 카테고리 탭 */
|
||||||
|
.ind-panel-cats-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
.ind-panel-cats {
|
.ind-panel-cats {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
padding: 7px 10px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
flex-shrink: 0;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--bg4) transparent;
|
scrollbar-color: var(--bg4) transparent;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
|
.ind-panel-cats-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-left: 4px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ind-panel-cat-action {
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.1s;
|
||||||
|
}
|
||||||
|
.ind-panel-cat-action:hover:not(:disabled) {
|
||||||
|
background: var(--bg4);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ind-panel-cat-action:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.ind-panel-cat-action.danger:hover:not(:disabled) {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
}
|
||||||
.ind-panel-cats::-webkit-scrollbar {
|
.ind-panel-cats::-webkit-scrollbar {
|
||||||
height: 5px;
|
height: 5px;
|
||||||
}
|
}
|
||||||
@@ -1210,6 +1252,159 @@ html.theme-blue {
|
|||||||
.ind-panel-cat.active { background: var(--accent); color: #fff; }
|
.ind-panel-cat.active { background: var(--accent); color: #fff; }
|
||||||
.ind-panel-cat-cnt { font-size: 10px; opacity: 0.75; }
|
.ind-panel-cat-cnt { font-size: 10px; opacity: 0.75; }
|
||||||
|
|
||||||
|
/* 사용자 정의 탭 편집 */
|
||||||
|
.ind-tab-editor-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
.ind-tab-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: min(420px, calc(100% - 24px));
|
||||||
|
max-height: calc(100% - 24px);
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text2);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-link:hover { text-decoration: underline; }
|
||||||
|
.ind-tab-editor-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: var(--bg3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-list {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg1);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-item:last-child { border-bottom: none; }
|
||||||
|
.ind-tab-editor-item:hover { background: var(--bg3); }
|
||||||
|
.ind-tab-editor-item.checked { background: #2962ff12; }
|
||||||
|
.ind-tab-editor-item input {
|
||||||
|
margin-top: 3px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-item-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-item-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-item-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ind-tab-editor-btn {
|
||||||
|
padding: 7px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-btn.cancel {
|
||||||
|
background: var(--bg3);
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-btn.cancel:hover { color: var(--text); }
|
||||||
|
.ind-tab-editor-btn.save {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.ind-tab-editor-btn.save:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
/* 목록 */
|
/* 목록 */
|
||||||
.ind-panel-list {
|
.ind-panel-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry';
|
||||||
|
import { MAIN_INDICATOR_LABELS } from '../utils/indicatorMainTab';
|
||||||
|
|
||||||
|
interface IndicatorCustomTabEditorProps {
|
||||||
|
mode: 'create' | 'edit';
|
||||||
|
initialName: string;
|
||||||
|
initialTypes: string[];
|
||||||
|
onSave: (name: string, indicatorTypes: string[]) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function indicatorLabel(def: IndicatorDef): { ko: string; en: string } {
|
||||||
|
const main = MAIN_INDICATOR_LABELS[def.type];
|
||||||
|
return main ?? { ko: def.description, en: def.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
const IndicatorCustomTabEditor: React.FC<IndicatorCustomTabEditorProps> = ({
|
||||||
|
mode,
|
||||||
|
initialName,
|
||||||
|
initialTypes,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const [name, setName] = useState(initialName);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(() => new Set(initialTypes));
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.toLowerCase().trim();
|
||||||
|
if (!q) return INDICATOR_REGISTRY;
|
||||||
|
return INDICATOR_REGISTRY.filter(d => {
|
||||||
|
const lbl = indicatorLabel(d);
|
||||||
|
return (
|
||||||
|
lbl.ko.toLowerCase().includes(q)
|
||||||
|
|| lbl.en.toLowerCase().includes(q)
|
||||||
|
|| d.shortName.toLowerCase().includes(q)
|
||||||
|
|| d.type.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const toggle = (type: string) => {
|
||||||
|
setSelected(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(type)) next.delete(type);
|
||||||
|
else next.add(type);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
window.alert('탭 이름을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected.size === 0) {
|
||||||
|
window.alert('탭에 표시할 지표를 하나 이상 선택해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ordered = INDICATOR_REGISTRY
|
||||||
|
.filter(d => selected.has(d.type))
|
||||||
|
.map(d => d.type);
|
||||||
|
onSave(trimmed, ordered);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="ind-tab-editor-overlay"
|
||||||
|
onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||||
|
>
|
||||||
|
<div className="ind-tab-editor" onMouseDown={e => e.stopPropagation()}>
|
||||||
|
<div className="ind-tab-editor-header">
|
||||||
|
<span className="ind-tab-editor-title">
|
||||||
|
{mode === 'create' ? '탭 추가' : '탭 수정'}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="gc-popup-close" onClick={onClose} aria-label="닫기">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ind-tab-editor-body">
|
||||||
|
<label className="ind-tab-editor-field">
|
||||||
|
<span className="ind-tab-editor-label">탭 이름</span>
|
||||||
|
<input
|
||||||
|
className="ind-tab-editor-input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="예: 내 지표"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="ind-tab-editor-field">
|
||||||
|
<div className="ind-tab-editor-label-row">
|
||||||
|
<span className="ind-tab-editor-label">
|
||||||
|
지표 선택 ({selected.size}개)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-tab-editor-link"
|
||||||
|
onClick={() => setSelected(new Set(INDICATOR_REGISTRY.map(d => d.type)))}
|
||||||
|
>
|
||||||
|
전체 선택
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-tab-editor-link"
|
||||||
|
onClick={() => setSelected(new Set())}
|
||||||
|
>
|
||||||
|
전체 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="ind-tab-editor-input"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="지표 검색..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ind-tab-editor-list">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="ind-panel-empty">검색 결과 없음</div>
|
||||||
|
) : (
|
||||||
|
filtered.map(def => {
|
||||||
|
const lbl = indicatorLabel(def);
|
||||||
|
const checked = selected.has(def.type);
|
||||||
|
return (
|
||||||
|
<label key={def.type} className={`ind-tab-editor-item${checked ? ' checked' : ''}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggle(def.type)}
|
||||||
|
/>
|
||||||
|
<span className="ind-tab-editor-item-info">
|
||||||
|
<span className="ind-tab-editor-item-name">{lbl.ko}</span>
|
||||||
|
<span className="ind-tab-editor-item-desc">{lbl.en}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ind-tab-editor-footer">
|
||||||
|
<button type="button" className="ind-tab-editor-btn cancel" onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ind-tab-editor-btn save" onClick={handleSave}>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default IndicatorCustomTabEditor;
|
||||||
@@ -145,6 +145,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
const [stratDesc, setStratDesc] = useState('');
|
const [stratDesc, setStratDesc] = useState('');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [saveOpen, setSaveOpen] = useState(false);
|
const [saveOpen, setSaveOpen] = useState(false);
|
||||||
|
const [saveNameError, setSaveNameError] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [descOpen, setDescOpen] = useState(false);
|
const [descOpen, setDescOpen] = useState(false);
|
||||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
@@ -493,7 +494,12 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!stratName.trim()) { showSnack('전략 이름을 입력하세요', false); return; }
|
if (!stratName.trim()) {
|
||||||
|
setSaveNameError(true);
|
||||||
|
showSnack('전략 이름을 입력하세요', false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveNameError(false);
|
||||||
const encodedBuy = encodeConditionForSave(buyEditorState);
|
const encodedBuy = encodeConditionForSave(buyEditorState);
|
||||||
const encodedSell = encodeConditionForSave(sellEditorState);
|
const encodedSell = encodeConditionForSave(sellEditorState);
|
||||||
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
|
||||||
@@ -830,7 +836,7 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
>
|
>
|
||||||
⬆
|
⬆
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="se-btn se-btn--gold" onClick={() => setSaveOpen(true)}>저장하기</button>
|
<button type="button" className="se-btn se-btn--gold" onClick={() => { setSaveNameError(false); setSaveOpen(true); }}>저장하기</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -1166,10 +1172,18 @@ export default function StrategyEditorPage({ theme }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{saveOpen && (
|
{saveOpen && (
|
||||||
<DraggableModalFrame onClose={() => setSaveOpen(false)} title="전략 저장">
|
<DraggableModalFrame onClose={() => { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
|
||||||
<div className="se-modal-body">
|
<div className="se-modal-body">
|
||||||
<label className="se-field-lbl">전략명 *</label>
|
<label className="se-field-lbl">전략명 *</label>
|
||||||
<input className="se-field-inp" value={stratName} onChange={e => setStratName(e.target.value)} placeholder="예: Golden_RSI_V1" />
|
<input
|
||||||
|
className={`se-field-inp${saveNameError ? ' se-field-inp--error' : ''}`}
|
||||||
|
value={stratName}
|
||||||
|
onChange={e => { setStratName(e.target.value); setSaveNameError(false); }}
|
||||||
|
placeholder="예: Golden_RSI_V1"
|
||||||
|
/>
|
||||||
|
{saveNameError && (
|
||||||
|
<p className="se-field-error" role="alert">전략 이름을 입력하세요</p>
|
||||||
|
)}
|
||||||
<label className="se-field-lbl">설명</label>
|
<label className="se-field-lbl">설명</label>
|
||||||
<textarea className="se-field-ta" value={stratDesc} onChange={e => setStratDesc(e.target.value)} rows={2} />
|
<textarea className="se-field-ta" value={stratDesc} onChange={e => setStratDesc(e.target.value)} rows={2} />
|
||||||
<div className="se-modal-actions">
|
<div className="se-modal-actions">
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { INDICATOR_REGISTRY, type IndicatorDef, type IndicatorCategory } from '../utils/indicatorRegistry';
|
import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry';
|
||||||
import {
|
import {
|
||||||
MAIN_INDICATOR_TYPES,
|
MAIN_INDICATOR_TYPES,
|
||||||
MAIN_INDICATOR_LABELS,
|
MAIN_INDICATOR_LABELS,
|
||||||
|
customTabKey,
|
||||||
|
isCustomTabKey,
|
||||||
|
parseCustomTabKey,
|
||||||
type IndicatorTabKey,
|
type IndicatorTabKey,
|
||||||
} from '../utils/indicatorMainTab';
|
} from '../utils/indicatorMainTab';
|
||||||
|
import {
|
||||||
|
loadCustomTabs,
|
||||||
|
createCustomTab,
|
||||||
|
updateCustomTab,
|
||||||
|
deleteCustomTab,
|
||||||
|
type IndicatorCustomTab,
|
||||||
|
} from '../utils/indicatorCustomTabsStorage';
|
||||||
|
import IndicatorCustomTabEditor from './IndicatorCustomTabEditor';
|
||||||
import { getIndicatorListLabels } from '../utils/indicatorSettingsList';
|
import { getIndicatorListLabels } from '../utils/indicatorSettingsList';
|
||||||
// IndicatorCategory는 IndDropdown 상태 타입에 사용됨
|
// IndicatorCategory는 IndDropdown 상태 타입에 사용됨
|
||||||
import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types';
|
import type { ChartType, Theme, Timeframe, ChartMode, IndicatorConfig } from '../types';
|
||||||
@@ -263,18 +274,10 @@ const IcReplay = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ─── 카테고리 목록 ─────────────────────────────────────────────────────────
|
// ─── 기본 탭 (사용자 정의 탭은 localStorage) ───────────────────────────────
|
||||||
const CATEGORIES: Array<{ key: IndicatorTabKey; label: string }> = [
|
const BUILTIN_TABS: Array<{ key: IndicatorTabKey; label: string }> = [
|
||||||
{ key: 'All', label: '전체' },
|
{ key: 'All', label: '전체' },
|
||||||
{ key: 'Main', label: 'Main' },
|
{ key: 'Main', label: '주요지표' },
|
||||||
{ key: 'Moving Averages', label: 'MA' },
|
|
||||||
{ key: 'Channels & Bands', label: '밴드' },
|
|
||||||
{ key: 'Oscillators', label: '오실레이터' },
|
|
||||||
{ key: 'Momentum', label: '모멘텀' },
|
|
||||||
{ key: 'Trend', label: '추세' },
|
|
||||||
{ key: 'Volatility', label: '변동성' },
|
|
||||||
{ key: 'Volume', label: '거래량' },
|
|
||||||
{ key: 'Candlestick Patterns',label: '패턴' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ─── 차트 타입 아이콘 매핑 ─────────────────────────────────────────────────
|
// ─── 차트 타입 아이콘 매핑 ─────────────────────────────────────────────────
|
||||||
@@ -309,8 +312,52 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
const [category, setCategory] = React.useState<IndicatorTabKey>('All');
|
const [category, setCategory] = React.useState<IndicatorTabKey>('All');
|
||||||
|
const [customTabs, setCustomTabs] = React.useState<IndicatorCustomTab[]>(() => loadCustomTabs());
|
||||||
|
const [editorOpen, setEditorOpen] = React.useState(false);
|
||||||
|
const [editorMode, setEditorMode] = React.useState<'create' | 'edit'>('create');
|
||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const refreshCustomTabs = useCallback(() => {
|
||||||
|
setCustomTabs(loadCustomTabs());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedCustomTab = React.useMemo(() => {
|
||||||
|
const id = parseCustomTabKey(category);
|
||||||
|
return id ? customTabs.find(t => t.id === id) ?? null : null;
|
||||||
|
}, [category, customTabs]);
|
||||||
|
|
||||||
|
const openCreateEditor = () => {
|
||||||
|
setEditorMode('create');
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditEditor = () => {
|
||||||
|
if (!selectedCustomTab) return;
|
||||||
|
setEditorMode('edit');
|
||||||
|
setEditorOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTab = () => {
|
||||||
|
if (!selectedCustomTab) return;
|
||||||
|
if (!window.confirm(`"${selectedCustomTab.name}" 탭을 삭제할까요?`)) return;
|
||||||
|
deleteCustomTab(selectedCustomTab.id);
|
||||||
|
refreshCustomTabs();
|
||||||
|
setCategory('All');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditorSave = (name: string, indicatorTypes: string[]) => {
|
||||||
|
if (editorMode === 'create') {
|
||||||
|
const tab = createCustomTab(name, indicatorTypes);
|
||||||
|
refreshCustomTabs();
|
||||||
|
setCategory(customTabKey(tab.id));
|
||||||
|
} else if (selectedCustomTab) {
|
||||||
|
updateCustomTab(selectedCustomTab.id, { name, indicatorTypes });
|
||||||
|
refreshCustomTabs();
|
||||||
|
}
|
||||||
|
setEditorOpen(false);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
searchRef.current?.focus();
|
searchRef.current?.focus();
|
||||||
// ESC 키로 닫기
|
// ESC 키로 닫기
|
||||||
@@ -342,11 +389,18 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
.filter(matchesQuery);
|
.filter(matchesQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
return INDICATOR_REGISTRY.filter(d => {
|
if (isCustomTabKey(category)) {
|
||||||
const matchCat = category === 'All' || d.category === category;
|
const tabId = parseCustomTabKey(category);
|
||||||
return matchCat && matchesQuery(d);
|
const tab = tabId ? customTabs.find(t => t.id === tabId) : null;
|
||||||
});
|
if (!tab) return [];
|
||||||
}, [search, category]);
|
return tab.indicatorTypes
|
||||||
|
.map(type => INDICATOR_REGISTRY.find(d => d.type === type))
|
||||||
|
.filter((d): d is IndicatorDef => d != null)
|
||||||
|
.filter(matchesQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
return INDICATOR_REGISTRY.filter(matchesQuery);
|
||||||
|
}, [search, category, customTabs]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
panelRef,
|
panelRef,
|
||||||
@@ -396,24 +450,57 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 카테고리 탭 */}
|
{/* 카테고리 탭 */}
|
||||||
<div className="ind-panel-cats">
|
<div className="ind-panel-cats-row">
|
||||||
{CATEGORIES.map(c => {
|
<div className="ind-panel-cats">
|
||||||
const cnt = c.key === 'All'
|
{BUILTIN_TABS.map(c => {
|
||||||
? INDICATOR_REGISTRY.length
|
const cnt = c.key === 'All'
|
||||||
: c.key === 'Main'
|
? INDICATOR_REGISTRY.length
|
||||||
? MAIN_INDICATOR_TYPES.length
|
: MAIN_INDICATOR_TYPES.length;
|
||||||
: INDICATOR_REGISTRY.filter(d => d.category === c.key).length;
|
return (
|
||||||
return (
|
<button
|
||||||
|
key={c.key}
|
||||||
|
type="button"
|
||||||
|
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
||||||
|
onClick={() => { setCategory(c.key); setSearch(''); }}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
<span className="ind-panel-cat-cnt"> {cnt}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{customTabs.map(tab => (
|
||||||
<button
|
<button
|
||||||
key={c.key}
|
key={tab.id}
|
||||||
className={`ind-panel-cat${category === c.key ? ' active' : ''}`}
|
type="button"
|
||||||
onClick={() => { setCategory(c.key); setSearch(''); }}
|
className={`ind-panel-cat${category === customTabKey(tab.id) ? ' active' : ''}`}
|
||||||
|
onClick={() => { setCategory(customTabKey(tab.id)); setSearch(''); }}
|
||||||
>
|
>
|
||||||
{c.label}
|
{tab.name}
|
||||||
<span className="ind-panel-cat-cnt"> {cnt}</span>
|
<span className="ind-panel-cat-cnt"> {tab.indicatorTypes.length}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
</div>
|
||||||
|
<div className="ind-panel-cats-actions">
|
||||||
|
<button type="button" className="ind-panel-cat-action" onClick={openCreateEditor}>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-cat-action"
|
||||||
|
disabled={!selectedCustomTab}
|
||||||
|
onClick={openEditEditor}
|
||||||
|
>
|
||||||
|
수정
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ind-panel-cat-action danger"
|
||||||
|
disabled={!selectedCustomTab}
|
||||||
|
onClick={handleDeleteTab}
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 지표 목록 */}
|
{/* 지표 목록 */}
|
||||||
@@ -456,11 +543,19 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="ind-panel-empty">"{search}" 검색 결과 없음</div>
|
<div className="ind-panel-empty">
|
||||||
|
{search
|
||||||
|
? `"${search}" 검색 결과 없음`
|
||||||
|
: isCustomTabKey(category)
|
||||||
|
? '이 탭에 표시할 지표가 없습니다. 수정 버튼으로 지표를 추가해 주세요.'
|
||||||
|
: '표시할 지표가 없습니다'}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map(def => {
|
filtered.map(def => {
|
||||||
const active = isActive(def.type);
|
const active = isActive(def.type);
|
||||||
const mainLbl = category === 'Main' ? MAIN_INDICATOR_LABELS[def.type] : undefined;
|
const mainLbl = (category === 'Main' || isCustomTabKey(category))
|
||||||
|
? MAIN_INDICATOR_LABELS[def.type]
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<div key={def.type} className={`ind-panel-item${active ? ' active' : ''}`}>
|
<div key={def.type} className={`ind-panel-item${active ? ' active' : ''}`}>
|
||||||
<div className="ind-panel-item-info">
|
<div className="ind-panel-item-info">
|
||||||
@@ -503,6 +598,16 @@ const IndDropdown: React.FC<IndDropdownProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{editorOpen && (
|
||||||
|
<IndicatorCustomTabEditor
|
||||||
|
mode={editorMode}
|
||||||
|
initialName={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.name : ''}
|
||||||
|
initialTypes={editorMode === 'edit' && selectedCustomTab ? selectedCustomTab.indicatorTypes : []}
|
||||||
|
onSave={handleEditorSave}
|
||||||
|
onClose={() => setEditorOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type StrategyFlowNodeData,
|
type StrategyFlowNodeData,
|
||||||
} from '../../utils/strategyFlowLayout';
|
} from '../../utils/strategyFlowLayout';
|
||||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
|
|
||||||
const LOGIC_COLORS: Record<string, string> = {
|
const LOGIC_COLORS: Record<string, string> = {
|
||||||
@@ -220,6 +221,7 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
|
|||||||
const node = d.logicNode!;
|
const node = d.logicNode!;
|
||||||
const cond = node.condition;
|
const cond = node.condition;
|
||||||
const ind = cond?.indicatorType ?? 'COND';
|
const ind = cond?.indicatorType ?? 'COND';
|
||||||
|
const indLabel = getStrategyIndicatorDisplayName(ind);
|
||||||
const condType = cond?.conditionType ?? '';
|
const condType = cond?.conditionType ?? '';
|
||||||
const isCross = ['CROSS_UP', 'CROSS_DOWN'].includes(condType);
|
const isCross = ['CROSS_UP', 'CROSS_DOWN'].includes(condType);
|
||||||
const tone = isCross ? 'cross' : 'value';
|
const tone = isCross ? 'cross' : 'value';
|
||||||
@@ -245,7 +247,7 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
|
|||||||
canTargetConnect={d.canTargetConnect !== false}
|
canTargetConnect={d.canTargetConnect !== false}
|
||||||
/>
|
/>
|
||||||
<div className="se-flow-cond-top">
|
<div className="se-flow-cond-top">
|
||||||
<div className="se-flow-cond-badge">{ind}</div>
|
<div className="se-flow-cond-badge">{indLabel}</div>
|
||||||
<div className="se-flow-cond-actions">
|
<div className="se-flow-cond-actions">
|
||||||
{showSettings && (
|
{showSettings && (
|
||||||
<button
|
<button
|
||||||
@@ -267,7 +269,7 @@ export const ConditionFlowNode = memo(function ConditionFlowNode({ id, data, sel
|
|||||||
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
<button type="button" className="se-flow-del" title="삭제" onClick={() => d.onDelete?.(id)}>×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="se-flow-cond-text">{d.label ?? ind}</div>
|
<div className="se-flow-cond-text">{d.label ?? indLabel}</div>
|
||||||
{settingsOpen && cond && d.def && (
|
{settingsOpen && cond && d.def && (
|
||||||
<ConditionNodeSettings
|
<ConditionNodeSettings
|
||||||
condition={cond}
|
condition={cond}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { LogicNode } from '../../utils/strategyTypes';
|
import type { LogicNode } from '../../utils/strategyTypes';
|
||||||
import { nodeToText, type DefType } from '../../utils/strategyEditorShared';
|
import { nodeToText, type DefType } from '../../utils/strategyEditorShared';
|
||||||
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
import { getIndicatorPaletteCategory } from './paletteCategories';
|
import { getIndicatorPaletteCategory } from './paletteCategories';
|
||||||
import {
|
import {
|
||||||
collectEditorBranches,
|
collectEditorBranches,
|
||||||
@@ -21,16 +22,18 @@ interface Props {
|
|||||||
function renderCondition(node: LogicNode, def: DefType): React.ReactNode {
|
function renderCondition(node: LogicNode, def: DefType): React.ReactNode {
|
||||||
const full = nodeToText(node, def);
|
const full = nodeToText(node, def);
|
||||||
const ind = node.condition?.indicatorType ?? '';
|
const ind = node.condition?.indicatorType ?? '';
|
||||||
const tail = ind && full.startsWith(`${ind} -`) ? full.slice(ind.length) : full;
|
const indLabel = getStrategyIndicatorDisplayName(ind);
|
||||||
|
const prefix = `${indLabel} -`;
|
||||||
|
const tail = indLabel && full.startsWith(prefix) ? full.slice(prefix.length) : full;
|
||||||
const cat = getIndicatorPaletteCategory(ind);
|
const cat = getIndicatorPaletteCategory(ind);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="se-formula-punc">(</span>
|
<span className="se-formula-punc">(</span>
|
||||||
{ind ? (
|
{indLabel ? (
|
||||||
<>
|
<>
|
||||||
<span className={`se-formula-ind se-formula-ind--${cat}`}>{ind}</span>
|
<span className={`se-formula-ind se-formula-ind--${cat}`}>{indLabel}</span>
|
||||||
{tail ? <span className="se-formula-detail">{tail}</span> : null}
|
{tail ? <span className="se-formula-detail">{tail.startsWith(' ') ? tail : ` ${tail}`}</span> : null}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="se-formula-detail">{full || '?'}</span>
|
<span className="se-formula-detail">{full || '?'}</span>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type DefType,
|
type DefType,
|
||||||
} from '../../utils/strategyEditorShared';
|
} from '../../utils/strategyEditorShared';
|
||||||
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
import { hasNodeSettings } from '../../utils/conditionPeriods';
|
||||||
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
||||||
import ConditionNodeSettings from './ConditionNodeSettings';
|
import ConditionNodeSettings from './ConditionNodeSettings';
|
||||||
import {
|
import {
|
||||||
type EditorConditionState,
|
type EditorConditionState,
|
||||||
@@ -109,7 +110,9 @@ const TreeNodeComp: React.FC<TreeNodeProps> = ({
|
|||||||
>
|
>
|
||||||
<div className="sp-node-head sp-node-head--cond" style={{ borderLeftColor: color }}>
|
<div className="sp-node-head sp-node-head--cond" style={{ borderLeftColor: color }}>
|
||||||
<span className="sp-node-badge" style={{ background: color }}>
|
<span className="sp-node-badge" style={{ background: color }}>
|
||||||
{node.type === 'CONDITION' && node.condition ? node.condition.indicatorType : node.type}
|
{node.type === 'CONDITION' && node.condition
|
||||||
|
? getStrategyIndicatorDisplayName(node.condition.indicatorType)
|
||||||
|
: node.type}
|
||||||
</span>
|
</span>
|
||||||
<span className="sp-node-label">{label}</span>
|
<span className="sp-node-label">{label}</span>
|
||||||
<div className="sp-node-actions">
|
<div className="sp-node-actions">
|
||||||
|
|||||||
@@ -1671,19 +1671,32 @@
|
|||||||
background: var(--se-input-bg);
|
background: var(--se-input-bg);
|
||||||
color: var(--se-text); padding: 7px 10px; font-size: 0.8rem;
|
color: var(--se-text); padding: 7px 10px; font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
.se-field-inp--error {
|
||||||
|
border-color: var(--se-danger);
|
||||||
|
box-shadow: 0 0 0 1px color-mix(in srgb, var(--se-danger) 35%, transparent);
|
||||||
|
}
|
||||||
|
.se-field-error {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--se-danger);
|
||||||
|
}
|
||||||
.se-modal-body { padding: 4px 0; }
|
.se-modal-body { padding: 4px 0; }
|
||||||
.se-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
.se-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
|
||||||
|
|
||||||
.se-snack {
|
.se-snack {
|
||||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||||
padding: 10px 18px; border-radius: 10px; font-size: 0.82rem; z-index: 9999;
|
padding: 10px 20px; border-radius: 10px; font-size: 0.82rem; font-weight: 600;
|
||||||
background: color-mix(in srgb, var(--se-success) 92%, #000);
|
z-index: 10001;
|
||||||
color: var(--se-success);
|
color: #fff;
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
background: #2e7d32;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.45);
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.se-snack--err {
|
.se-snack--err {
|
||||||
background: color-mix(in srgb, var(--se-danger) 92%, #000);
|
background: #c62828;
|
||||||
color: var(--se-danger);
|
border-color: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 전략 설명 팝업 ── */
|
/* ── 전략 설명 팝업 ── */
|
||||||
|
|||||||
@@ -1948,6 +1948,10 @@ export class ChartManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry.config = config;
|
entry.config = config;
|
||||||
|
|
||||||
|
if (this._candleOnlyLayout && this._isSubPaneIndicator(entry)) {
|
||||||
|
this._hideSubPaneIndicator(entry);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 메인 차트(캔들) 색상 스타일 즉시 업데이트 */
|
/** 메인 차트(캔들) 색상 스타일 즉시 업데이트 */
|
||||||
@@ -2146,6 +2150,7 @@ export class ChartManager {
|
|||||||
} catch { /* ok */ }
|
} catch { /* ok */ }
|
||||||
this.container.style.height = '';
|
this.container.style.height = '';
|
||||||
this._resetPaneHeightsCandleFullscreen(availableHeight);
|
this._resetPaneHeightsCandleFullscreen(availableHeight);
|
||||||
|
this._hideAllSubPaneIndicators();
|
||||||
} else {
|
} else {
|
||||||
this.restoreFromCandleFullscreen(availableHeight);
|
this.restoreFromCandleFullscreen(availableHeight);
|
||||||
}
|
}
|
||||||
@@ -2166,6 +2171,8 @@ export class ChartManager {
|
|||||||
|
|
||||||
this._resetMainPricePan();
|
this._resetMainPricePan();
|
||||||
|
|
||||||
|
this._restoreSubPaneIndicatorVisibility();
|
||||||
|
|
||||||
const H = (availableHeight && availableHeight > 0)
|
const H = (availableHeight && availableHeight > 0)
|
||||||
? availableHeight
|
? availableHeight
|
||||||
: this.container.clientHeight;
|
: this.container.clientHeight;
|
||||||
@@ -2181,6 +2188,62 @@ export class ChartManager {
|
|||||||
this.autoScale();
|
this.autoScale();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** pane 2+ 하단 보조지표 여부 (pane 0 오버레이 제외) */
|
||||||
|
private _isSubPaneIndicator(entry: IndicatorEntry): boolean {
|
||||||
|
return (entry.paneIndex ?? 0) >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 캔들 전체보기 — 하단 보조지표 pane 시리즈·기준선 숨김 */
|
||||||
|
private _hideSubPaneIndicator(entry: IndicatorEntry): void {
|
||||||
|
for (const series of entry.seriesList) {
|
||||||
|
try {
|
||||||
|
series.applyOptions({
|
||||||
|
visible: false,
|
||||||
|
lastValueVisible: false,
|
||||||
|
title: '',
|
||||||
|
} as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
|
||||||
|
} catch { /* ok */ }
|
||||||
|
}
|
||||||
|
const first = entry.seriesList[0];
|
||||||
|
if (first) {
|
||||||
|
for (const pl of entry.hlineRefs) {
|
||||||
|
try { first.removePriceLine(pl); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
entry.hlineRefs = [];
|
||||||
|
if (entry.fillPrimitive) {
|
||||||
|
try { first.detachPrimitive(entry.fillPrimitive); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.cloudPlugin) {
|
||||||
|
for (const s of entry.seriesList) {
|
||||||
|
try { s.detachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
detachBbBandFill(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _hideAllSubPaneIndicators(): void {
|
||||||
|
for (const entry of this.indicators.values()) {
|
||||||
|
if (this._isSubPaneIndicator(entry)) {
|
||||||
|
this._hideSubPaneIndicator(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 캔들 전체보기 해제 — 하단 보조지표 표시·스타일 복원 */
|
||||||
|
private _restoreSubPaneIndicatorVisibility(): void {
|
||||||
|
for (const entry of this.indicators.values()) {
|
||||||
|
if (!this._isSubPaneIndicator(entry)) continue;
|
||||||
|
if (entry.cloudPlugin) {
|
||||||
|
const host = seriesByPlotId(entry, 'plot3') ?? entry.seriesList[0];
|
||||||
|
if (host) {
|
||||||
|
try { host.attachPrimitive(entry.cloudPlugin); } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.applyIndicatorStyle(entry.config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 실제 보조지표가 붙어 있는 pane index 집합 (2+) */
|
/** 실제 보조지표가 붙어 있는 pane index 집합 (2+) */
|
||||||
private _activeIndicatorPaneIndices(): Set<number> {
|
private _activeIndicatorPaneIndices(): Set<number> {
|
||||||
const indices = new Set<number>();
|
const indices = new Set<number>();
|
||||||
@@ -2954,6 +3017,8 @@ export class ChartManager {
|
|||||||
panes[i].setStretchFactor(i === 0 ? 1 : ORPHAN_STRETCH);
|
panes[i].setStretchFactor(i === 0 ? 1 : ORPHAN_STRETCH);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._hideAllSubPaneIndicators();
|
||||||
|
|
||||||
return H;
|
return H;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/** 지표 추가 팝업 — 사용자 정의 탭 (localStorage) */
|
||||||
|
|
||||||
|
export interface IndicatorCustomTab {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** INDICATOR_REGISTRY type 목록 (표시 순서) */
|
||||||
|
indicatorTypes: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'gc-indicator-custom-tabs-v1';
|
||||||
|
|
||||||
|
function newId(): string {
|
||||||
|
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCustomTabs(): IndicatorCustomTab[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const arr = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(arr)) return [];
|
||||||
|
return arr.filter(
|
||||||
|
(t): t is IndicatorCustomTab =>
|
||||||
|
t != null
|
||||||
|
&& typeof t === 'object'
|
||||||
|
&& typeof (t as IndicatorCustomTab).id === 'string'
|
||||||
|
&& typeof (t as IndicatorCustomTab).name === 'string'
|
||||||
|
&& Array.isArray((t as IndicatorCustomTab).indicatorTypes),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveCustomTabs(tabs: IndicatorCustomTab[]): void {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCustomTab(name: string, indicatorTypes: string[]): IndicatorCustomTab {
|
||||||
|
const tab: IndicatorCustomTab = {
|
||||||
|
id: newId(),
|
||||||
|
name: name.trim(),
|
||||||
|
indicatorTypes: [...new Set(indicatorTypes)],
|
||||||
|
};
|
||||||
|
const tabs = loadCustomTabs();
|
||||||
|
tabs.push(tab);
|
||||||
|
saveCustomTabs(tabs);
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCustomTab(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<Pick<IndicatorCustomTab, 'name' | 'indicatorTypes'>>,
|
||||||
|
): IndicatorCustomTab | null {
|
||||||
|
const tabs = loadCustomTabs();
|
||||||
|
const idx = tabs.findIndex(t => t.id === id);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
const next: IndicatorCustomTab = {
|
||||||
|
...tabs[idx],
|
||||||
|
...patch,
|
||||||
|
name: patch.name != null ? patch.name.trim() : tabs[idx].name,
|
||||||
|
indicatorTypes: patch.indicatorTypes != null
|
||||||
|
? [...new Set(patch.indicatorTypes)]
|
||||||
|
: tabs[idx].indicatorTypes,
|
||||||
|
};
|
||||||
|
tabs[idx] = next;
|
||||||
|
saveCustomTabs(tabs);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCustomTab(id: string): void {
|
||||||
|
saveCustomTabs(loadCustomTabs().filter(t => t.id !== id));
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
import type { IndicatorCategory } from './indicatorRegistry';
|
|
||||||
|
|
||||||
/** 지표 선택 팝업 탭 키 */
|
/** 지표 선택 팝업 탭 키 */
|
||||||
export type IndicatorTabKey = IndicatorCategory | 'All' | 'Main';
|
export type IndicatorTabKey = 'All' | 'Main' | `custom:${string}`;
|
||||||
|
|
||||||
|
export function customTabKey(id: string): IndicatorTabKey {
|
||||||
|
return `custom:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCustomTabKey(key: IndicatorTabKey): string | null {
|
||||||
|
return key.startsWith('custom:') ? key.slice(7) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCustomTabKey(key: IndicatorTabKey): key is `custom:${string}` {
|
||||||
|
return key.startsWith('custom:');
|
||||||
|
}
|
||||||
|
|
||||||
/** Main 탭 고정 지표 (표시 순서) */
|
/** Main 탭 고정 지표 (표시 순서) */
|
||||||
export const MAIN_INDICATOR_TYPES: readonly string[] = [
|
export const MAIN_INDICATOR_TYPES: readonly string[] = [
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
parsePriorExtremePeriod,
|
parsePriorExtremePeriod,
|
||||||
syncPriceExtremeSimpleFields,
|
syncPriceExtremeSimpleFields,
|
||||||
} from '../utils/priceExtremeIndicators';
|
} from '../utils/priceExtremeIndicators';
|
||||||
|
import { getStrategyIndicatorDisplayName } from '../utils/strategyPaletteStorage';
|
||||||
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
import ComboFieldSelect from '../components/strategyEditor/ComboFieldSelect';
|
||||||
import {
|
import {
|
||||||
getCompositeFieldOpts,
|
getCompositeFieldOpts,
|
||||||
@@ -635,6 +636,7 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
|||||||
if (!node) return '';
|
if (!node) return '';
|
||||||
if (node.type === 'CONDITION' && node.condition) {
|
if (node.type === 'CONDITION' && node.condition) {
|
||||||
const c = normalizeCompositeCondition(node.condition);
|
const c = normalizeCompositeCondition(node.condition);
|
||||||
|
const indName = getStrategyIndicatorDisplayName(c.indicatorType);
|
||||||
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
|
const opts = getFieldOpts(c.indicatorType, 'buy', DEF, c);
|
||||||
const C = condLabel(c.conditionType);
|
const C = condLabel(c.conditionType);
|
||||||
if (c.composite && c.leftPeriod && c.rightPeriod) {
|
if (c.composite && c.leftPeriod && c.rightPeriod) {
|
||||||
@@ -647,15 +649,15 @@ export const nodeToText = (node: LogicNode, DEF: DefType = DEF_DEFAULTS): string
|
|||||||
const lCt = getCompositeLeftCandleType(c);
|
const lCt = getCompositeLeftCandleType(c);
|
||||||
const rCt = getCompositeRightCandleType(c);
|
const rCt = getCompositeRightCandleType(c);
|
||||||
const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`;
|
const tf = lCt !== rCt ? ` [${lCt}/${rCt}]` : ` [${lCt}]`;
|
||||||
return `${c.indicatorType} - ${L} ${C} ${R}${tf}`;
|
return `${indName} - ${L} ${C} ${R}${tf}`;
|
||||||
}
|
}
|
||||||
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
|
const lv = resolveFieldOptionValue(c.indicatorType, c.leftField);
|
||||||
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
|
const rv = resolveFieldOptionValue(c.indicatorType, c.rightField);
|
||||||
const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? c.indicatorType;
|
const L = opts.find(o => o.value === lv)?.label ?? c.leftField ?? indName;
|
||||||
const R = opts.find(o => o.value === rv)?.label
|
const R = opts.find(o => o.value === rv)?.label
|
||||||
?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? '');
|
?? (rv && parseThresholdField(rv) != null ? `임계(${parseThresholdField(rv)})` : rv ?? '');
|
||||||
if (R && R !== '선택안함') return `${c.indicatorType} - ${L} ${C} ${R}`;
|
if (R && R !== '선택안함') return `${indName} - ${L} ${C} ${R}`;
|
||||||
return `${c.indicatorType} - ${L} ${C}`;
|
return `${indName} - ${L} ${C}`;
|
||||||
}
|
}
|
||||||
if (node.type === 'AND') {
|
if (node.type === 'AND') {
|
||||||
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
const parts = (node.children ?? []).map(c => nodeToText(c, DEF));
|
||||||
|
|||||||
@@ -136,6 +136,22 @@ export function resetPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 전략편집기·전략빌더에 표시할 지표명 (우측 팔레트 label 우선) */
|
||||||
|
const STRATEGY_INDICATOR_TYPE_ALIASES: Record<string, string> = {
|
||||||
|
NEW_PSYCHOLOGICAL: 'PSYCHOLOGICAL',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStrategyIndicatorDisplayName(indicatorType: string): string {
|
||||||
|
const resolved = STRATEGY_INDICATOR_TYPE_ALIASES[indicatorType] ?? indicatorType;
|
||||||
|
|
||||||
|
for (const kind of ['auxiliary', 'composite'] as const) {
|
||||||
|
const item = loadPaletteItems(kind).find(i => i.value === resolved);
|
||||||
|
if (item?.label) return item.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
export function createPaletteItemId(): string {
|
export function createPaletteItemId(): string {
|
||||||
return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
return `custom-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user