전략편집기 횡보구간 컨트롤 추가

This commit is contained in:
Macbook
2026-06-12 09:08:44 +09:00
parent 74b0ea4ab6
commit d741d3fec6
8 changed files with 1601 additions and 19 deletions
+102 -7
View File
@@ -45,6 +45,8 @@ import {
type StartCombineOp,
} from '../utils/strategyStartNodes';
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
import { buildSidewaysFilterNode } from '../utils/sidewaysFilterPalette';
import {
loadPaletteItems,
type PaletteItem,
@@ -99,7 +101,16 @@ import {
strategyDtoToListExportItem,
} from '../utils/strategyImportExport';
import PaletteChip from './strategyEditor/PaletteChip';
import { readStoredSize, storeSize, usePanelResize } from './strategyEditor/usePanelResize';
import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle';
import {
clampPanelSize,
readStoredBool,
readStoredSize,
storeBool,
storeSize,
usePanelResize,
useRightPanelResize,
} from './strategyEditor/usePanelResize';
import StrategyDescriptionModal from './strategyEditor/StrategyDescriptionModal';
import ReactDOM from 'react-dom';
import { MarketSearchPanel } from './MarketSearchPanel';
@@ -120,10 +131,21 @@ const BACKTEST_FOCUS_KEY = 'backtest_focus_id';
const LEFT_PANEL_MIN = 220;
const LEFT_PANEL_MAX = 520;
const LEFT_PANEL_DEFAULT = 280;
const RIGHT_PANEL_MIN = 380;
const RIGHT_PANEL_MAX = 560;
const RIGHT_PANEL_DEFAULT = 380;
const TERMINAL_MIN = 88;
const TERMINAL_MAX = 420;
const TERMINAL_DEFAULT = 140;
function readRightPanelWidth(): number {
return clampPanelSize(
readStoredSize('se-right-width', RIGHT_PANEL_DEFAULT),
RIGHT_PANEL_MIN,
RIGHT_PANEL_MAX,
);
}
interface Props {
theme: Theme;
onNavigateToBacktest?: () => void;
@@ -189,7 +211,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const [sellCondition, setSellCondition] = useState<LogicNode | null>(null);
const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy');
const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite'>('auxiliary');
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
const [paletteSearch, setPaletteSearch] = useState('');
@@ -224,8 +246,11 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const qbMarketBtnRef = useRef<HTMLButtonElement>(null);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [rightWidth, setRightWidth] = useState(() => readRightPanelWidth());
const [rightOpen, setRightOpen] = useState(() => readStoredBool('se-right-open', true));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const terminalHeightRef = useRef(terminalHeight);
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
@@ -246,6 +271,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const layoutPersistReadyRef = useRef(false);
const STRATEGY_AUTOSAVE_MS = 350;
leftWidthRef.current = leftWidth;
rightWidthRef.current = rightWidth;
terminalHeightRef.current = terminalHeight;
const [layoutSeedKey, setLayoutSeedKey] = useState('draft:buy:0');
@@ -260,6 +286,22 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
v => storeSize('se-left-width', v),
);
const onRightSplitter = useRightPanelResize(
setRightWidth,
() => rightWidthRef.current,
RIGHT_PANEL_MIN,
RIGHT_PANEL_MAX,
v => storeSize('se-right-width', v),
);
const toggleRightPanel = useCallback(() => {
setRightOpen(prev => {
const next = !prev;
storeBool('se-right-open', next);
return next;
});
}, []);
const onTerminalSplitter = usePanelResize(
'horizontal',
setTerminalHeight,
@@ -271,8 +313,9 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const bodyStyle = useMemo(() => ({
'--se-left-width': `${leftWidth}px`,
'--se-right-width': `${rightWidth}px`,
'--se-terminal-height': `${terminalHeight}px`,
}) as React.CSSProperties, [leftWidth, terminalHeight]);
}) as React.CSSProperties, [leftWidth, rightWidth, terminalHeight]);
const showSnack = (msg: string, ok = true) => {
setSnack({ msg, ok });
@@ -1021,6 +1064,15 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
const applySidewaysFilter = useCallback((filterId: string) => {
const newNode = buildSidewaysFilterNode(filterId, DEF);
if (!newNode) return;
const root = currentRoot;
if (!root) setCurrentRoot(newNode);
else setCurrentRoot(mergeAtRoot(root, newNode, false));
scheduleStrategyPersist();
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
const templateRows = useMemo(() => {
@@ -1645,7 +1697,24 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div>
</main>
<aside className="se-right">
{rightOpen && (
<div
className="se-splitter se-splitter--v"
role="separator"
aria-orientation="vertical"
aria-label="우측 패널 너비 조절"
onPointerDown={onRightSplitter}
/>
)}
<div className={`se-side-wrap se-side-wrap--right${rightOpen ? ' se-side-wrap--open' : ''}`}>
<SePanelCollapseHandle
side="right"
open={rightOpen}
onToggle={toggleRightPanel}
label="지표 패널"
/>
<aside className={`se-right se-right--collapsible${rightOpen ? ' se-right--collapsible--open' : ''}`}>
<div className="se-palette-panel">
<div className="se-right-tabs">
<button type="button" className={rightTab === 'indicators' ? 'se-right-tab se-right-tab--on' : 'se-right-tab'} onClick={() => setRightTab('indicators')}></button>
@@ -1702,7 +1771,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
<div className="se-palette-subtabs">
<button
type="button"
className={`se-palette-subtab${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
className={`se-palette-subtab se-palette-subtab--aux${indicatorSubTab === 'auxiliary' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('auxiliary');
setSelectedPaletteKey(null);
@@ -1712,7 +1781,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</button>
<button
type="button"
className={`se-palette-subtab${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
className={`se-palette-subtab se-palette-subtab--composite${indicatorSubTab === 'composite' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('composite');
setSelectedPaletteKey(null);
@@ -1720,6 +1789,16 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
>
</button>
<button
type="button"
className={`se-palette-subtab se-palette-subtab--range${indicatorSubTab === 'range' ? ' se-palette-subtab--on' : ''}`}
onClick={() => {
setIndicatorSubTab('range');
setSelectedPaletteKey(null);
}}
>
</button>
</div>
{indicatorSubTab === 'auxiliary' ? (
<IndicatorPaletteTab
@@ -1739,7 +1818,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
applyPaletteItem(item);
}}
/>
) : (
) : indicatorSubTab === 'composite' ? (
<IndicatorPaletteTab
kind="composite"
items={compositePalette}
@@ -1757,6 +1836,21 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
applyPaletteItem(item);
}}
/>
) : (
<SidewaysFilterPaletteTab
def={DEF}
searchQuery={paletteSearch}
selectedItemId={
selectedPaletteKey?.startsWith('range:')
? selectedPaletteKey.slice('range:'.length)
: null
}
onSelectItem={id => setSelectedPaletteKey(id ? paletteKey('range', id) : null)}
onAddToCanvas={item => {
selectPalette('range', item.id);
applySidewaysFilter(item.id);
}}
/>
)}
</>
)}
@@ -1821,6 +1915,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
</div>
</div>
</aside>
</div>
</div>
</div>
</div>
@@ -0,0 +1,43 @@
import React from 'react';
interface Props {
side: 'left' | 'right';
open: boolean;
onToggle: () => void;
label: string;
}
/** 전략편집기 좌·우 패널 접기 핸들 */
export default function SePanelCollapseHandle({ side, open, onToggle, label }: Props) {
return (
<button
type="button"
className={`se-panel-handle se-panel-handle--${side}${open ? ' se-panel-handle--open' : ''}`}
onClick={e => {
e.stopPropagation();
onToggle();
}}
title={open ? `${label} 닫기` : `${label} 열기`}
aria-expanded={open}
aria-label={open ? `${label} 닫기` : `${label} 열기`}
>
<svg
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{side === 'left' ? (
open ? <polyline points="6,1 2,7 6,13" /> : <polyline points="2,1 6,7 2,13" />
) : (
open ? <polyline points="2,1 6,7 2,13" /> : <polyline points="6,1 2,7 6,13" />
)}
</svg>
</button>
);
}
@@ -0,0 +1,209 @@
import React, { useMemo, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { DefType } from '../../utils/strategyEditorShared';
import {
DEFAULT_SIDEWAYS_FILTER_ITEMS,
filterSidewaysItems,
type SidewaysFilterItem,
type SidewaysFilterMode,
} from '../../utils/sidewaysFilterPalette';
export interface SidewaysFilterDragPayload {
type: 'sidewaysFilter';
value: string;
label: string;
}
interface Props {
def: DefType;
searchQuery: string;
selectedItemId: string | null;
onSelectItem: (id: string | null) => void;
onAddToCanvas: (item: SidewaysFilterItem) => void;
}
function modeIcon(mode: SidewaysFilterMode): { glyph: string; cls: string; aria: string } {
return mode === 'include'
? { glyph: '▣', cls: 'se-range-chip-icon--include', aria: '횡보' }
: { glyph: '⊘', cls: 'se-range-chip-icon--exclude', aria: '제외' };
}
interface HoverHintState {
label: string;
desc: string;
x: number;
y: number;
}
function SidewaysFilterChip({
item,
selected,
onSelect,
onAdd,
onHintChange,
}: {
item: SidewaysFilterItem;
selected: boolean;
onSelect: () => void;
onAdd: () => void;
onHintChange: (hint: HoverHintState | null) => void;
}) {
const rightIcon = modeIcon(item.mode);
const showHint = useCallback((el: HTMLElement) => {
const r = el.getBoundingClientRect();
onHintChange({
label: item.label,
desc: item.desc,
x: r.left + r.width / 2,
y: r.top,
});
}, [item.label, item.desc, onHintChange]);
const hideHint = useCallback(() => onHintChange(null), [onHintChange]);
const onDragStart = (e: React.DragEvent) => {
hideHint();
const payload: SidewaysFilterDragPayload = {
type: 'sidewaysFilter',
value: item.id,
label: item.label,
};
e.dataTransfer.setData('application/json', JSON.stringify(payload));
e.dataTransfer.effectAllowed = 'copy';
};
const handleClick = () => onSelect();
const handleDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
onAdd();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (selected) onAdd();
else onSelect();
};
return (
<div
draggable
className={`se-palette-card se-palette-card--range se-range-chip${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={e => showHint(e.currentTarget)}
onMouseLeave={hideHint}
onFocus={e => showHint(e.currentTarget)}
onBlur={hideHint}
role="button"
tabIndex={0}
onKeyDown={handleKeyDown}
aria-label={item.label}
>
<div className="se-range-chip-icons">
<span className="se-range-chip-icon se-range-chip-icon--left" aria-hidden></span>
<span
className={`se-range-chip-icon se-range-chip-icon--right ${rightIcon.cls}`}
aria-label={rightIcon.aria}
title=""
>
{rightIcon.glyph}
</span>
</div>
<span className="se-range-chip-name">{item.label}</span>
</div>
);
}
function SidewaysFilterHintTooltip({ hint }: { hint: HoverHintState }) {
return createPortal(
<div
className="se-range-hint-tooltip"
role="tooltip"
style={{
left: hint.x,
top: hint.y,
}}
>
<div className="se-range-hint-tooltip-title">{hint.label}</div>
<div className="se-range-hint-tooltip-desc">{hint.desc}</div>
</div>,
document.body,
);
}
export default function SidewaysFilterPaletteTab({
def,
searchQuery,
selectedItemId,
onSelectItem,
onAddToCanvas,
}: Props) {
void def;
const [modeFilter, setModeFilter] = useState<'all' | SidewaysFilterMode>('all');
const [hoverHint, setHoverHint] = useState<HoverHintState | null>(null);
const filtered = useMemo(
() => filterSidewaysItems(DEFAULT_SIDEWAYS_FILTER_ITEMS, searchQuery, modeFilter),
[searchQuery, modeFilter],
);
return (
<div className="se-palette-section se-palette-section--range se-palette-section--scroll">
{hoverHint && <SidewaysFilterHintTooltip hint={hoverHint} />}
<div className="se-palette-toolbar">
<span className="se-palette-toolbar-title"> </span>
<span className="se-range-count">{filtered.length}</span>
</div>
<div className="se-range-mode-tabs" role="tablist" aria-label="횡보 필터 모드">
<button
type="button"
role="tab"
aria-selected={modeFilter === 'all'}
className={`se-range-mode-tab${modeFilter === 'all' ? ' se-range-mode-tab--on' : ''}`}
onClick={() => setModeFilter('all')}
>
</button>
<button
type="button"
role="tab"
aria-selected={modeFilter === 'include'}
className={`se-range-mode-tab se-range-mode-tab--include${modeFilter === 'include' ? ' se-range-mode-tab--on' : ''}`}
onClick={() => setModeFilter('include')}
>
</button>
<button
type="button"
role="tab"
aria-selected={modeFilter === 'exclude'}
className={`se-range-mode-tab se-range-mode-tab--exclude${modeFilter === 'exclude' ? ' se-range-mode-tab--on' : ''}`}
onClick={() => setModeFilter('exclude')}
>
</button>
</div>
<div className="se-palette-grid se-palette-grid--3 se-range-grid">
{filtered.map(item => (
<SidewaysFilterChip
key={item.id}
item={item}
selected={selectedItemId === item.id}
onSelect={() => onSelectItem(item.id)}
onAdd={() => onAddToCanvas(item)}
onHintChange={setHoverHint}
/>
))}
</div>
{filtered.length === 0 && (
<p className="se-palette-empty"> .</p>
)}
</div>
);
}
@@ -28,6 +28,7 @@ import {
updateNode,
type DefType,
} from '../../utils/strategyEditorShared';
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPalette';
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
import {
START_NODE_ID,
@@ -563,18 +564,28 @@ function StrategyEditorCanvasInner({
})));
}, [setNodes]);
const resolvePaletteDropNode = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
): LogicNode | null => {
if (data.type === 'sidewaysFilter') {
return buildSidewaysFilterNode(data.value, def);
}
return makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
}, [signalTab, def]);
const addOrphanAt = useCallback((
data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number },
) => {
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
const newNode = resolvePaletteDropNode(data);
if (!newNode) return;
positionsRef.current.set(newNode.id, {
x: flowPos.x - FLOW_NODE_W / 2,
y: flowPos.y - FLOW_NODE_H / 2,
});
onOrphansChange([...orphans, newNode]);
scheduleLayoutEmit();
}, [orphans, onOrphansChange, signalTab, def, scheduleLayoutEmit]);
}, [orphans, onOrphansChange, resolvePaletteDropNode, scheduleLayoutEmit]);
const applyDropWithAttach = useCallback((
anchorId: string,
@@ -586,7 +597,8 @@ function StrategyEditorCanvasInner({
return;
}
const newNode = makeNode(data.type, data.value, signalTab, def, makeNodeOptionsFromPalette(data));
const newNode = resolvePaletteDropNode(data);
if (!newNode) return;
const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return;
@@ -636,7 +648,7 @@ function StrategyEditorCanvasInner({
}
saveAttachHandles(anchorId);
scheduleLayoutEmit();
}, [root, extraRoots, signalTab, def, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit]);
}, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]);
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
updateDropPreview(anchorId, flowPos);
@@ -48,6 +48,49 @@ export function usePanelResize(
}, [axis, onResize, getCurrent, min, max, onCommit]);
}
/** 우측 패널 — 스플리터를 왼쪽으로 드래그하면 넓어짐 */
export function useRightPanelResize(
onResize: (next: number) => void,
getCurrent: () => number,
min: number,
max: number,
onCommit?: (value: number) => void,
) {
return useCallback((e: React.PointerEvent<HTMLDivElement>) => {
e.preventDefault();
const startX = e.clientX;
const start = getCurrent();
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.classList.add('se-splitter--active');
const splitter = e.currentTarget;
const onMove = (ev: PointerEvent) => {
const delta = ev.clientX - startX;
onResize(clamp(start - delta, min, max));
};
const onUp = (ev: PointerEvent) => {
document.body.style.cursor = '';
document.body.style.userSelect = '';
splitter.releasePointerCapture(ev.pointerId);
splitter.classList.remove('se-splitter--active');
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
onCommit?.(getCurrent());
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
}, [onResize, getCurrent, min, max, onCommit]);
}
export function clampPanelSize(value: number, min: number, max: number): number {
return clamp(value, min, max);
}
export function readStoredSize(key: string, fallback: number): number {
try {
const raw = getUiPreferences().panels?.[key];