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

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
@@ -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];