차트 세로크로스헤어 교차점 on off

This commit is contained in:
Macbook
2026-06-22 12:17:03 +09:00
parent 4a3538fb35
commit a1812922a0
13 changed files with 179 additions and 8 deletions
@@ -52,6 +52,8 @@ const NULL_SETTINGS_PAGE_PROPS: SettingsPageChartBindings = {
onChartLegendOptionsChange: () => {},
chartCrosshairInfoVisible: true,
onChartCrosshairInfoVisible: () => {},
chartCrosshairMarkersVisible: true,
onChartCrosshairMarkersVisible: () => {},
chartHoverToolbarVisible: true,
onChartHoverToolbarVisible: () => {},
chartPaneSeparator: undefined,
+16
View File
@@ -192,6 +192,8 @@ export interface SettingsPageChartBindings {
onChartLegendOptionsChange: (patch: Partial<import('../types/chartLegend').ChartLegendVisibility>) => void;
chartCrosshairInfoVisible: boolean;
onChartCrosshairInfoVisible: (v: boolean) => void;
chartCrosshairMarkersVisible: boolean;
onChartCrosshairMarkersVisible: (v: boolean) => void;
chartHoverToolbarVisible: boolean;
onChartHoverToolbarVisible: (v: boolean) => void;
chartPaneSeparator: import('../types/chartPaneSeparator').ChartPaneSeparatorOptions | undefined;
@@ -610,6 +612,7 @@ export function useChartWorkspace({
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
const chartLegendOptions = resolveChartLegendOptions(appDefaults.chartLegendOptions);
const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true;
const chartCrosshairMarkersVisible = appDefaults.chartCrosshairMarkersVisible ?? true;
const chartHoverToolbarVisible = appDefaults.chartHoverToolbarVisible ?? true;
const chartPaneSeparator = resolveChartPaneSeparatorOptions(appDefaults.chartPaneSeparator);
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
@@ -1010,6 +1013,12 @@ export function useChartWorkspace({
});
}, [appSettingsLoaded, chartCandleAreaPriceLabels, chartSeriesPriceLabels]);
useEffect(() => {
if (!appSettingsLoaded) return;
managerRef.current?.setCrosshairMarkersVisible(chartCrosshairMarkersVisible);
slotRefs.current.forEach(slot => slot?.setCrosshairMarkersVisible(chartCrosshairMarkersVisible));
}, [appSettingsLoaded, chartCrosshairMarkersVisible]);
useEffect(() => {
if (!appSettingsLoaded) return;
const wrapper = document.querySelector('.chart-wrapper') as HTMLElement | null;
@@ -1903,6 +1912,12 @@ export function useChartWorkspace({
},
chartCrosshairInfoVisible,
onChartCrosshairInfoVisible: v => saveAppDef({ chartCrosshairInfoVisible: v }),
chartCrosshairMarkersVisible,
onChartCrosshairMarkersVisible: v => {
saveAppDef({ chartCrosshairMarkersVisible: v });
managerRef.current?.setCrosshairMarkersVisible(v);
slotRefs.current.forEach(slot => slot?.setCrosshairMarkersVisible(v));
},
chartHoverToolbarVisible,
onChartHoverToolbarVisible: v => saveAppDef({ chartHoverToolbarVisible: v }),
chartPaneSeparator,
@@ -1929,6 +1944,7 @@ export function useChartWorkspace({
handleAddIndicator, handleRemoveByType, handleIndicatorListOrderChange,
chartCandleAreaPriceLabels, chartSeriesPriceLabels, chartVolumeVisible,
chartLiveReceiveHighlight, chartLegendOptions, chartCrosshairInfoVisible,
chartCrosshairMarkersVisible,
chartHoverToolbarVisible, chartPaneSeparator, saveAppDef, clearLiveMarkers,
refreshPaperAccount, handleTimezoneChange, handleChartTimeFormatChange,
handleTradeAlertTimeFormatChange, displayTimezone, chartTimeFormat, tradeAlertTimeFormat,
+4
View File
@@ -137,6 +137,7 @@ export interface ChartSlotHandle {
setSeriesPriceLabelsEnabled: (enabled: boolean) => void;
setCandleAreaPriceLabelsEnabled: (enabled: boolean) => void;
setIndicatorAreaPriceLabelsEnabled: (enabled: boolean) => void;
setCrosshairMarkersVisible: (enabled: boolean) => void;
/** 거래량 pane on/off */
setVolumeVisible: (visible: boolean) => void;
/** 9·20일 신고가·신저가 표시 */
@@ -382,6 +383,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
setIndicatorAreaPriceLabelsEnabled: (enabled: boolean) => {
managerRef.current?.setIndicatorAreaPriceLabelsEnabled(enabled);
},
setCrosshairMarkersVisible: (enabled: boolean) => {
managerRef.current?.setCrosshairMarkersVisible(enabled);
},
setVolumeVisible: (visible: boolean) => {
managerRef.current?.setVolumeVisible(visible);
},
+23
View File
@@ -129,6 +129,8 @@ interface SettingsPageProps {
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
chartCrosshairInfoVisible?: boolean;
onChartCrosshairInfoVisible?: (v: boolean) => void;
chartCrosshairMarkersVisible?: boolean;
onChartCrosshairMarkersVisible?: (v: boolean) => void;
chartHoverToolbarVisible?: boolean;
onChartHoverToolbarVisible?: (v: boolean) => void;
chartPaneSeparator?: ChartPaneSeparatorOptions;
@@ -1010,6 +1012,8 @@ interface ChartPanelProps {
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
chartCrosshairInfoVisible?: boolean;
onChartCrosshairInfoVisible?: (v: boolean) => void;
chartCrosshairMarkersVisible?: boolean;
onChartCrosshairMarkersVisible?: (v: boolean) => void;
chartHoverToolbarVisible?: boolean;
onChartHoverToolbarVisible?: (v: boolean) => void;
chartPaneSeparator?: ChartPaneSeparatorOptions;
@@ -1036,6 +1040,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
onChartLegendOptionsChange,
chartCrosshairInfoVisible = true,
onChartCrosshairInfoVisible,
chartCrosshairMarkersVisible = true,
onChartCrosshairMarkersVisible,
chartHoverToolbarVisible = true,
onChartHoverToolbarVisible,
chartPaneSeparator,
@@ -1248,6 +1254,19 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
<span className="stg-toggle-slider" />
</label>
</SettingRow>
<SettingRow
label="교차점 점 표시"
desc="켜면 세로 크로스헤어가 캔들·보조지표 선과 만나는 위치에 색 점이 표시됩니다. 끄면 크로스헤어 선만 표시됩니다."
>
<label className="stg-toggle">
<input
type="checkbox"
checked={chartCrosshairMarkersVisible}
onChange={e => onChartCrosshairMarkersVisible?.(e.target.checked)}
/>
<span className="stg-toggle-slider" />
</label>
</SettingRow>
</SettingSection>
)}
@@ -1955,6 +1974,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onChartLegendOptionsChange,
chartCrosshairInfoVisible = true,
onChartCrosshairInfoVisible,
chartCrosshairMarkersVisible = true,
onChartCrosshairMarkersVisible,
chartHoverToolbarVisible = true,
onChartHoverToolbarVisible,
chartPaneSeparator,
@@ -2077,6 +2098,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
onChartLegendOptionsChange={onChartLegendOptionsChange}
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
onChartCrosshairInfoVisible={onChartCrosshairInfoVisible}
chartCrosshairMarkersVisible={chartCrosshairMarkersVisible}
onChartCrosshairMarkersVisible={onChartCrosshairMarkersVisible}
chartHoverToolbarVisible={chartHoverToolbarVisible}
onChartHoverToolbarVisible={onChartHoverToolbarVisible}
chartPaneSeparator={chartPaneSeparator}
+13
View File
@@ -6,6 +6,7 @@ import { formatUpbitKrwPrice } from '../utils/safeFormat';
import { useChartTimeFormat } from '../utils/chartTimeFormat';
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
import { useAppSettings, resolveAppDefaults } from '../hooks/useAppSettings';
/** Upbit 실시간 — 초기 히스토리가 이 개수 미만이면 setData 하지 않음 (종목 전환 직후 sparse 봉 → 캔들 폭 비정상) */
const MIN_BARS_FOR_FULL_RELOAD = Math.min(50, Math.floor(DISPLAY_COUNT / 4));
@@ -165,6 +166,8 @@ interface TradingChartProps {
showCandleOverlayControls?: boolean;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
crosshairInfoVisible?: boolean;
/** 세로 크로스헤어와 선 그래프 교차점 점 표시 (미지정 시 전역 차트 설정) */
crosshairMarkersVisible?: boolean;
/** 외부 캔들 데이터 fetch 중 (실시간 차트 초기 로딩) */
dataLoading?: boolean;
/** chartPaintReady 변경 알림 (워크스페이스 전체 로딩 오버레이용) */
@@ -237,6 +240,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
showChartRightToolbar,
showCandleOverlayControls = false,
crosshairInfoVisible = true,
crosshairMarkersVisible,
dataLoading = false,
onChartPaintReadyChange,
defaultCandleOnly = false,
@@ -533,6 +537,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
mgr.setVolumeVisible(volumeVisible, wrapperH > 0 ? wrapperH : undefined);
}, [volumeVisible]);
const { settings } = useAppSettings();
const appDefaults = resolveAppDefaults(settings);
const resolvedCrosshairMarkers = crosshairMarkersVisible ?? appDefaults.chartCrosshairMarkersVisible ?? true;
const resolvedCandlePriceLabels = candleAreaPriceLabelsEnabled ?? seriesPriceLabelsEnabled ?? true;
const resolvedIndicatorPriceLabels = indicatorAreaPriceLabelsEnabled ?? seriesPriceLabelsEnabled ?? true;
@@ -544,6 +552,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
managerRef.current?.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
}, [resolvedIndicatorPriceLabels]);
useEffect(() => {
managerRef.current?.setCrosshairMarkersVisible(resolvedCrosshairMarkers);
}, [resolvedCrosshairMarkers]);
useEffect(() => {
if (!paneSeparatorOptions) return;
managerRef.current?.setPaneSeparatorOptions(paneSeparatorOptions);
@@ -1025,6 +1037,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
}
mgr.setCandleAreaPriceLabelsEnabled(resolvedCandlePriceLabels);
mgr.setIndicatorAreaPriceLabelsEnabled(resolvedIndicatorPriceLabels);
mgr.setCrosshairMarkersVisible(resolvedCrosshairMarkers);
if (paneSeparatorOptions) mgr.setPaneSeparatorOptions(paneSeparatorOptions);
if (auxIndicatorOnlyRef.current) mgr.applyAuxIndicatorOnlyLayout(true);
onManagerReady(mgr);
@@ -8,6 +8,8 @@ interface Props {
indicatorAreaEnabled: boolean;
onCandleAreaChange: (enabled: boolean) => void;
onIndicatorAreaChange: (enabled: boolean) => void;
crosshairMarkersEnabled?: boolean;
onCrosshairMarkersChange?: (enabled: boolean) => void;
className?: string;
}
@@ -16,8 +18,11 @@ const ChartPriceAxisLabelSettings: React.FC<Props> = ({
indicatorAreaEnabled,
onCandleAreaChange,
onIndicatorAreaChange,
crosshairMarkersEnabled = true,
onCrosshairMarkersChange,
className = '',
}) => (
<>
<section className={`seval-price-label-settings${className ? ` ${className}` : ''}`}>
<h3 className="seval-price-label-settings-title"> ·</h3>
<div className="seval-price-label-settings-body">
@@ -68,6 +73,30 @@ const ChartPriceAxisLabelSettings: React.FC<Props> = ({
</div>
</div>
</section>
{onCrosshairMarkersChange && (
<section className={`seval-price-label-settings seval-crosshair-marker-settings${className ? ` ${className}` : ''}`}>
<h3 className="seval-price-label-settings-title"> </h3>
<div className="seval-price-label-settings-body">
<div className="seval-price-label-row">
<div className="seval-price-label-row-text">
<span className="seval-price-label-row-title"> </span>
<span className="seval-price-label-row-desc">
· .
</span>
</div>
<label className="ism-toggle seval-price-label-toggle" title="크로스헤어 교차점 점 표시">
<input
type="checkbox"
checked={crosshairMarkersEnabled}
onChange={e => onCrosshairMarkersChange(e.target.checked)}
/>
<span className="ism-toggle-slider" />
</label>
</div>
</div>
</section>
)}
</>
);
export default ChartPriceAxisLabelSettings;
@@ -99,6 +99,7 @@ const StrategyEvaluationChartSettingsTab: React.FC<Props> = ({
const appDefaults = useMemo(() => resolveAppDefaults(settings), [settings]);
const candlePriceLabels = appDefaults.chartCandleAreaPriceLabels !== false;
const indicatorPriceLabels = appDefaults.chartSeriesPriceLabels !== false;
const crosshairMarkers = appDefaults.chartCrosshairMarkersVisible !== false;
const reloadFromDb = useCallback(() => {
const next: Record<string, IndicatorConfig> = {};
@@ -221,6 +222,8 @@ const StrategyEvaluationChartSettingsTab: React.FC<Props> = ({
indicatorAreaEnabled={indicatorPriceLabels}
onCandleAreaChange={v => saveAppSettings({ chartCandleAreaPriceLabels: v })}
onIndicatorAreaChange={v => saveAppSettings({ chartSeriesPriceLabels: v })}
crosshairMarkersEnabled={crosshairMarkers}
onCrosshairMarkersChange={v => saveAppSettings({ chartCrosshairMarkersVisible: v })}
/>
</div>
</div>
+1
View File
@@ -177,6 +177,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
chartVolumeVisible: s.chartVolumeVisible ?? true,
chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true,
chartCrosshairInfoVisible: s.chartCrosshairInfoVisible ?? true,
chartCrosshairMarkersVisible: s.chartCrosshairMarkersVisible ?? true,
chartHoverToolbarVisible: s.chartHoverToolbarVisible ?? true,
chartLegendOptions: resolveChartLegendOptions(
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
+75 -8
View File
@@ -347,6 +347,8 @@ export class ChartManager {
private _candleAreaPriceLabelsEnabled = true;
/** 하단 보조지표 pane(2+) — 우측 가격축 라벨·설명 */
private _indicatorAreaPriceLabelsEnabled = true;
/** 세로 크로스헤어와 선 그래프 교차점 점 표시 */
private _crosshairMarkersVisible = true;
/** 차트 하단 거래량 pane 표시 */
private _volumeVisible = true;
@@ -596,15 +598,23 @@ export class ChartManager {
borderUpColor: t.upColor, borderDownColor: t.downColor,
wickUpColor: t.upColor, wickDownColor: t.downColor,
priceFormat: MAIN_PRICE_FORMAT,
...this._crosshairMarkerSeriesOptions(),
});
case 'bar':
return this.chart.addSeries(BarSeries, { upColor: t.upColor, downColor: t.downColor, priceFormat: MAIN_PRICE_FORMAT });
return this.chart.addSeries(BarSeries, {
upColor: t.upColor, downColor: t.downColor, priceFormat: MAIN_PRICE_FORMAT,
...this._crosshairMarkerSeriesOptions(),
});
case 'line':
return this.chart.addSeries(LineSeries, { color: '#2962FF', lineWidth: 2, crosshairMarkerVisible: true, priceFormat: MAIN_PRICE_FORMAT });
return this.chart.addSeries(LineSeries, {
color: '#2962FF', lineWidth: 2, priceFormat: MAIN_PRICE_FORMAT,
...this._crosshairMarkerSeriesOptions(),
});
case 'area':
return this.chart.addSeries(AreaSeries, {
lineColor: '#2962FF', topColor: '#2962FF44', bottomColor: '#2962FF00', lineWidth: 2,
priceFormat: MAIN_PRICE_FORMAT,
...this._crosshairMarkerSeriesOptions(),
});
case 'baseline':
return this.chart.addSeries(BaselineSeries, {
@@ -612,6 +622,7 @@ export class ChartManager {
topLineColor: '#26a69a', topFillColor1: '#26a69a44', topFillColor2: '#26a69a00',
bottomLineColor: '#ef5350', bottomFillColor1: '#ef535000', bottomFillColor2: '#ef535044',
priceFormat: MAIN_PRICE_FORMAT,
...this._crosshairMarkerSeriesOptions(),
});
default:
return this.chart.addSeries(CandlestickSeries, {
@@ -619,6 +630,7 @@ export class ChartManager {
borderUpColor: t.upColor, borderDownColor: t.downColor,
wickUpColor: t.upColor, wickDownColor: t.downColor,
priceFormat: MAIN_PRICE_FORMAT,
...this._crosshairMarkerSeriesOptions(),
});
}
}
@@ -979,8 +991,7 @@ export class ChartManager {
lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false,
lastValueVisible: showPriceLabel,
crosshairMarkerVisible: true,
crosshairMarkerRadius: plotId === 'plot0' ? 4 : 3,
...this._crosshairMarkerSeriesOptions(plotId === 'plot0' ? 4 : 3),
title: showSeriesTitle ? this.seriesTitleForPlot(enriched, plotDef) : '',
visible: isPlotVisible,
priceScaleId: obvScaleId,
@@ -1046,7 +1057,7 @@ export class ChartManager {
lineWidth: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1) as 1 | 2 | 3 | 4,
lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false,
crosshairMarkerVisible: true,
...this._crosshairMarkerSeriesOptions(),
lastValueVisible: showPriceLabel,
title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '',
visible: isPlotVisible,
@@ -1100,6 +1111,7 @@ export class ChartManager {
lastValueVisible: showPriceLabel,
title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '',
visible: isPlotVisible,
...this._crosshairMarkerSeriesOptions(),
...(indicatorPriceFormat ? { priceFormat: indicatorPriceFormat } : {}),
}, pane);
series.setData(filtered.map(p => ({ time: p.time as Time, value: p.value })));
@@ -1277,7 +1289,7 @@ export class ChartManager {
lineWidth: Math.max(1, plotDef.lineWidth ?? regPlot?.lineWidth ?? 1) as 1 | 2 | 3 | 4,
lineStyle: plotSeriesLineStyle(plotDef.lineStyle),
priceLineVisible: false,
crosshairMarkerVisible: true,
...this._crosshairMarkerSeriesOptions(),
lastValueVisible: showPriceLabel,
title: showSeriesTitle ? this.seriesTitleForPlot(config, plotDef) : '',
visible: isPlotVisible,
@@ -2089,6 +2101,7 @@ export class ChartManager {
lastValueVisible: showPriceLabel && lineVisible,
title: showPriceLabel && lineVisible ? info.title : '',
visible: lineVisible,
...this._crosshairMarkerSeriesOptions(),
}, pane);
series.setData(seriesData);
seriesList.push(series);
@@ -3544,6 +3557,57 @@ export class ChartManager {
return this._indicatorAreaPriceLabelsEnabled;
}
setCrosshairMarkersVisible(enabled: boolean): void {
if (this._crosshairMarkersVisible === enabled) return;
this._crosshairMarkersVisible = enabled;
this._refreshCrosshairMarkerStyles();
}
getCrosshairMarkersVisible(): boolean {
return this._crosshairMarkersVisible;
}
private _crosshairMarkerSeriesOptions(radius?: number): {
crosshairMarkerVisible: boolean;
crosshairMarkerRadius?: number;
} {
return {
crosshairMarkerVisible: this._crosshairMarkersVisible,
...(radius != null ? { crosshairMarkerRadius: radius } : {}),
};
}
private _refreshCrosshairMarkerStyles(): void {
const visible = this._crosshairMarkersVisible;
if (this.mainSeries) {
try {
this.mainSeries.applyOptions({ crosshairMarkerVisible: visible } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
}
for (const s of this.compareSeriesMap.values()) {
try {
s.applyOptions({ crosshairMarkerVisible: visible } as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
}
for (const entry of this.indicators.values()) {
entry.seriesList.forEach((series, i) => {
if (entry.seriesMeta[i]?.isHistogram) return;
const pid = entry.seriesMeta[i]?.plotId;
const radius = entry.type === 'OBV' && pid === 'plot0'
? 4
: entry.type === 'OBV' && pid === 'plot1'
? 3
: undefined;
try {
series.applyOptions({
crosshairMarkerVisible: visible,
...(radius != null ? { crosshairMarkerRadius: radius } : {}),
} as Parameters<ISeriesApi<SeriesType>['applyOptions']>[0]);
} catch { /* ok */ }
});
}
}
private _refreshPriceLabelStyles(): void {
for (const entry of this.indicators.values()) {
this.applyIndicatorStyle(entry.config);
@@ -3649,7 +3713,7 @@ export class ChartManager {
: Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1)) as 1 | 2 | 3 | 4)
: Math.max(1, plot.lineWidth ?? regPlot?.lineWidth ?? 1);
opts['lineStyle'] = plotSeriesLineStyle(plot.lineStyle);
opts['crosshairMarkerVisible'] = true;
opts['crosshairMarkerVisible'] = this._crosshairMarkersVisible;
let plotAllows = true;
if (entry.type === 'IchimokuCloud') {
plotAllows = entry.seriesMeta[i]?.plotId !== 'plot2';
@@ -4980,7 +5044,10 @@ export class ChartManager {
// ─── Compare overlay ──────────────────────────────────────────────────────
addCompareSeries(symbol: string, bars: OHLCVBar[], color: string): void {
if (this.compareSeriesMap.has(symbol)) return;
const s = this.chart.addSeries(LineSeries, { color, lineWidth: 2, priceLineVisible: false });
const s = this.chart.addSeries(LineSeries, {
color, lineWidth: 2, priceLineVisible: false,
...this._crosshairMarkerSeriesOptions(),
});
const base = bars[0]?.close ?? 1;
s.setData(bars.map(b => ({ time: b.time as Time, value: (b.close / base - 1) * 100 })));
this.compareSeriesMap.set(symbol, s);
+2
View File
@@ -526,6 +526,8 @@ export interface AppSettingsDto {
chartLiveReceiveHighlight?: boolean;
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
chartCrosshairInfoVisible?: boolean;
/** 세로 크로스헤어와 지표 선 교차점 점 표시 (기본 true) */
chartCrosshairMarkersVisible?: boolean;
/** 차트 마우스오버 줌·이동 플로팅 툴바 표시 (기본 true) */
chartHoverToolbarVisible?: boolean;
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */