횡보컨트롤 배경색 적용
This commit is contained in:
@@ -46,7 +46,7 @@ import {
|
||||
} from '../utils/strategyStartNodes';
|
||||
import IndicatorPaletteTab from './strategyEditor/IndicatorPaletteTab';
|
||||
import SidewaysFilterPaletteTab from './strategyEditor/SidewaysFilterPaletteTab';
|
||||
import { buildSidewaysFilterNode } from '../utils/sidewaysFilterPalette';
|
||||
import { buildSidewaysFilterNode, loadSidewaysPaletteItems } from '../utils/sidewaysFilterPaletteStorage';
|
||||
import {
|
||||
loadPaletteItems,
|
||||
type PaletteItem,
|
||||
@@ -240,6 +240,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
const [indicatorSubTab, setIndicatorSubTab] = useState<'auxiliary' | 'composite' | 'range'>('auxiliary');
|
||||
const [auxiliaryPalette, setAuxiliaryPalette] = useState<PaletteItem[]>(() => loadPaletteItems('auxiliary'));
|
||||
const [compositePalette, setCompositePalette] = useState<PaletteItem[]>(() => loadPaletteItems('composite'));
|
||||
const [sidewaysPalette, setSidewaysPalette] = useState(() => loadSidewaysPaletteItems());
|
||||
const [paletteSearch, setPaletteSearch] = useState('');
|
||||
const [templateSearch, setTemplateSearch] = useState('');
|
||||
const [userTemplates, setUserTemplates] = useState<UserStrategyTemplate[]>(() => loadUserStrategyTemplates());
|
||||
@@ -1157,13 +1158,13 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
|
||||
|
||||
const applySidewaysFilter = useCallback((filterId: string) => {
|
||||
const newNode = buildSidewaysFilterNode(filterId, DEF);
|
||||
const newNode = buildSidewaysFilterNode(filterId, DEF, sidewaysPalette);
|
||||
if (!newNode) return;
|
||||
const root = currentRoot;
|
||||
if (!root) setCurrentRoot(newNode);
|
||||
else setCurrentRoot(mergeAtRoot(root, newNode, false));
|
||||
scheduleStrategyPersist();
|
||||
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
|
||||
}, [DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist, sidewaysPalette]);
|
||||
|
||||
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
|
||||
|
||||
@@ -1952,6 +1953,8 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
|
||||
) : (
|
||||
<SidewaysFilterPaletteTab
|
||||
def={DEF}
|
||||
items={sidewaysPalette}
|
||||
onItemsChange={setSidewaysPalette}
|
||||
searchQuery={paletteSearch}
|
||||
selectedItemId={
|
||||
selectedPaletteKey?.startsWith('range:')
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import DraggableModalFrame from '../DraggableModalFrame';
|
||||
import type { SidewaysFilterPaletteEntry } from '../../utils/sidewaysFilterPaletteStorage';
|
||||
import {
|
||||
SIDEWAYS_CATEGORY_OPTIONS,
|
||||
SIDEWAYS_TEMPLATE_OPTIONS,
|
||||
} from '../../utils/sidewaysFilterPaletteStorage';
|
||||
import type { SidewaysFilterMode } from '../../utils/sidewaysFilterPalette';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
mode: 'add' | 'edit';
|
||||
initial?: SidewaysFilterPaletteEntry | null;
|
||||
onClose: () => void;
|
||||
onSave: (entry: Omit<SidewaysFilterPaletteEntry, 'id' | 'builtIn'> & { id?: string; builtIn?: boolean }) => void;
|
||||
}
|
||||
|
||||
export default function SidewaysFilterItemModal({
|
||||
open, mode, initial, onClose, onSave,
|
||||
}: Props) {
|
||||
const [templateId, setTemplateId] = useState(SIDEWAYS_TEMPLATE_OPTIONS[0]?.value ?? 'adx-lt-25');
|
||||
const [label, setLabel] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [category, setCategory] = useState(SIDEWAYS_CATEGORY_OPTIONS[0]?.value ?? 'adx');
|
||||
const [filterMode, setFilterMode] = useState<SidewaysFilterMode>('include');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === 'edit' && initial) {
|
||||
setTemplateId(initial.templateId);
|
||||
setLabel(initial.label);
|
||||
setDesc(initial.desc);
|
||||
setCategory(initial.category);
|
||||
setFilterMode(initial.mode);
|
||||
return;
|
||||
}
|
||||
const tpl = SIDEWAYS_TEMPLATE_OPTIONS[0];
|
||||
if (tpl) {
|
||||
setTemplateId(tpl.value);
|
||||
setLabel(`${tpl.label} (복사)`);
|
||||
setDesc(tpl.desc);
|
||||
setCategory(tpl.category);
|
||||
setFilterMode(tpl.mode);
|
||||
}
|
||||
}, [open, mode, initial]);
|
||||
|
||||
const handleTemplateChange = (next: string) => {
|
||||
setTemplateId(next);
|
||||
if (mode !== 'add') return;
|
||||
const tpl = SIDEWAYS_TEMPLATE_OPTIONS.find(o => o.value === next);
|
||||
if (!tpl) return;
|
||||
setLabel(`${tpl.label} (복사)`);
|
||||
setDesc(tpl.desc);
|
||||
setCategory(tpl.category);
|
||||
setFilterMode(tpl.mode);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const title = mode === 'add' ? '횡보 필터 추가' : '횡보 필터 수정';
|
||||
|
||||
const handleSubmit = () => {
|
||||
const trimmedLabel = label.trim();
|
||||
if (!trimmedLabel) {
|
||||
window.alert('표시 이름을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
id: initial?.id,
|
||||
builtIn: initial?.builtIn,
|
||||
templateId,
|
||||
label: trimmedLabel,
|
||||
desc: desc.trim() || trimmedLabel,
|
||||
category,
|
||||
mode: filterMode,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableModalFrame onClose={onClose} title={title}>
|
||||
<div className="se-modal-body se-palette-item-modal">
|
||||
<label className="se-field-lbl">기반 필터</label>
|
||||
<select
|
||||
className="se-field-inp"
|
||||
value={templateId}
|
||||
disabled={mode === 'edit'}
|
||||
onChange={e => handleTemplateChange(e.target.value)}
|
||||
>
|
||||
{SIDEWAYS_TEMPLATE_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="se-field-lbl">표시 이름</label>
|
||||
<input
|
||||
className="se-field-inp"
|
||||
value={label}
|
||||
onChange={e => setLabel(e.target.value)}
|
||||
placeholder="예: ADX < 25"
|
||||
/>
|
||||
|
||||
<label className="se-field-lbl">설명</label>
|
||||
<input
|
||||
className="se-field-inp"
|
||||
value={desc}
|
||||
onChange={e => setDesc(e.target.value)}
|
||||
placeholder="짧은 설명"
|
||||
/>
|
||||
|
||||
<label className="se-field-lbl">카테고리</label>
|
||||
<select
|
||||
className="se-field-inp"
|
||||
value={category}
|
||||
onChange={e => setCategory(e.target.value as typeof category)}
|
||||
>
|
||||
{SIDEWAYS_CATEGORY_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="se-field-lbl">모드</label>
|
||||
<select
|
||||
className="se-field-inp"
|
||||
value={filterMode}
|
||||
onChange={e => setFilterMode(e.target.value as SidewaysFilterMode)}
|
||||
>
|
||||
<option value="include">횡보 (include)</option>
|
||||
<option value="exclude">횡보 제외 (exclude)</option>
|
||||
</select>
|
||||
|
||||
<div className="se-modal-actions">
|
||||
<button type="button" className="se-btn se-btn--ghost" onClick={onClose}>취소</button>
|
||||
<button type="button" className="se-btn se-btn--gold" onClick={handleSubmit}>
|
||||
{mode === 'add' ? '추가' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableModalFrame>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,15 @@ 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';
|
||||
import {
|
||||
createSidewaysFilterPaletteId,
|
||||
saveSidewaysPaletteItems,
|
||||
type SidewaysFilterPaletteEntry,
|
||||
} from '../../utils/sidewaysFilterPaletteStorage';
|
||||
import SidewaysFilterItemModal from './SidewaysFilterItemModal';
|
||||
|
||||
export interface SidewaysFilterDragPayload {
|
||||
type: 'sidewaysFilter';
|
||||
@@ -16,16 +20,18 @@ export interface SidewaysFilterDragPayload {
|
||||
|
||||
interface Props {
|
||||
def: DefType;
|
||||
items: SidewaysFilterPaletteEntry[];
|
||||
onItemsChange: (items: SidewaysFilterPaletteEntry[]) => void;
|
||||
searchQuery: string;
|
||||
selectedItemId: string | null;
|
||||
onSelectItem: (id: string | null) => void;
|
||||
onAddToCanvas: (item: SidewaysFilterItem) => void;
|
||||
onAddToCanvas: (item: SidewaysFilterPaletteEntry) => void;
|
||||
}
|
||||
|
||||
function modeIcon(mode: SidewaysFilterMode): { glyph: string; cls: string; aria: string } {
|
||||
function modeIcon(mode: SidewaysFilterMode): { glyph: string; aria: string } {
|
||||
return mode === 'include'
|
||||
? { glyph: '▣', cls: 'se-range-chip-icon--include', aria: '횡보' }
|
||||
: { glyph: '⊘', cls: 'se-range-chip-icon--exclude', aria: '제외' };
|
||||
? { glyph: '▣', aria: '횡보' }
|
||||
: { glyph: '⊘', aria: '제외' };
|
||||
}
|
||||
|
||||
interface HoverHintState {
|
||||
@@ -42,7 +48,7 @@ function SidewaysFilterChip({
|
||||
onAdd,
|
||||
onHintChange,
|
||||
}: {
|
||||
item: SidewaysFilterItem;
|
||||
item: SidewaysFilterPaletteEntry;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onAdd: () => void;
|
||||
@@ -89,7 +95,7 @@ function SidewaysFilterChip({
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`se-palette-card se-palette-card--range se-range-chip${selected ? ' se-palette-card--selected' : ''}`}
|
||||
className={`se-palette-card se-palette-card--range se-palette-card--range-${item.mode}${selected ? ' se-palette-card--selected' : ''}`}
|
||||
onDragStart={onDragStart}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
@@ -102,17 +108,29 @@ function SidewaysFilterChip({
|
||||
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>
|
||||
<div className="se-palette-card-head">
|
||||
<span className="se-palette-card-icon" aria-hidden>↔</span>
|
||||
<div className="se-palette-card-meta">
|
||||
<span className="se-palette-card-name">{item.label}</span>
|
||||
<span className="se-palette-card-desc">{item.desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="se-palette-card-period">
|
||||
<span>모드</span>
|
||||
<input
|
||||
readOnly
|
||||
value={item.mode === 'include' ? '횡보' : '제외'}
|
||||
className="se-palette-period-input se-palette-mode-input"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<span
|
||||
className={`se-range-chip-icon se-range-chip-icon--right ${rightIcon.cls}`}
|
||||
aria-label={rightIcon.aria}
|
||||
title=""
|
||||
className={`se-palette-mode-mark se-palette-mode-mark--${item.mode}`}
|
||||
aria-hidden
|
||||
title={rightIcon.aria}
|
||||
>
|
||||
{rightIcon.glyph}
|
||||
</span>
|
||||
</div>
|
||||
<span className="se-range-chip-name">{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +154,8 @@ function SidewaysFilterHintTooltip({ hint }: { hint: HoverHintState }) {
|
||||
|
||||
export default function SidewaysFilterPaletteTab({
|
||||
def,
|
||||
items,
|
||||
onItemsChange,
|
||||
searchQuery,
|
||||
selectedItemId,
|
||||
onSelectItem,
|
||||
@@ -144,18 +164,118 @@ export default function SidewaysFilterPaletteTab({
|
||||
void def;
|
||||
const [modeFilter, setModeFilter] = useState<'all' | SidewaysFilterMode>('all');
|
||||
const [hoverHint, setHoverHint] = useState<HoverHintState | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'add' | 'edit'>('add');
|
||||
const [editTarget, setEditTarget] = useState<SidewaysFilterPaletteEntry | null>(null);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterSidewaysItems(DEFAULT_SIDEWAYS_FILTER_ITEMS, searchQuery, modeFilter),
|
||||
[searchQuery, modeFilter],
|
||||
() => filterSidewaysItems(items, searchQuery, modeFilter),
|
||||
[items, searchQuery, modeFilter],
|
||||
);
|
||||
|
||||
const selected = items.find(i => i.id === selectedItemId) ?? null;
|
||||
|
||||
const persist = useCallback((next: SidewaysFilterPaletteEntry[]) => {
|
||||
onItemsChange(next);
|
||||
saveSidewaysPaletteItems(next);
|
||||
}, [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<SidewaysFilterPaletteEntry, 'id' | 'builtIn'> & { id?: string; builtIn?: boolean }) => {
|
||||
if (modalMode === 'add') {
|
||||
const entry: SidewaysFilterPaletteEntry = {
|
||||
id: createSidewaysFilterPaletteId(),
|
||||
templateId: draft.templateId,
|
||||
label: draft.label,
|
||||
desc: draft.desc,
|
||||
category: draft.category,
|
||||
mode: draft.mode,
|
||||
};
|
||||
persist([...items, entry]);
|
||||
onSelectItem(entry.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,
|
||||
category: draft.category,
|
||||
mode: draft.mode,
|
||||
templateId: draft.templateId,
|
||||
} : i)));
|
||||
};
|
||||
|
||||
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 className="se-palette-toolbar-actions" role="toolbar" aria-label="횡보 필터 관리">
|
||||
<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-range-mode-tabs" role="tablist" aria-label="횡보 필터 모드">
|
||||
@@ -204,6 +324,14 @@ export default function SidewaysFilterPaletteTab({
|
||||
{filtered.length === 0 && (
|
||||
<p className="se-palette-empty">검색 결과가 없습니다.</p>
|
||||
)}
|
||||
|
||||
<SidewaysFilterItemModal
|
||||
open={modalOpen}
|
||||
mode={modalMode}
|
||||
initial={editTarget}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
updateNode,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPalette';
|
||||
import { buildSidewaysFilterNode } from '../../utils/sidewaysFilterPaletteStorage';
|
||||
import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
|
||||
@@ -1751,15 +1751,30 @@
|
||||
color: var(--se-palette-section-range, #f59e0b);
|
||||
}
|
||||
|
||||
.se-palette-card--range {
|
||||
border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 35%, var(--se-border));
|
||||
.se-palette-mode-input {
|
||||
width: auto;
|
||||
min-width: 34px;
|
||||
flex: 1;
|
||||
max-width: 52px;
|
||||
}
|
||||
.se-palette-card--range:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 55%, var(--se-border));
|
||||
.se-palette-mode-mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.62rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.se-palette-card-icon--range {
|
||||
background: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 18%, transparent);
|
||||
color: var(--se-palette-section-range, #f59e0b);
|
||||
.se-palette-mode-mark--include {
|
||||
background: color-mix(in srgb, #38bdf8 18%, transparent);
|
||||
color: #38bdf8;
|
||||
}
|
||||
.se-palette-mode-mark--exclude {
|
||||
background: color-mix(in srgb, var(--se-palette-range-exclude-accent) 18%, transparent);
|
||||
color: var(--se-palette-range-exclude-accent);
|
||||
}
|
||||
|
||||
.se-range-count {
|
||||
@@ -1778,12 +1793,14 @@
|
||||
flex: 1;
|
||||
padding: 6px 4px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--se-border);
|
||||
background: var(--se-btn-bg);
|
||||
border: 1px solid color-mix(in srgb, var(--se-palette-range-accent) 28%, var(--se-border));
|
||||
background: color-mix(in srgb, var(--se-palette-range-accent) 6%, var(--se-btn-bg));
|
||||
color: var(--se-text-muted);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
|
||||
transition: border-color 0.12s, background 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
.se-range-mode-tab--on {
|
||||
border-color: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 50%, transparent);
|
||||
@@ -1858,60 +1875,6 @@
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.se-range-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px 8px 9px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.se-range-chip-icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.se-range-chip-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.se-range-chip-icon--left {
|
||||
background: color-mix(in srgb, var(--se-palette-section-range, #f59e0b) 18%, transparent);
|
||||
color: var(--se-palette-section-range, #f59e0b);
|
||||
}
|
||||
|
||||
.se-range-chip-icon--right.se-range-chip-icon--include {
|
||||
background: color-mix(in srgb, #38bdf8 18%, transparent);
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.se-range-chip-icon--right.se-range-chip-icon--exclude {
|
||||
background: color-mix(in srgb, #f87171 18%, transparent);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.se-range-chip-name {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
color: var(--se-text);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.se-range-hint-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10050;
|
||||
@@ -2158,7 +2121,9 @@
|
||||
.se-palette-card--ind {
|
||||
border-color: color-mix(in srgb, var(--se-palette-ind-accent) 42%, transparent);
|
||||
background: var(--se-palette-ind-bg);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-ind-accent);
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--se-palette-ind-accent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
|
||||
}
|
||||
.se-palette-card--ind .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
|
||||
@@ -2166,14 +2131,19 @@
|
||||
}
|
||||
.se-palette-card--ind:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-ind-accent) 65%, transparent);
|
||||
box-shadow: inset 3px 0 0 var(--se-palette-ind-accent), 0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--se-palette-ind-accent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent),
|
||||
0 4px 14px color-mix(in srgb, var(--se-palette-ind-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
/* ── 복합지표 ── */
|
||||
.se-palette-card--composite {
|
||||
border-color: color-mix(in srgb, #c084fc 42%, transparent);
|
||||
background: color-mix(in srgb, #c084fc 8%, var(--se-palette-card-bg));
|
||||
box-shadow: inset 3px 0 0 #c084fc;
|
||||
background: linear-gradient(145deg, color-mix(in srgb, #c084fc 22%, var(--bg2)), color-mix(in srgb, #c084fc 10%, var(--bg2)));
|
||||
box-shadow:
|
||||
inset 3px 0 0 #c084fc,
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
|
||||
}
|
||||
.se-palette-card--composite .se-palette-card-icon {
|
||||
background: color-mix(in srgb, #c084fc 22%, transparent);
|
||||
@@ -2181,12 +2151,70 @@
|
||||
}
|
||||
.se-palette-card--composite:hover {
|
||||
border-color: color-mix(in srgb, #c084fc 65%, transparent);
|
||||
box-shadow: inset 3px 0 0 #c084fc, 0 4px 14px color-mix(in srgb, #c084fc 22%, transparent);
|
||||
box-shadow:
|
||||
inset 3px 0 0 #c084fc,
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent),
|
||||
0 4px 14px color-mix(in srgb, #c084fc 22%, transparent);
|
||||
}
|
||||
.se-palette-card--selected.se-palette-card--composite {
|
||||
outline-color: #c084fc;
|
||||
}
|
||||
|
||||
/* ── 횡보 필터 (base .se-palette-card 뒤에 배치 — 배경 덮어쓰기 방지) ── */
|
||||
.se-palette-card.se-palette-card--range,
|
||||
.se-palette-card.se-palette-card--range-include {
|
||||
border-color: color-mix(in srgb, var(--se-palette-range-accent) 42%, transparent);
|
||||
background: var(--se-palette-range-bg);
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--se-palette-range-accent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
|
||||
}
|
||||
.se-palette-card.se-palette-card--range .se-palette-card-icon,
|
||||
.se-palette-card.se-palette-card--range-include .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-range-accent) 22%, transparent);
|
||||
color: var(--se-palette-range-accent);
|
||||
}
|
||||
.se-palette-card.se-palette-card--range:hover,
|
||||
.se-palette-card.se-palette-card--range-include:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-range-accent) 65%, transparent);
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--se-palette-range-accent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent),
|
||||
0 4px 14px color-mix(in srgb, var(--se-palette-range-accent) 22%, transparent);
|
||||
}
|
||||
.se-palette-card--selected.se-palette-card.se-palette-card--range,
|
||||
.se-palette-card--selected.se-palette-card.se-palette-card--range-include {
|
||||
outline-color: var(--se-palette-range-accent);
|
||||
}
|
||||
.se-palette-card.se-palette-card--range-exclude {
|
||||
border-color: color-mix(in srgb, var(--se-palette-range-exclude-accent) 42%, transparent);
|
||||
background: var(--se-palette-range-exclude-bg);
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--se-palette-range-exclude-accent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent);
|
||||
}
|
||||
.se-palette-card.se-palette-card--range-exclude .se-palette-card-icon {
|
||||
background: color-mix(in srgb, var(--se-palette-range-exclude-accent) 22%, transparent);
|
||||
color: var(--se-palette-range-exclude-accent);
|
||||
}
|
||||
.se-palette-card.se-palette-card--range-exclude:hover {
|
||||
border-color: color-mix(in srgb, var(--se-palette-range-exclude-accent) 65%, transparent);
|
||||
box-shadow:
|
||||
inset 3px 0 0 var(--se-palette-range-exclude-accent),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--se-text) 6%, transparent),
|
||||
0 4px 14px color-mix(in srgb, var(--se-palette-range-exclude-accent) 22%, transparent);
|
||||
}
|
||||
.se-palette-card--selected.se-palette-card.se-palette-card--range-exclude {
|
||||
outline-color: var(--se-palette-range-exclude-accent);
|
||||
}
|
||||
.se-palette-card.se-palette-card--range .se-palette-card-desc {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.se-palette-card-head { display: flex; align-items: flex-start; gap: 6px; }
|
||||
.se-palette-card-icon {
|
||||
width: 24px; height: 24px; border-radius: 6px;
|
||||
|
||||
@@ -68,10 +68,15 @@
|
||||
--se-palette-band-accent: #a78bfa;
|
||||
--se-palette-band-bg: color-mix(in srgb, #a78bfa 14%, var(--bg2));
|
||||
--se-palette-ind-accent: #34d399;
|
||||
--se-palette-ind-bg: color-mix(in srgb, #34d399 14%, var(--bg2));
|
||||
--se-palette-ind-bg: linear-gradient(145deg, color-mix(in srgb, #34d399 22%, var(--bg2)), color-mix(in srgb, #34d399 10%, var(--bg2)));
|
||||
--se-palette-range-accent: #f59e0b;
|
||||
--se-palette-range-bg: linear-gradient(145deg, color-mix(in srgb, #f59e0b 22%, var(--bg2)), color-mix(in srgb, #f59e0b 10%, var(--bg2)));
|
||||
--se-palette-range-exclude-accent: #f87171;
|
||||
--se-palette-range-exclude-bg: linear-gradient(145deg, color-mix(in srgb, #f87171 20%, var(--bg2)), color-mix(in srgb, #f87171 9%, var(--bg2)));
|
||||
--se-palette-section-logic: #4dabf7;
|
||||
--se-palette-section-band: #a78bfa;
|
||||
--se-palette-section-ind: #34d399;
|
||||
--se-palette-section-range: #f59e0b;
|
||||
--se-tab-active: var(--se-gold);
|
||||
--se-handle: var(--accent);
|
||||
--se-edge: color-mix(in srgb, var(--accent) 72%, transparent);
|
||||
@@ -202,10 +207,15 @@
|
||||
--se-palette-band-accent: #7b1fa2;
|
||||
--se-palette-band-bg: rgba(123, 31, 162, 0.07);
|
||||
--se-palette-ind-accent: #2e7d32;
|
||||
--se-palette-ind-bg: rgba(46, 125, 50, 0.08);
|
||||
--se-palette-ind-bg: linear-gradient(145deg, rgba(46, 125, 50, 0.16), rgba(46, 125, 50, 0.06));
|
||||
--se-palette-range-accent: #e65100;
|
||||
--se-palette-range-bg: linear-gradient(145deg, rgba(230, 81, 0, 0.16), rgba(230, 81, 0, 0.06));
|
||||
--se-palette-range-exclude-accent: #c62828;
|
||||
--se-palette-range-exclude-bg: linear-gradient(145deg, rgba(198, 40, 40, 0.14), rgba(198, 40, 40, 0.05));
|
||||
--se-palette-section-logic: #1565c0;
|
||||
--se-palette-section-band: #7b1fa2;
|
||||
--se-palette-section-ind: #2e7d32;
|
||||
--se-palette-section-range: #e65100;
|
||||
|
||||
--vtd-sig-gold-text: color-mix(in srgb, var(--se-gold) 88%, #212121);
|
||||
--vtd-sig-panel-bg: color-mix(in srgb, var(--se-gold) 5%, #ffffff);
|
||||
@@ -261,10 +271,15 @@
|
||||
--se-palette-band-accent: #ce93d8;
|
||||
--se-palette-band-bg: rgba(206, 147, 216, 0.12);
|
||||
--se-palette-ind-accent: #69f0ae;
|
||||
--se-palette-ind-bg: rgba(105, 240, 174, 0.1);
|
||||
--se-palette-ind-bg: linear-gradient(145deg, rgba(105, 240, 174, 0.18), rgba(105, 240, 174, 0.07));
|
||||
--se-palette-range-accent: #ffb74d;
|
||||
--se-palette-range-bg: linear-gradient(145deg, rgba(255, 183, 77, 0.22), rgba(255, 183, 77, 0.08));
|
||||
--se-palette-range-exclude-accent: #ef5350;
|
||||
--se-palette-range-exclude-bg: linear-gradient(145deg, rgba(239, 83, 80, 0.18), rgba(239, 83, 80, 0.07));
|
||||
--se-palette-section-logic: #5eb5ff;
|
||||
--se-palette-section-band: #ce93d8;
|
||||
--se-palette-section-ind: #69f0ae;
|
||||
--se-palette-section-range: #ffb74d;
|
||||
}
|
||||
|
||||
/* Light theme — flow node & handle tweaks */
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { IndicatorCustomTab } from '../utils/indicatorCustomTabsStorage';
|
||||
import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
|
||||
import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage';
|
||||
import type { UserStrategyTemplate } from '../utils/strategyTemplateStorage';
|
||||
import type { SidewaysFilterPaletteEntry } from '../utils/sidewaysFilterPaletteStorage';
|
||||
|
||||
/** gc_app_settings.ui_preferences_json 구조 */
|
||||
export interface UiPreferences {
|
||||
@@ -16,6 +17,7 @@ export interface UiPreferences {
|
||||
userTemplates?: UserStrategyTemplate[];
|
||||
/** 내장 템플릿 목록에서 숨긴 ID (getBuiltinTemplateId) */
|
||||
hiddenBuiltinTemplateIds?: string[];
|
||||
paletteSideways?: SidewaysFilterPaletteEntry[];
|
||||
};
|
||||
indicator?: {
|
||||
customTabs?: IndicatorCustomTab[];
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import type { DefType } from './strategyEditorShared';
|
||||
import { genId } from './strategyNodeIds';
|
||||
import type { SidewaysFilterPaletteEntry } from './sidewaysFilterPaletteStorage';
|
||||
import { THRESHOLD_HL_MID, THRESHOLD_HL_UNDER } from './thresholdSymbols';
|
||||
|
||||
export type SidewaysFilterMode = 'include' | 'exclude';
|
||||
@@ -28,7 +29,9 @@ export interface SidewaysFilterItem {
|
||||
category: SidewaysFilterCategory;
|
||||
/** include=횡보 구간만, exclude=횡보 제외(추세만) */
|
||||
mode: SidewaysFilterMode;
|
||||
build: (def: DefType) => LogicNode;
|
||||
builtIn?: boolean;
|
||||
/** 모드(NOT) 적용 전 내부 조건 트리 */
|
||||
buildInner: (def: DefType) => LogicNode;
|
||||
}
|
||||
|
||||
export const SIDEWAYS_FILTER_CATEGORY_LABEL: Record<SidewaysFilterCategory, string> = {
|
||||
@@ -158,14 +161,6 @@ function dcNarrow(period: number, threshold: number): LogicNode {
|
||||
);
|
||||
}
|
||||
|
||||
function buildInclude(inner: LogicNode): LogicNode {
|
||||
return inner;
|
||||
}
|
||||
|
||||
function buildExclude(inner: LogicNode): LogicNode {
|
||||
return notNode(inner);
|
||||
}
|
||||
|
||||
function item(
|
||||
id: string,
|
||||
label: string,
|
||||
@@ -180,13 +175,25 @@ function item(
|
||||
desc,
|
||||
category,
|
||||
mode,
|
||||
build: def => {
|
||||
const node = typeof inner === 'function' ? inner(def) : inner;
|
||||
return mode === 'exclude' ? buildExclude(node) : buildInclude(node);
|
||||
},
|
||||
builtIn: true,
|
||||
buildInner: def => (typeof inner === 'function' ? inner(def) : inner),
|
||||
};
|
||||
}
|
||||
|
||||
export function applySidewaysFilterMode(inner: LogicNode, mode: SidewaysFilterMode): LogicNode {
|
||||
return mode === 'exclude' ? notNode(inner) : inner;
|
||||
}
|
||||
|
||||
export function buildSidewaysFilterLogic(
|
||||
templateId: string,
|
||||
mode: SidewaysFilterMode,
|
||||
def: DefType,
|
||||
): LogicNode | null {
|
||||
const tpl = BUILTIN_FILTER_MAP.get(templateId);
|
||||
if (!tpl) return null;
|
||||
return applySidewaysFilterMode(tpl.buildInner(def), mode);
|
||||
}
|
||||
|
||||
/** 내장 횡보 필터 목록 */
|
||||
export const DEFAULT_SIDEWAYS_FILTER_ITEMS: SidewaysFilterItem[] = [
|
||||
// ── ADX ──
|
||||
@@ -299,23 +306,17 @@ export const DEFAULT_SIDEWAYS_FILTER_ITEMS: SidewaysFilterItem[] = [
|
||||
)),
|
||||
];
|
||||
|
||||
const FILTER_MAP = new Map(DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => [i.id, i]));
|
||||
const BUILTIN_FILTER_MAP = new Map(DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => [i.id, i]));
|
||||
|
||||
export function getSidewaysFilterItem(id: string): SidewaysFilterItem | undefined {
|
||||
return FILTER_MAP.get(id);
|
||||
}
|
||||
|
||||
export function buildSidewaysFilterNode(id: string, def: DefType): LogicNode | null {
|
||||
const item = getSidewaysFilterItem(id);
|
||||
if (!item) return null;
|
||||
return item.build(def);
|
||||
return BUILTIN_FILTER_MAP.get(id);
|
||||
}
|
||||
|
||||
export function filterSidewaysItems(
|
||||
items: SidewaysFilterItem[],
|
||||
items: SidewaysFilterPaletteEntry[],
|
||||
query: string,
|
||||
modeFilter: 'all' | SidewaysFilterMode,
|
||||
): SidewaysFilterItem[] {
|
||||
): SidewaysFilterPaletteEntry[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
return items.filter(i => {
|
||||
if (modeFilter !== 'all' && i.mode !== modeFilter) return false;
|
||||
@@ -332,12 +333,12 @@ export function filterSidewaysItems(
|
||||
}
|
||||
|
||||
export function groupSidewaysFiltersByCategory(
|
||||
items: SidewaysFilterItem[],
|
||||
): { category: SidewaysFilterCategory; label: string; items: SidewaysFilterItem[] }[] {
|
||||
items: SidewaysFilterPaletteEntry[],
|
||||
): { category: SidewaysFilterCategory; label: string; items: SidewaysFilterPaletteEntry[] }[] {
|
||||
const order: SidewaysFilterCategory[] = [
|
||||
'adx', 'bollinger', 'ma', 'ichimoku', 'donchian', 'oscillator', 'cycle', 'dmi', 'volume', 'combo',
|
||||
];
|
||||
const grouped = new Map<SidewaysFilterCategory, SidewaysFilterItem[]>();
|
||||
const grouped = new Map<SidewaysFilterCategory, SidewaysFilterPaletteEntry[]>();
|
||||
for (const i of items) {
|
||||
const list = grouped.get(i.category) ?? [];
|
||||
list.push(i);
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/** 횡보필터 팔레트 — DB ui_preferences 저장 */
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import type { DefType } from './strategyEditorShared';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
import {
|
||||
DEFAULT_SIDEWAYS_FILTER_ITEMS,
|
||||
buildSidewaysFilterLogic,
|
||||
type SidewaysFilterCategory,
|
||||
type SidewaysFilterMode,
|
||||
} from './sidewaysFilterPalette';
|
||||
|
||||
export interface SidewaysFilterPaletteEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
category: SidewaysFilterCategory;
|
||||
mode: SidewaysFilterMode;
|
||||
/** 내장 필터 템플릿 ID — 로직 생성에 사용 */
|
||||
templateId: string;
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
function defaultEntries(): SidewaysFilterPaletteEntry[] {
|
||||
return DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => ({
|
||||
id: i.id,
|
||||
templateId: i.id,
|
||||
label: i.label,
|
||||
desc: i.desc,
|
||||
category: i.category,
|
||||
mode: i.mode,
|
||||
builtIn: true,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseStored(raw: unknown): SidewaysFilterPaletteEntry[] | null {
|
||||
if (!Array.isArray(raw)) return null;
|
||||
return raw.filter(
|
||||
(e): e is SidewaysFilterPaletteEntry =>
|
||||
e != null
|
||||
&& typeof e === 'object'
|
||||
&& typeof (e as SidewaysFilterPaletteEntry).id === 'string'
|
||||
&& typeof (e as SidewaysFilterPaletteEntry).templateId === 'string'
|
||||
&& typeof (e as SidewaysFilterPaletteEntry).label === 'string',
|
||||
);
|
||||
}
|
||||
|
||||
function mergeMissingBuiltins(
|
||||
stored: SidewaysFilterPaletteEntry[],
|
||||
defaults: SidewaysFilterPaletteEntry[],
|
||||
): SidewaysFilterPaletteEntry[] {
|
||||
const existing = new Set(stored.map(i => i.id));
|
||||
const added = defaults.filter(d => !existing.has(d.id));
|
||||
return added.length > 0 ? [...stored, ...added] : stored;
|
||||
}
|
||||
|
||||
export function loadSidewaysPaletteItems(): SidewaysFilterPaletteEntry[] {
|
||||
const raw = getUiPreferences().strategyEditor?.paletteSideways;
|
||||
const stored = parseStored(raw);
|
||||
const defaults = defaultEntries();
|
||||
if (!stored?.length) return defaults;
|
||||
return mergeMissingBuiltins(stored, defaults);
|
||||
}
|
||||
|
||||
export function saveSidewaysPaletteItems(items: SidewaysFilterPaletteEntry[]): void {
|
||||
patchUiPreferences({ strategyEditor: { paletteSideways: items } });
|
||||
}
|
||||
|
||||
export function createSidewaysFilterPaletteId(): string {
|
||||
return `range-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
export function buildSidewaysFilterNode(
|
||||
id: string,
|
||||
def: DefType,
|
||||
entries?: SidewaysFilterPaletteEntry[],
|
||||
): LogicNode | null {
|
||||
const list = entries ?? loadSidewaysPaletteItems();
|
||||
const entry = list.find(e => e.id === id);
|
||||
if (!entry) return null;
|
||||
return buildSidewaysFilterLogic(entry.templateId, entry.mode, def);
|
||||
}
|
||||
|
||||
export const SIDEWAYS_TEMPLATE_OPTIONS = DEFAULT_SIDEWAYS_FILTER_ITEMS.map(i => ({
|
||||
value: i.id,
|
||||
label: i.label,
|
||||
category: i.category,
|
||||
mode: i.mode,
|
||||
desc: i.desc,
|
||||
}));
|
||||
|
||||
export const SIDEWAYS_CATEGORY_OPTIONS: { value: SidewaysFilterCategory; label: string }[] = [
|
||||
{ value: 'adx', label: 'ADX · 추세강도' },
|
||||
{ value: 'bollinger', label: '볼린저 밴드' },
|
||||
{ value: 'ma', label: 'MA · 이동평균' },
|
||||
{ value: 'ichimoku', label: '일목균형표' },
|
||||
{ value: 'donchian', label: 'Donchian · 채널' },
|
||||
{ value: 'oscillator', label: '오실레이터' },
|
||||
{ value: 'cycle', label: 'Stoch·lookback' },
|
||||
{ value: 'dmi', label: 'DMI' },
|
||||
{ value: 'volume', label: '거래량 · VR' },
|
||||
{ value: 'combo', label: '복합 필터' },
|
||||
];
|
||||
Reference in New Issue
Block a user