횡보컨트롤 배경색 적용

This commit is contained in:
Macbook
2026-06-12 11:08:58 +09:00
parent 981f09cf29
commit 9208418c33
9 changed files with 538 additions and 118 deletions
@@ -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,
+96 -68
View File
@@ -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;
+18 -3
View File
@@ -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 */
+2
View File
@@ -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[];
+27 -26
View File
@@ -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: '복합 필터' },
];