전략편집기 횡보구간 컨트롤 추가
This commit is contained in:
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user