diff --git a/frontend/src/App.css b/frontend/src/App.css
index 1656a53..62c98da 100644
--- a/frontend/src/App.css
+++ b/frontend/src/App.css
@@ -7643,6 +7643,16 @@ html.desktop-client .tmb-logo-version {
color: var(--text2);
line-height: 1.35;
}
+.se-cond-preset-section {
+ margin: 10px 0 4px;
+ font-size: 0.65rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: #a78bfa;
+}
+.se-cond-preset-section:first-of-type {
+ margin-top: 0;
+}
.se-cond-preset-empty {
margin: 0;
font-size: 0.68rem;
diff --git a/frontend/src/components/strategyEditor/ConditionPresetGraph.tsx b/frontend/src/components/strategyEditor/ConditionPresetGraph.tsx
index 168fd1d..3179618 100644
--- a/frontend/src/components/strategyEditor/ConditionPresetGraph.tsx
+++ b/frontend/src/components/strategyEditor/ConditionPresetGraph.tsx
@@ -65,6 +65,21 @@ export default function ConditionPresetGraph({ type, size = 48 }: Props) {
>
)}
+ {(type === 'path_first_cross' || type === 'path_pullback_recross' || type === 'path_deep_recovery') && (
+ <>
+
+
+ >
+ )}
+ {type === 'path_first_cross' && (
+
+ )}
+ {type === 'path_pullback_recross' && (
+
+ )}
+ {type === 'path_deep_recovery' && (
+
+ )}
);
}
diff --git a/frontend/src/components/strategyEditor/ConditionPresetPicker.tsx b/frontend/src/components/strategyEditor/ConditionPresetPicker.tsx
index 3f9832e..a5c5b38 100644
--- a/frontend/src/components/strategyEditor/ConditionPresetPicker.tsx
+++ b/frontend/src/components/strategyEditor/ConditionPresetPicker.tsx
@@ -1,38 +1,84 @@
import React, { useEffect, useRef } from 'react';
-import type { ConditionDSL } from '../../utils/strategyTypes';
+import type { ConditionDSL, LogicNode } from '../../utils/strategyTypes';
import type { DefType } from '../../utils/strategyEditorShared';
import {
applyConditionPreset,
+ applyCompoundConditionPreset,
getConditionPresetsForIndicator,
type ConditionPreset,
} from '../../utils/conditionPresets';
+import { isCompoundConditionPreset } from '../../utils/conditionPathPresets';
import ConditionPresetGraph from './ConditionPresetGraph';
interface Props {
condition: ConditionDSL;
def: DefType;
signalType: 'buy' | 'sell';
+ nodeId: string;
onChange: (next: ConditionDSL) => void;
+ onReplaceNode: (node: LogicNode) => void;
onClose: () => void;
popoverClassName?: string;
}
+function PresetSection({
+ title,
+ presets,
+ onPick,
+}: {
+ title: string;
+ presets: ConditionPreset[];
+ onPick: (preset: ConditionPreset) => void;
+}) {
+ if (presets.length === 0) return null;
+ return (
+ <>
+
{title}
+
+ {presets.map(preset => (
+ -
+
+
+ ))}
+
+ >
+ );
+}
+
export default function ConditionPresetPicker({
condition,
def,
signalType,
+ nodeId,
onChange,
+ onReplaceNode,
onClose,
popoverClassName = 'sp-node-preset-pop',
}: Props) {
const popRef = useRef(null);
const presets = getConditionPresetsForIndicator(condition.indicatorType, def, signalType);
- const sorted = [...presets].sort((a, b) => {
- const aMatch = a.tags?.includes(signalType) ? 0 : 1;
- const bMatch = b.tags?.includes(signalType) ? 0 : 1;
- return aMatch - bMatch;
- });
+ const sortBySignal = (list: ConditionPreset[]) =>
+ [...list].sort((a, b) => {
+ const aMatch = a.tags?.includes(signalType) ? 0 : 1;
+ const bMatch = b.tags?.includes(signalType) ? 0 : 1;
+ return aMatch - bMatch;
+ });
+
+ const basicPresets = sortBySignal(presets.filter(p => p.category !== 'path'));
+ const pathPresets = sortBySignal(presets.filter(p => p.category === 'path'));
useEffect(() => {
const onDoc = (e: MouseEvent) => {
@@ -48,11 +94,15 @@ export default function ConditionPresetPicker({
}, [onClose]);
const handlePick = (preset: ConditionPreset) => {
- onChange(applyConditionPreset(condition, preset, def));
+ if (isCompoundConditionPreset(preset)) {
+ onReplaceNode(applyCompoundConditionPreset(nodeId, condition, preset, def));
+ } else {
+ onChange(applyConditionPreset(condition, preset, def));
+ }
onClose();
};
- if (sorted.length === 0) {
+ if (presets.length === 0) {
return (
@@ -71,25 +121,8 @@ export default function ConditionPresetPicker({
그래프 패턴을 선택하면 캔들 범위·조건대상·조건이 자동 설정됩니다.
-
- {sorted.map(preset => (
- -
-
-
- ))}
-
+
+
);
}
diff --git a/frontend/src/components/strategyEditor/StrategyListEditor.tsx b/frontend/src/components/strategyEditor/StrategyListEditor.tsx
index 629c71f..aa958c0 100644
--- a/frontend/src/components/strategyEditor/StrategyListEditor.tsx
+++ b/frontend/src/components/strategyEditor/StrategyListEditor.tsx
@@ -218,7 +218,9 @@ const TreeNodeComp: React.FC = ({
condition={node.condition}
def={def}
signalType={signalType}
+ nodeId={node.id}
onChange={handleCondChange}
+ onReplaceNode={onUpdate}
onClose={() => setPresetOpen(false)}
popoverClassName="sp-node-preset-pop"
/>
diff --git a/frontend/src/utils/conditionPathPresets.ts b/frontend/src/utils/conditionPathPresets.ts
new file mode 100644
index 0000000..25c7fcd
--- /dev/null
+++ b/frontend/src/utils/conditionPathPresets.ts
@@ -0,0 +1,182 @@
+/**
+ * 상향 돌파 경로 판별 — 복합(AND) 조건 프리셋
+ * @see 상향돌파 케이스별 판별.md
+ */
+import type { ConditionDSL } from './strategyTypes';
+import type { DefType } from './strategyEditorShared';
+import { getFieldOpts } from './strategyEditorShared';
+import {
+ THRESHOLD_HL_MID,
+ THRESHOLD_HL_OVER,
+ THRESHOLD_HL_UNDER,
+} from './thresholdSymbols';
+import { thresholdField } from './conditionPeriods';
+import type { ConditionPreset, ConditionPresetGraph } from './conditionPresets';
+
+const DEFAULT_PATH_LOOKBACK = 30;
+
+/** 상향 돌파 경로 판별 지원 지표 */
+const PATH_ELIGIBLE = new Set([
+ 'RSI', 'CCI', 'STOCHASTIC', 'WILLIAMS_R',
+ 'PSYCHOLOGICAL', 'NEW_PSYCHOLOGICAL', 'INVEST_PSYCHOLOGICAL',
+ 'VR', 'BWI', 'TRIX',
+]);
+
+type PathLineConfig = {
+ valueField: string;
+ targetField: string;
+ floorField: string;
+};
+
+function pathLines(indicatorType: string): PathLineConfig | null {
+ switch (indicatorType) {
+ case 'RSI':
+ return { valueField: 'RSI_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
+ case 'CCI':
+ return { valueField: 'CCI_VALUE', targetField: thresholdField(50), floorField: THRESHOLD_HL_MID };
+ case 'STOCHASTIC':
+ return { valueField: 'STOCH_K', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
+ case 'WILLIAMS_R':
+ return { valueField: 'WILLIAMS_R_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
+ case 'PSYCHOLOGICAL':
+ case 'INVEST_PSYCHOLOGICAL':
+ return { valueField: 'PSY_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
+ case 'NEW_PSYCHOLOGICAL':
+ return { valueField: 'NEW_PSY_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
+ case 'VR':
+ return { valueField: 'VR_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
+ case 'BWI':
+ return { valueField: 'BWI_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
+ case 'TRIX':
+ return { valueField: 'TRIX_VALUE', targetField: THRESHOLD_HL_MID, floorField: THRESHOLD_HL_UNDER };
+ default:
+ return null;
+ }
+}
+
+function fieldLabel(
+ indicatorType: string,
+ field: string,
+ def: DefType,
+ signalType: 'buy' | 'sell',
+ mock: ConditionDSL,
+): string {
+ return getFieldOpts(indicatorType, signalType, def, mock).find(o => o.value === field)?.label ?? field;
+}
+
+function withLookback(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial {
+ return { ...patch, lookbackPeriod: n };
+}
+
+function withExistsIn(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial {
+ return { ...patch, candleRangeMode: 'EXISTS_IN', candleRange: n };
+}
+
+function withNotExistsIn(patch: Partial, n = DEFAULT_PATH_LOOKBACK): Partial {
+ return { ...patch, candleRangeMode: 'NOT_EXISTS_IN', candleRange: n };
+}
+
+/** ① 0(바닥선) 위에서 목표선 첫 상향 돌파 */
+function buildPathFirstCross(cfg: PathLineConfig): Partial[] {
+ const { valueField, targetField, floorField } = cfg;
+ return [
+ { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField },
+ withLookback({ conditionType: 'LOWEST_GTE', leftField: valueField, rightField: floorField }),
+ withNotExistsIn({ conditionType: 'GT', leftField: valueField, rightField: targetField }),
+ ];
+}
+
+/** ② 목표선 위→아래 조정(바닥선 유지) 후 목표선 재상향 돌파 */
+function buildPathPullbackRecross(cfg: PathLineConfig): Partial[] {
+ const { valueField, targetField, floorField } = cfg;
+ return [
+ { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField },
+ withLookback({ conditionType: 'LOWEST_GTE', leftField: valueField, rightField: floorField }),
+ withExistsIn({ conditionType: 'GT', leftField: valueField, rightField: targetField }),
+ withExistsIn({ conditionType: 'LT', leftField: valueField, rightField: targetField }),
+ ];
+}
+
+/** ③ 바닥선 이탈(침체) 후 변곡·목표선 상향 돌파 */
+function buildPathDeepRecovery(cfg: PathLineConfig): Partial[] {
+ const { valueField, targetField, floorField } = cfg;
+ return [
+ { conditionType: 'CROSS_UP', leftField: valueField, rightField: targetField },
+ withLookback({ conditionType: 'LOWEST_LTE', leftField: valueField, rightField: floorField }),
+ ];
+}
+
+function enrichPathPreset(
+ preset: ConditionPreset,
+ indicatorType: string,
+ def: DefType,
+ signalType: 'buy' | 'sell',
+ cfg: PathLineConfig,
+): ConditionPreset {
+ const mockCross: ConditionDSL = {
+ indicatorType,
+ conditionType: 'CROSS_UP',
+ leftField: cfg.valueField,
+ rightField: cfg.targetField,
+ };
+ const left = fieldLabel(indicatorType, cfg.valueField, def, signalType, mockCross);
+ const target = fieldLabel(indicatorType, cfg.targetField, def, signalType, mockCross);
+ const floor = fieldLabel(indicatorType, cfg.floorField, def, signalType, {
+ ...mockCross,
+ conditionType: 'LOWEST_GTE',
+ rightField: cfg.floorField,
+ });
+ const n = DEFAULT_PATH_LOOKBACK;
+
+ const descById: Record = {
+ path_first_cross: {
+ label: `① ${target} 첫 상향 돌파 (${floor} 이상 유지)`,
+ description: `${left}이(가) 최근 ${n}봉 동안 ${floor} 아래로 내려간 적 없고, ${target} 위 체류 이력도 없을 때 ${target}을(를) 처음 상향 돌파 (AND ${preset.compound?.length ?? 3}조건)`,
+ },
+ path_pullback_recross: {
+ label: `② ${target} 되돌림 후 재상향 돌파`,
+ description: `${left}이(가) ${floor} 위를 유지한 채 ${target} 위·아래를 오간 뒤, 다시 ${target}을(를) 상향 돌파 (AND ${preset.compound?.length ?? 4}조건)`,
+ },
+ path_deep_recovery: {
+ label: `③ ${floor} 침체 후 ${target} 상향 돌파`,
+ description: `최근 ${n}봉 내 ${left}이(가) ${floor} 이하(침체) 구간을 거친 뒤, ${target}을(를) 상향 돌파하는 V자 반등 (AND ${preset.compound?.length ?? 2}조건)`,
+ },
+ };
+
+ const extra = descById[preset.id];
+ if (!extra) return preset;
+ return { ...preset, label: extra.label, description: extra.description };
+}
+
+export function getPathBreakoutPresets(
+ indicatorType: string,
+ def: DefType,
+ signalType: 'buy' | 'sell',
+): ConditionPreset[] {
+ if (!PATH_ELIGIBLE.has(indicatorType)) return [];
+ const cfg = pathLines(indicatorType);
+ if (!cfg) return [];
+
+ const base = (id: string, graph: ConditionPresetGraph, compound: Partial[]): ConditionPreset => ({
+ id,
+ label: id,
+ description: '',
+ graph,
+ patch: {},
+ category: 'path',
+ compound,
+ tags: ['buy'],
+ });
+
+ const presets: ConditionPreset[] = [
+ base('path_first_cross', 'path_first_cross', buildPathFirstCross(cfg)),
+ base('path_pullback_recross', 'path_pullback_recross', buildPathPullbackRecross(cfg)),
+ base('path_deep_recovery', 'path_deep_recovery', buildPathDeepRecovery(cfg)),
+ ];
+
+ return presets.map(p => enrichPathPreset(p, indicatorType, def, signalType, cfg));
+}
+
+export function isCompoundConditionPreset(preset: ConditionPreset): boolean {
+ return !!preset.compound && preset.compound.length > 0;
+}
diff --git a/frontend/src/utils/conditionPresetLabels.ts b/frontend/src/utils/conditionPresetLabels.ts
new file mode 100644
index 0000000..fe1f311
--- /dev/null
+++ b/frontend/src/utils/conditionPresetLabels.ts
@@ -0,0 +1,160 @@
+/**
+ * 조건 프리셋 — 지표·기준선 값 반영 라벨/설명 생성
+ */
+import type { ConditionDSL } from './strategyTypes';
+import { CONDITION_LABEL } from './strategyTypes';
+import type { DefType } from './strategyEditorShared';
+import { getFieldOpts } from './strategyEditorShared';
+import type { ConditionPresetGraph } from './conditionPresets';
+
+type PresetLike = {
+ id: string;
+ label: string;
+ description: string;
+ graph: ConditionPresetGraph;
+ patch: Partial;
+ tags?: ('buy' | 'sell')[];
+};
+
+function resolveFieldLabel(
+ indicatorType: string,
+ field: string | undefined,
+ def: DefType,
+ signalType: 'buy' | 'sell',
+ mockCond: ConditionDSL,
+): string {
+ if (!field || field === 'NONE') return '';
+ const opts = getFieldOpts(indicatorType, signalType, def, mockCond);
+ return opts.find(o => o.value === field)?.label ?? field;
+}
+
+function condVerb(condType: string): string {
+ switch (condType) {
+ case 'CROSS_UP': return '상향 돌파';
+ case 'CROSS_DOWN': return '하향 돌파';
+ case 'GT': return '초과';
+ case 'LT': return '미만';
+ case 'GTE': return '이상';
+ case 'LTE': return '이하';
+ case 'EQ': return '같음';
+ case 'NEQ': return '다름';
+ case 'SLOPE_UP': return '상승 기울기';
+ case 'SLOPE_DOWN': return '하락 기울기';
+ default: return CONDITION_LABEL[condType] ?? condType;
+ }
+}
+
+function buildDescription(
+ left: string,
+ right: string,
+ condType: string,
+ slopePeriod?: number,
+): string {
+ const sp = slopePeriod ?? 3;
+ switch (condType) {
+ case 'CROSS_UP':
+ if (left && right) {
+ return `${left}이(가) ${right}을(를) 아래에서 위로 돌파하는 순간 (직전 봉 ≤ 기준, 현재 봉 > 기준)`;
+ }
+ return '기준선을 아래에서 위로 돌파하는 순간';
+ case 'CROSS_DOWN':
+ if (left && right) {
+ return `${left}이(가) ${right}을(를) 위에서 아래로 이탈하는 순간 (직전 봉 ≥ 기준, 현재 봉 < 기준)`;
+ }
+ return '기준선을 위에서 아래로 이탈하는 순간';
+ case 'GT':
+ return left && right
+ ? `${left}이(가) ${right}보다 큰 상태`
+ : '비교 대상보다 큰 상태';
+ case 'LT':
+ return left && right
+ ? `${left}이(가) ${right}보다 작은 상태`
+ : '비교 대상보다 작은 상태';
+ case 'GTE':
+ return left && right
+ ? `${left}이(가) ${right} 이상인 상태`
+ : '기준선 이상 유지';
+ case 'LTE':
+ return left && right
+ ? `${left}이(가) ${right} 이하인 상태`
+ : '기준선 이하 유지';
+ case 'SLOPE_UP':
+ return left
+ ? `${left}이(가) 최근 ${sp}봉 구간에서 상승 추세인 상태`
+ : `최근 ${sp}봉 구간 상승 추세`;
+ case 'SLOPE_DOWN':
+ return left
+ ? `${left}이(가) 최근 ${sp}봉 구간에서 하락 추세인 상태`
+ : `최근 ${sp}봉 구간 하락 추세`;
+ case 'ABOVE_CLOUD':
+ return '종가가 일목균형표 구름대 위에 있는 상태';
+ case 'BELOW_CLOUD':
+ return '종가가 일목균형표 구름대 아래에 있는 상태';
+ case 'CLOUD_BREAK_UP':
+ return '종가가 구름 상단을 상향 돌파하는 순간';
+ case 'CLOUD_BREAK_DOWN':
+ return '종가가 구름 하단을 하향 이탈하는 순간';
+ default:
+ if (left && right) {
+ return `${left} — ${condVerb(condType)} — ${right}`;
+ }
+ return condVerb(condType);
+ }
+}
+
+function buildTitle(left: string, right: string, condType: string): string {
+ const verb = condVerb(condType);
+ if (condType === 'SLOPE_UP' || condType === 'SLOPE_DOWN') {
+ return left ? `${left} ${verb}` : verb;
+ }
+ if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN'].includes(condType)) {
+ return CONDITION_LABEL[condType] ?? verb;
+ }
+ if (left && right) {
+ return `${left} ${verb} ${right}`;
+ }
+ if (left) return `${left} ${verb}`;
+ if (right) return `${verb} ${right}`;
+ return verb;
+}
+
+/** 프리셋 patch + DEF 기준으로 목록 제목·설명을 구체화 */
+export function enrichConditionPresetLabels(
+ preset: PresetLike,
+ indicatorType: string,
+ def: DefType,
+ signalType: 'buy' | 'sell',
+): PresetLike {
+ const patch = preset.patch;
+ const condType = patch.conditionType ?? 'GT';
+
+ if (['ABOVE_CLOUD', 'BELOW_CLOUD', 'CLOUD_BREAK_UP', 'CLOUD_BREAK_DOWN', 'IN_CLOUD'].includes(condType)) {
+ const label = CONDITION_LABEL[condType] ?? preset.label;
+ const description = buildDescription('', '', condType);
+ return { ...preset, label, description };
+ }
+
+ const mockCond: ConditionDSL = {
+ indicatorType,
+ conditionType: condType,
+ leftField: patch.leftField,
+ rightField: patch.rightField,
+ };
+
+ const left = patch.leftField && patch.leftField !== 'NONE'
+ ? resolveFieldLabel(indicatorType, patch.leftField, def, signalType, mockCond)
+ : '';
+ const right = patch.rightField && patch.rightField !== 'NONE'
+ ? resolveFieldLabel(indicatorType, patch.rightField, def, signalType, mockCond)
+ : '';
+
+ if (!left && !right) {
+ return preset;
+ }
+
+ return {
+ ...preset,
+ label: buildTitle(left, right, condType),
+ description: buildDescription(left, right, condType, patch.slopePeriod),
+ };
+}
diff --git a/frontend/src/utils/conditionPresets.ts b/frontend/src/utils/conditionPresets.ts
index 1c4d494..fd5ef61 100644
--- a/frontend/src/utils/conditionPresets.ts
+++ b/frontend/src/utils/conditionPresets.ts
@@ -11,6 +11,10 @@ import {
THRESHOLD_HL_UNDER,
} from './thresholdSymbols';
import { nhPriorField, nlPriorField } from './priceExtremeIndicators';
+import { enrichConditionPresetLabels } from './conditionPresetLabels';
+import { getPathBreakoutPresets, isCompoundConditionPreset } from './conditionPathPresets';
+import { genId } from './strategyEditorShared';
+import type { LogicNode } from './strategyTypes';
export type ConditionPresetGraph =
| 'cross_up'
@@ -21,7 +25,12 @@ export type ConditionPresetGraph =
| 'slope_down'
| 'dual_cross_up'
| 'dual_cross_down'
- | 'between';
+ | 'between'
+ | 'path_first_cross'
+ | 'path_pullback_recross'
+ | 'path_deep_recovery';
+
+export type ConditionPresetCategory = 'basic' | 'path';
export type ConditionPreset = {
id: string;
@@ -29,6 +38,9 @@ export type ConditionPreset = {
description: string;
graph: ConditionPresetGraph;
patch: Partial;
+ /** 복합(AND) — 여러 조건을 한 번에 구성 */
+ compound?: Partial[];
+ category?: ConditionPresetCategory;
tags?: ('buy' | 'sell')[];
};
@@ -75,7 +87,7 @@ function compare(
function slope(up: boolean, left: string, tags?: ('buy' | 'sell')[]): ConditionPreset {
return {
- id: up ? 'slope_up' : 'slope_down',
+ id: up ? `slope_up_${left}` : `slope_down_${left}`,
label: up ? '상승 기울기' : '하락 기울기',
description: up ? '최근 구간 상승 추세' : '최근 구간 하락 추세',
graph: up ? 'slope_up' : 'slope_down',
@@ -269,15 +281,25 @@ export function getConditionPresetsForIndicator(
signalType: 'buy' | 'sell',
): ConditionPreset[] {
const builder = PRESET_BUILDERS[indicatorType];
- if (!builder) return [];
- const all = builder(def, signalType);
+ if (!builder) return getPathBreakoutPresets(indicatorType, def, signalType);
+ const basic = builder(def, signalType);
+ const path = getPathBreakoutPresets(indicatorType, def, signalType);
+ const all = [...basic, ...path];
const seen = new Set();
- return all.filter(p => {
- const key = `${p.patch.conditionType}:${p.patch.leftField}:${p.patch.rightField}:${p.label}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
+ return all
+ .filter(p => {
+ if (isCompoundConditionPreset(p)) {
+ return !seen.has(p.id);
+ }
+ const key = `${p.patch.conditionType}:${p.patch.leftField}:${p.patch.rightField}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ })
+ .map(p => {
+ if (isCompoundConditionPreset(p)) return p;
+ return enrichConditionPresetLabels(p, indicatorType, def, signalType);
+ });
}
export function applyConditionPreset(
@@ -303,3 +325,32 @@ export function applyConditionPreset(
const inherited = initConditionPeriodsInherit(cond.indicatorType, merged, def);
return applyCondTypeDefaults(inherited, condType, def);
}
+
+/** 복합 프리셋 → AND 노드 (동일 id 유지) */
+export function applyCompoundConditionPreset(
+ nodeId: string,
+ cond: ConditionDSL,
+ preset: ConditionPreset,
+ def: DefType,
+): LogicNode {
+ const patches = preset.compound ?? [];
+ const children: LogicNode[] = patches.map(patch => {
+ const single: ConditionPreset = {
+ id: `${preset.id}_part`,
+ label: '',
+ description: '',
+ graph: preset.graph,
+ patch,
+ };
+ return {
+ id: genId(),
+ type: 'CONDITION',
+ condition: applyConditionPreset(cond, single, def),
+ };
+ });
+ return {
+ id: nodeId,
+ type: 'AND',
+ children,
+ };
+}