225 lines
8.3 KiB
TypeScript
225 lines
8.3 KiB
TypeScript
import React, { useCallback, useMemo, useState } from 'react';
|
|
import PaletteChip from './PaletteChip';
|
|
import PaletteItemModal from './PaletteItemModal';
|
|
import type { DefType } from '../../utils/strategyEditorShared';
|
|
import { getCompositeDefaultPeriods } from '../../utils/compositeIndicators';
|
|
import { isStochOverboughtPairPaletteValue } from '../../utils/stochOverboughtPair';
|
|
import { isPriceExtremeBreakoutPairPaletteValue } from '../../utils/priceExtremeBreakoutPair';
|
|
import { isInflection33PaletteValue } from '../../utils/inflection33Strategy';
|
|
import { isStableStrategyPaletteValue, STABLE_STRATEGY_PALETTE_ITEMS } from '../../utils/stableStrategyPairs';
|
|
import { getDefaultIndicatorPeriod } from '../../utils/conditionPeriods';
|
|
import { getStrategyIndicatorDisplayName } from '../../utils/strategyPaletteStorage';
|
|
import { getIndicatorPeriodLabel } from '../../utils/strategyFlowLayout';
|
|
import {
|
|
createPaletteItemId,
|
|
savePaletteItems,
|
|
type PaletteItem,
|
|
type PaletteItemKind,
|
|
} from '../../utils/strategyPaletteStorage';
|
|
|
|
/** 팔레트 카드 기간 표시 — 항상 보조지표 설정(DEF) 기준 */
|
|
function periodLabel(item: PaletteItem, def: DefType): string {
|
|
if (item.kind === 'composite' && isStochOverboughtPairPaletteValue(item.value)) {
|
|
const sec = item.secondaryIndicator ?? 'CCI';
|
|
return `Stoch + ${getStrategyIndicatorDisplayName(sec)}`;
|
|
}
|
|
if (item.kind === 'composite' && isPriceExtremeBreakoutPairPaletteValue(item.value)) {
|
|
const s = item.shortPeriod ?? 9;
|
|
const l = item.longPeriod ?? 20;
|
|
return `${s} / ${l}일`;
|
|
}
|
|
if (item.kind === 'composite' && isInflection33PaletteValue(item.value)) {
|
|
return `${item.period ?? 33}일`;
|
|
}
|
|
if (item.kind === 'composite' && isStableStrategyPaletteValue(item.value)) {
|
|
const meta = STABLE_STRATEGY_PALETTE_ITEMS.find(i => i.value === item.value);
|
|
return meta?.periodHint ?? '';
|
|
}
|
|
if (item.kind === 'composite') {
|
|
const d = getCompositeDefaultPeriods(item.value, def);
|
|
return `${d.short} / ${d.long}`;
|
|
}
|
|
return getIndicatorPeriodLabel(item.value, def) || String(getDefaultIndicatorPeriod(item.value, def));
|
|
}
|
|
|
|
interface Props {
|
|
kind: PaletteItemKind;
|
|
items: PaletteItem[];
|
|
onItemsChange: (items: PaletteItem[]) => void;
|
|
def: DefType;
|
|
searchQuery: string;
|
|
selectedItemId: string | null;
|
|
onSelectItem: (id: string | null) => void;
|
|
onAddToCanvas: (item: PaletteItem) => void;
|
|
}
|
|
|
|
export default function IndicatorPaletteTab({
|
|
kind, items, onItemsChange, def, searchQuery, selectedItemId, onSelectItem, onAddToCanvas,
|
|
}: Props) {
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
|
|
const [editTarget, setEditTarget] = useState<PaletteItem | null>(null);
|
|
|
|
const q = searchQuery.trim().toLowerCase();
|
|
const filtered = useMemo(() => {
|
|
if (!q) return items;
|
|
return items.filter(
|
|
i => i.label.toLowerCase().includes(q)
|
|
|| i.desc.toLowerCase().includes(q)
|
|
|| i.value.toLowerCase().includes(q),
|
|
);
|
|
}, [items, q]);
|
|
|
|
const selected = items.find(i => i.id === selectedItemId) ?? null;
|
|
|
|
const persist = useCallback((next: PaletteItem[]) => {
|
|
onItemsChange(next);
|
|
savePaletteItems(kind, next);
|
|
}, [kind, onItemsChange]);
|
|
|
|
const openAdd = () => {
|
|
setEditTarget(null);
|
|
setModalMode('add');
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const openEdit = () => {
|
|
if (!selected) {
|
|
window.alert('수정할 지표 카드를 먼저 선택해 주세요.');
|
|
return;
|
|
}
|
|
setEditTarget(selected);
|
|
setModalMode('edit');
|
|
setModalOpen(true);
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (!selected) {
|
|
window.alert('삭제할 지표 카드를 먼저 선택해 주세요.');
|
|
return;
|
|
}
|
|
if (!window.confirm(`「${selected.label}」을(를) 목록에서 삭제할까요?`)) return;
|
|
const next = items.filter(i => i.id !== selected.id);
|
|
persist(next);
|
|
onSelectItem(null);
|
|
};
|
|
|
|
const handleSave = (draft: Omit<PaletteItem, 'id' | 'kind'> & { id?: string }) => {
|
|
if (modalMode === 'add') {
|
|
const item: PaletteItem = {
|
|
id: createPaletteItemId(),
|
|
kind,
|
|
value: draft.value,
|
|
label: draft.label,
|
|
desc: draft.desc,
|
|
period: draft.period,
|
|
shortPeriod: draft.shortPeriod,
|
|
longPeriod: draft.longPeriod,
|
|
};
|
|
persist([...items, item]);
|
|
onSelectItem(item.id);
|
|
return;
|
|
}
|
|
const id = draft.id ?? editTarget?.id;
|
|
if (!id) return;
|
|
persist(items.map(i => (i.id === id ? {
|
|
...i,
|
|
label: draft.label,
|
|
desc: draft.desc,
|
|
period: draft.period,
|
|
shortPeriod: draft.shortPeriod,
|
|
longPeriod: draft.longPeriod,
|
|
} : i)));
|
|
};
|
|
|
|
return (
|
|
<div className={`se-palette-section se-palette-section--${kind === 'auxiliary' ? 'aux' : 'composite'} se-palette-section--scroll`}>
|
|
<div className="se-palette-toolbar">
|
|
<span className="se-palette-toolbar-title">{kind === 'auxiliary' ? '보조지표' : '복합지표'}</span>
|
|
<div className="se-palette-toolbar-actions" role="toolbar" aria-label={`${kind === 'auxiliary' ? '보조' : '복합'}지표 관리`}>
|
|
<button
|
|
type="button"
|
|
className="se-palette-tool-btn"
|
|
title="지표 추가"
|
|
aria-label="지표 추가"
|
|
onClick={openAdd}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
|
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="se-palette-tool-btn"
|
|
title="선택 지표 수정"
|
|
aria-label="선택 지표 수정"
|
|
disabled={!selected}
|
|
onClick={openEdit}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
|
<path d="M4 20h4l10-10-4-4L4 16v4z" stroke="currentColor" strokeWidth="1.75" fill="none" strokeLinejoin="round" />
|
|
<path d="M14 6l4 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="se-palette-tool-btn se-palette-tool-btn--danger"
|
|
title="선택 지표 삭제"
|
|
aria-label="선택 지표 삭제"
|
|
disabled={!selected}
|
|
onClick={handleDelete}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden>
|
|
<path d="M6 7h12M9 7V5h6v2M10 11v6M14 11v6M8 7l1 12h6l1-12" stroke="currentColor" strokeWidth="1.75" fill="none" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="se-palette-grid se-palette-grid--3">
|
|
{filtered.map(item => (
|
|
<PaletteChip
|
|
key={item.id}
|
|
type="indicator"
|
|
value={item.value}
|
|
label={item.label}
|
|
desc={item.desc}
|
|
color={kind === 'composite' ? 'composite' : 'ind'}
|
|
composite={kind === 'composite'
|
|
&& !isStochOverboughtPairPaletteValue(item.value)
|
|
&& !isPriceExtremeBreakoutPairPaletteValue(item.value)
|
|
&& !isInflection33PaletteValue(item.value)
|
|
&& !isStableStrategyPaletteValue(item.value)}
|
|
stochPair={isStochOverboughtPairPaletteValue(item.value)}
|
|
priceExtremeBreakout={isPriceExtremeBreakoutPairPaletteValue(item.value)}
|
|
inflection33={isInflection33PaletteValue(item.value)}
|
|
stableStrategy={isStableStrategyPaletteValue(item.value)}
|
|
secondaryIndicator={item.secondaryIndicator}
|
|
period={periodLabel(item, def)}
|
|
periodValue={item.period}
|
|
shortPeriod={item.shortPeriod}
|
|
longPeriod={item.longPeriod}
|
|
selected={selectedItemId === item.id}
|
|
onSelect={() => onSelectItem(item.id)}
|
|
onAdd={() => onAddToCanvas(item)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{filtered.length === 0 && (
|
|
<p className="se-palette-empty">검색 결과가 없습니다.</p>
|
|
)}
|
|
|
|
<PaletteItemModal
|
|
open={modalOpen}
|
|
mode={modalMode}
|
|
kind={kind}
|
|
initial={editTarget}
|
|
def={def}
|
|
onClose={() => setModalOpen(false)}
|
|
onSave={handleSave}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|