위젯 종료버튼 겹침문제 수정

This commit is contained in:
Macbook
2026-06-15 20:59:07 +09:00
parent 8b45362a00
commit 44f6ddfa0f
16 changed files with 263 additions and 53 deletions
@@ -14,7 +14,7 @@ import java.util.Map;
@RequestMapping("/admin")
@RequiredArgsConstructor
public class AdminController {
private final AdminService adminService;
private final RolePermissionService rolePermissionService;
+2 -1
View File
@@ -20,7 +20,8 @@
"minWidth": 960,
"minHeight": 640,
"center": true,
"visible": true
"visible": true,
"acceptFirstMouse": true
}
],
"security": {
+1
View File
@@ -41,6 +41,7 @@ export async function openWidgetPickerWindow(params: {
center: true,
decorations: true,
alwaysOnTop: true,
acceptFirstMouse: true,
backgroundColor: '#1a1b26',
});
+1
View File
@@ -37,6 +37,7 @@ export async function openWidgetWindow(instance: FloatingWidgetInstance): Promis
minHeight: 240,
decorations: true,
resizable: true,
acceptFirstMouse: true,
center: false,
x: instance.position.x,
y: instance.position.y,
@@ -102,7 +102,11 @@ export const TopMenuBarNavGroups = memo(function TopMenuBarNavGroups({
className={`tmb-nav-btn tmb-nav-group-btn${isActive ? ' tmb-nav-btn--active' : ''}${isOpen ? ' tmb-nav-group-btn--open' : ''}`}
aria-expanded={isOpen}
aria-haspopup="menu"
onClick={() => setOpenGroupId(prev => (prev === group.id ? null : group.id))}
onPointerDown={e => {
e.preventDefault();
e.stopPropagation();
setOpenGroupId(prev => (prev === group.id ? null : group.id));
}}
>
<span className="tmb-nav-icon">{group.icon}</span>
<span className="tmb-nav-label">{group.label}</span>
@@ -37,6 +37,8 @@ import {
ensurePaperChartOverlays,
} from '../../utils/strategyToChartIndicators';
import type { ChartOverlayToggleKey } from '../../utils/chartOverlayVisibility';
import { WIDGET_CHART_TF_OPTIONS } from '../../utils/chartWidgetTimeframes';
import WidgetTimeframePicker from '../../widgets/panels/WidgetTimeframePicker';
interface Props {
symbol: string;
@@ -61,6 +63,9 @@ interface Props {
focusTimeSec?: number | null;
/** 하단 확대·축소·좌우 이동 툴바 (위젯 등에서는 false) */
showHoverToolbar?: boolean;
/** 종목 우측 시간봉 선택 (위젯 전략 차트) */
timeframeOptions?: Timeframe[];
onTimeframeChange?: (tf: Timeframe) => void;
}
const noop = () => {};
@@ -117,6 +122,8 @@ const BacktestAnalysisChart: React.FC<Props> = ({
reportDisabled = false,
focusTimeSec = null,
showHoverToolbar = true,
timeframeOptions,
onTimeframeChange,
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const { defaults: appDefaults } = useAppSettings();
@@ -414,7 +421,16 @@ const BacktestAnalysisChart: React.FC<Props> = ({
<div className="btd-analysis-toolbar">
<div className="btd-analysis-toolbar-left">
<span className="btd-analysis-select btd-analysis-select--static">{koName}</span>
<span className="btd-analysis-select btd-analysis-select--static">{tfKo}</span>
{onTimeframeChange ? (
<WidgetTimeframePicker
options={timeframeOptions ?? WIDGET_CHART_TF_OPTIONS}
value={chartTimeframe}
onChange={onTimeframeChange}
className="btd-analysis-tf-row"
/>
) : (
<span className="btd-analysis-select btd-analysis-select--static">{tfKo}</span>
)}
<span className="btd-analysis-select btd-analysis-select--static">{chartTypeKo}</span>
</div>
<div className="btd-analysis-toolbar-right">
@@ -9,9 +9,10 @@ import {
} from '../../utils/backendApi';
import { loadAnalysisCandles } from '../../utils/analysisChartData';
import { normalizeChartTimeframe } from '../../utils/backtestUiUtils';
import { mergeWidgetTimeframeOptions } from '../../utils/chartWidgetTimeframes';
import { resolveStrategyPrimaryTimeframe } from '../../utils/strategyToChartIndicators';
import { resolveEvaluationFromLoadedBars } from '../../utils/backtestWarmup';
import type { OHLCVBar, Theme } from '../../types';
import type { OHLCVBar, Theme, Timeframe } from '../../types';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import {
DEFAULT_PAPER_OVERLAY_VISIBILITY,
@@ -28,6 +29,8 @@ interface Props {
theme?: Theme;
/** 위젯 임베드 시 하단 줌/패닝 툴바 숨김 */
showHoverToolbar?: boolean;
/** 위젯 — 종목 우측 시간봉 선택 */
enableTimeframePicker?: boolean;
}
/**
@@ -35,12 +38,16 @@ interface Props {
* 좌측(과거) 스크롤 시 추가 캔들·보조지표 로드 후 전체 구간 시그널 재계산.
*/
const PaperAnalysisChart: React.FC<Props> = ({
market, strategyId, theme = 'dark', showHoverToolbar = true,
market,
strategyId,
theme = 'dark',
showHoverToolbar = true,
enableTimeframePicker = false,
}) => {
const { getParams } = useIndicatorSettings();
const [signals, setSignals] = useState<BacktestSignal[]>([]);
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
const [timeframe, setTimeframe] = useState('1h');
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
const [running, setRunning] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -53,8 +60,15 @@ const PaperAnalysisChart: React.FC<Props> = ({
const historyDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const strategyRef = useRef(strategy);
const strategyIdRef = useRef(strategyId);
const timeframeRef = useRef(timeframe);
strategyRef.current = strategy;
strategyIdRef.current = strategyId;
timeframeRef.current = timeframe;
const timeframeOptions = useMemo(
() => mergeWidgetTimeframeOptions(strategy),
[strategy],
);
useEffect(() => {
if (!strategyId) {
@@ -71,6 +85,11 @@ const PaperAnalysisChart: React.FC<Props> = ({
return () => { cancelled = true; };
}, [strategyId]);
useEffect(() => {
if (!strategy) return;
setTimeframe(normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy)));
}, [strategyId, strategy?.id]);
const runSignalsForBars = useCallback(async (
bars: OHLCVBar[],
tf: string,
@@ -139,8 +158,7 @@ const PaperAnalysisChart: React.FC<Props> = ({
return;
}
let cancelled = false;
const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strategy));
setTimeframe(tf);
const tf = normalizeChartTimeframe(timeframe);
setError(null);
void (async () => {
@@ -162,7 +180,7 @@ const PaperAnalysisChart: React.FC<Props> = ({
cancelled = true;
++backtestGenRef.current;
};
}, [market, strategyId, strategy, toTimeSec, runSignalsForBars]);
}, [market, strategyId, strategy, timeframe, toTimeSec, runSignalsForBars]);
const handleToggleOverlay = useCallback((key: PaperOverlayToggleKey) => {
setOverlayVisibility(prev => ({ ...prev, [key]: !prev[key] }));
@@ -172,7 +190,7 @@ const PaperAnalysisChart: React.FC<Props> = ({
const strat = strategyRef.current;
const sid = strategyIdRef.current;
if (!strat || !sid) return;
const tf = normalizeChartTimeframe(resolveStrategyPrimaryTimeframe(strat));
const tf = normalizeChartTimeframe(timeframeRef.current);
if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current);
historyDebounceRef.current = setTimeout(() => {
historyDebounceRef.current = null;
@@ -180,6 +198,10 @@ const PaperAnalysisChart: React.FC<Props> = ({
}, HISTORY_BACKTEST_DEBOUNCE_MS);
}, [runSignalsForBars]);
const handleTimeframeChange = useCallback((tf: Timeframe) => {
setTimeframe(tf);
}, []);
useEffect(() => () => {
if (historyDebounceRef.current) clearTimeout(historyDebounceRef.current);
}, []);
@@ -195,6 +217,7 @@ const PaperAnalysisChart: React.FC<Props> = ({
}
const statusBusy = running || historyLoading;
const chartTimeframe = normalizeChartTimeframe(timeframe);
return (
<div className="ptd-analysis-chart ptd-analysis-chart--backtest">
@@ -208,7 +231,7 @@ const PaperAnalysisChart: React.FC<Props> = ({
)}
<BacktestAnalysisChart
symbol={market}
timeframe={timeframe}
timeframe={chartTimeframe}
toTimeSec={toTimeSec}
barCount={BAR_COUNT}
signals={signals}
@@ -219,6 +242,8 @@ const PaperAnalysisChart: React.FC<Props> = ({
overlayVisibility={overlayVisibility}
onToggleOverlay={handleToggleOverlay}
showHoverToolbar={showHoverToolbar}
timeframeOptions={enableTimeframePicker ? timeframeOptions : undefined}
onTimeframeChange={enableTimeframePicker ? handleTimeframeChange : undefined}
/>
</div>
);
@@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts';
import type { Theme, Timeframe } from '../../types';
import { fetchUpbitCandles } from '../../utils/upbitApi';
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '10m', '15m', '1h', '4h', '1D'];
import { WIDGET_CHART_TF_OPTIONS } from '../../utils/chartWidgetTimeframes';
interface Props {
market: string;
@@ -11,6 +10,10 @@ interface Props {
wsLabel?: string;
timeframe?: Timeframe;
onTimeframeChange?: (tf: Timeframe) => void;
/** 위젯 헤더에 시간봉이 있을 때 내부 행 숨김 */
hideTimeframeRow?: boolean;
/** 위젯 임베드 시 상단 타이틀·배지 숨김 */
hideChartHead?: boolean;
}
function readChartTheme(container: HTMLElement | null) {
@@ -33,6 +36,8 @@ const PaperMiniChart: React.FC<Props> = ({
wsLabel = 'WebSocket <100ms',
timeframe: timeframeProp,
onTimeframeChange,
hideTimeframeRow = false,
hideChartHead = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
@@ -159,24 +164,28 @@ const PaperMiniChart: React.FC<Props> = ({
return (
<div className="ptd-chart-wrap">
<div className="ptd-chart-head">
<span className="ptd-chart-title"> </span>
<span className="ptd-ws-badge">{wsLabel} </span>
</div>
{!hideChartHead && (
<div className="ptd-chart-head">
<span className="ptd-chart-title"> </span>
<span className="ptd-ws-badge">{wsLabel} </span>
</div>
)}
<div ref={containerRef} className="ptd-chart-canvas" />
{loading && <div className="ptd-chart-loading"> </div>}
<div className="ptd-tf-row">
{TF_OPTIONS.map(tf => (
<button
key={tf}
type="button"
className={`ptd-tf-btn${timeframe === tf ? ' ptd-tf-btn--active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
{!hideTimeframeRow && (
<div className="ptd-tf-row">
{WIDGET_CHART_TF_OPTIONS.map(tf => (
<button
key={tf}
type="button"
className={`ptd-tf-btn${timeframe === tf ? ' ptd-tf-btn--active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
)}
</div>
);
};
@@ -47,7 +47,7 @@ const WidgetSlotView: React.FC<Props> = ({
</div>
</header>
)}
<div className="wd-slot-body">
<div className={['wd-slot-body', Host && onClearWidget ? 'wd-slot-body--has-clear' : ''].filter(Boolean).join(' ')}>
{Host ? (
<Host config={slot.config} />
) : (
+14
View File
@@ -1507,6 +1507,12 @@
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.btd-analysis-toolbar-right {
flex-shrink: 1;
justify-content: flex-end;
}
.btd-analysis-select--static {
@@ -1519,6 +1525,14 @@
background: color-mix(in srgb, var(--btd-bg) 60%, transparent);
}
.btd-analysis-tf-row {
flex: 1;
min-width: 0;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.btd-analysis-tool {
width: 28px;
height: 28px;
+67 -3
View File
@@ -164,16 +164,53 @@
position: absolute;
top: 6px;
right: 6px;
z-index: 3;
z-index: 10;
width: 22px;
height: 22px;
border: 1px solid var(--wd-slot-border);
border-radius: 4px;
background: color-mix(in srgb, var(--bg2) 88%, transparent);
background: color-mix(in srgb, var(--bg2) 92%, transparent);
color: var(--se-text-muted);
font-size: 0.65rem;
cursor: pointer;
opacity: 0.55;
opacity: 0.85;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.25);
}
/* 위젯 ✕ 버튼 — 차트·패널 헤더 텍스트와 겹침 방지 */
.wd-slot-body--has-clear .wd-candle-chart-header,
.wd-slot-body--has-clear .btd-analysis-toolbar,
.wd-slot-body--has-clear .wd-panel-header,
.wd-slot-body--has-clear .wd-chart-symbol-tf-bar {
padding-right: 36px;
box-sizing: border-box;
}
.wd-slot-body--has-clear .btd-analysis-toolbar-left {
flex: 1;
min-width: 0;
overflow: hidden;
}
.wd-slot-body--has-clear .btd-analysis-toolbar-right {
flex-shrink: 1;
min-width: 0;
max-width: 42%;
overflow: hidden;
}
.wd-slot-body--has-clear .btd-analysis-count {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.wd-slot-body--has-clear .wd-candle-chart-header-actions {
flex-shrink: 1;
min-width: 0;
flex-wrap: wrap;
justify-content: flex-end;
max-width: min(100%, 220px);
}
.wd-slot-overlay-clear:hover {
@@ -447,6 +484,33 @@
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
}
.wd-chart-symbol-tf-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 4px 10px 6px;
border-bottom: 1px solid color-mix(in srgb, var(--se-border, var(--border)) 55%, transparent);
}
.wd-chart-symbol-label {
font-size: 0.72rem;
font-weight: 600;
color: var(--se-text, var(--text));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: min(220px, 42%);
flex-shrink: 0;
}
.wd-chart-symbol-tf-bar .wd-candle-chart-tf-row {
flex: 1;
justify-content: flex-start;
}
.wd-candle-chart-tf {
@@ -0,0 +1,23 @@
import type { Timeframe } from '../types';
import type { StrategyDto } from './backendApi';
import { resolveStrategyTimeframes } from './strategyToChartIndicators';
/** 위젯 차트 — 종목 우측 시간봉 선택 기본 옵션 */
export const WIDGET_CHART_TF_OPTIONS: Timeframe[] = [
'1m', '3m', '5m', '10m', '15m', '30m', '1h', '4h', '1D',
];
const TF_ORDER = new Map<Timeframe, number>(
WIDGET_CHART_TF_OPTIONS.map((tf, i) => [tf, i]),
);
/** 전략 DSL 시간봉 + 위젯 기본 옵션 병합 (중복 제거, 표준 순서) */
export function mergeWidgetTimeframeOptions(strategy?: StrategyDto): Timeframe[] {
const merged = new Set<Timeframe>([...WIDGET_CHART_TF_OPTIONS, ...resolveStrategyTimeframes(strategy)]);
return [...merged].sort((a, b) => {
const ai = TF_ORDER.get(a) ?? 999;
const bi = TF_ORDER.get(b) ?? 999;
if (ai !== bi) return ai - bi;
return a.localeCompare(b);
});
}
@@ -22,8 +22,9 @@ import type { ChartManager } from '../utils/ChartManager';
import {
buildVirtualTradingChartIndicators,
resolveStrategyPrimaryTimeframe,
resolveStrategyTimeframes,
} from '../utils/strategyToChartIndicators';
import { mergeWidgetTimeframeOptions } from '../utils/chartWidgetTimeframes';
import WidgetTimeframePicker from './panels/WidgetTimeframePicker';
import { timeframeToCandleType } from '../utils/chartCandleType';
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
import { getKoreanName } from '../utils/marketNameCache';
@@ -76,7 +77,7 @@ const WidgetCandleChartPanel: React.FC<Props> = ({
chartRealtimeSource = 'BACKEND_STOMP',
}) => {
const { getParams, getVisualConfig } = useIndicatorSettings();
const tfOptions = useMemo(() => resolveStrategyTimeframes(strategy), [strategy]);
const tfOptions = useMemo(() => mergeWidgetTimeframeOptions(strategy), [strategy]);
const [timeframe, setTimeframe] = useState<Timeframe>(
() => resolveStrategyPrimaryTimeframe(strategy),
);
@@ -402,20 +403,11 @@ const WidgetCandleChartPanel: React.FC<Props> = ({
<span className="wd-candle-chart-market-sym">{market}</span>
</button>
{tfOptions.length > 1 && (
<div className="wd-candle-chart-tf-row">
{tfOptions.map(tf => (
<button
key={tf}
type="button"
className={`wd-candle-chart-tf${timeframe === tf ? ' wd-candle-chart-tf--on' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
)}
<WidgetTimeframePicker
options={tfOptions}
value={timeframe}
onChange={setTimeframe}
/>
<div className="wd-candle-chart-header-actions">
<div className="wd-candle-chart-toolbar-toggles" role="group" aria-label="차트 툴바 표시">
@@ -1,19 +1,39 @@
import React from 'react';
import React, { useState } from 'react';
import PaperMiniChart from '../../components/paper/PaperMiniChart';
import { useWidgetDashboard } from '../WidgetDashboardContext';
import WidgetPanelHeader from '../panels/WidgetPanelHeader';
import WidgetTimeframePicker from '../panels/WidgetTimeframePicker';
import { WIDGET_CHART_TF_OPTIONS } from '../../utils/chartWidgetTimeframes';
import { resolveName } from '../widgetMarketUtils';
import type { Timeframe } from '../../types';
const MiniChartWidgetHost: React.FC = () => {
const { selectedMarket, theme, tickers } = useWidgetDashboard();
const ticker = tickers.get(selectedMarket);
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
return (
<div className="wd-widget-host wd-widget-host--panel wd-widget-host--mini-chart">
<WidgetPanelHeader title="미니 차트" />
<div className="wd-panel-subline">{resolveName(selectedMarket, ticker)} · {selectedMarket}</div>
<div className="wd-chart-symbol-tf-bar">
<span className="wd-chart-symbol-label">
{resolveName(selectedMarket, ticker)} · {selectedMarket}
</span>
<WidgetTimeframePicker
options={WIDGET_CHART_TF_OPTIONS}
value={timeframe}
onChange={setTimeframe}
/>
</div>
<div className="wd-mini-chart-wrap">
<PaperMiniChart market={selectedMarket} theme={theme} />
<PaperMiniChart
market={selectedMarket}
theme={theme}
timeframe={timeframe}
onTimeframeChange={setTimeframe}
hideChartHead
hideTimeframeRow
/>
</div>
</div>
);
@@ -23,6 +23,7 @@ const StrategyChartWidgetHost: React.FC<{ config?: Record<string, unknown> }> =
strategyId={strategyId}
theme={theme}
showHoverToolbar={false}
enableTimeframePicker
/>
</div>
);
@@ -0,0 +1,39 @@
import React from 'react';
import type { Timeframe } from '../../types';
interface Props {
options: Timeframe[];
value: Timeframe;
onChange: (tf: Timeframe) => void;
className?: string;
'aria-label'?: string;
}
/** 위젯 차트 — 종목 우측 시간봉 버튼 그룹 */
const WidgetTimeframePicker: React.FC<Props> = ({
options,
value,
onChange,
className = '',
'aria-label': ariaLabel = '시간봉',
}) => (
<div
className={`wd-candle-chart-tf-row${className ? ` ${className}` : ''}`}
role="group"
aria-label={ariaLabel}
>
{options.map(tf => (
<button
key={tf}
type="button"
className={`wd-candle-chart-tf${value === tf ? ' wd-candle-chart-tf--on' : ''}`}
aria-pressed={value === tf}
onClick={() => onChange(tf)}
>
{tf}
</button>
))}
</div>
);
export default WidgetTimeframePicker;