실시간 차트 스크롤시 날짜 동기화 문제
This commit is contained in:
@@ -112,6 +112,11 @@ public class GcAppSettings {
|
||||
@Builder.Default
|
||||
private Boolean chartLiveReceiveHighlight = true;
|
||||
|
||||
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
|
||||
@Column(name = "chart_crosshair_info_visible", nullable = false)
|
||||
@Builder.Default
|
||||
private Boolean chartCrosshairInfoVisible = true;
|
||||
|
||||
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 JSON */
|
||||
@Column(name = "chart_legend_options_json", columnDefinition = "JSON")
|
||||
@JdbcTypeCode(SqlTypes.JSON)
|
||||
|
||||
@@ -96,6 +96,8 @@ public class AppSettingsService {
|
||||
Boolean.parseBoolean(d.get("chartVolumeVisible").toString()));
|
||||
if (d.containsKey("chartLiveReceiveHighlight")) s.setChartLiveReceiveHighlight(
|
||||
Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString()));
|
||||
if (d.containsKey("chartCrosshairInfoVisible")) s.setChartCrosshairInfoVisible(
|
||||
Boolean.parseBoolean(d.get("chartCrosshairInfoVisible").toString()));
|
||||
if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions")));
|
||||
if (d.containsKey("chartPaneSeparator")) s.setChartPaneSeparatorJson(toJson(d.get("chartPaneSeparator")));
|
||||
if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup(
|
||||
@@ -199,6 +201,7 @@ public class AppSettingsService {
|
||||
m.put("chartSeriesPriceLabels", s.getChartSeriesPriceLabels() != null ? s.getChartSeriesPriceLabels() : true);
|
||||
m.put("chartVolumeVisible", s.getChartVolumeVisible() != null ? s.getChartVolumeVisible() : true);
|
||||
m.put("chartLiveReceiveHighlight", s.getChartLiveReceiveHighlight() != null ? s.getChartLiveReceiveHighlight() : true);
|
||||
m.put("chartCrosshairInfoVisible", s.getChartCrosshairInfoVisible() != null ? s.getChartCrosshairInfoVisible() : true);
|
||||
m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson()));
|
||||
m.put("chartPaneSeparator", parseJson(s.getChartPaneSeparatorJson()));
|
||||
m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE gc_app_settings
|
||||
ADD COLUMN chart_crosshair_info_visible BOOLEAN NOT NULL DEFAULT TRUE
|
||||
COMMENT '크로스헤어 이동 시 OHLC·보조지표 수치 표시';
|
||||
@@ -988,6 +988,7 @@ html.theme-blue {
|
||||
border-top: 1px solid var(--border, rgba(42, 46, 58, 0.85));
|
||||
background: color-mix(in srgb, var(--bg, #131722) 88%, transparent);
|
||||
overflow: hidden;
|
||||
will-change: transform;
|
||||
}
|
||||
.candle-pane-time-axis__label {
|
||||
position: absolute;
|
||||
|
||||
@@ -638,6 +638,7 @@ function App() {
|
||||
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
|
||||
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
|
||||
const chartLegendOptions = appDefaults.chartLegendOptions;
|
||||
const chartCrosshairInfoVisible = appDefaults.chartCrosshairInfoVisible ?? true;
|
||||
const chartPaneSeparator = appDefaults.chartPaneSeparator;
|
||||
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
|
||||
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
|
||||
@@ -1878,6 +1879,8 @@ function App() {
|
||||
chartLegendOptions: { ...chartLegendOptions, ...patch } as unknown as Record<string, boolean>,
|
||||
});
|
||||
}}
|
||||
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
||||
onChartCrosshairInfoVisible={v => saveAppDef({ chartCrosshairInfoVisible: v })}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
onChartPaneSeparatorChange={opts => {
|
||||
saveAppDef({ chartPaneSeparator: opts });
|
||||
@@ -2201,6 +2204,7 @@ function App() {
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartVolumeVisible={chartVolumeVisible}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||
displayTimezone={displayTimezone}
|
||||
@@ -2241,6 +2245,7 @@ function App() {
|
||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||
chartVolumeVisible={chartVolumeVisible}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
||||
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||
displayTimezone={displayTimezone}
|
||||
@@ -2257,6 +2262,7 @@ function App() {
|
||||
<div className="tv-chart-col" style={{ display: layoutDef.count > 1 ? 'none' : undefined }}>
|
||||
<ChartLegendBar
|
||||
visibility={chartLegendOptions}
|
||||
crosshairInfoVisible={chartCrosshairInfoVisible}
|
||||
symbol={symbol}
|
||||
timeframe={timeframe}
|
||||
useUpbit={useUpbit}
|
||||
@@ -2291,6 +2297,7 @@ function App() {
|
||||
drawings={drawings} logScale={logScale}
|
||||
drawingsLocked={drawingsLocked}
|
||||
drawingsVisible={drawingsVisible}
|
||||
crosshairInfoVisible={chartCrosshairInfoVisible}
|
||||
onCrosshair={setLegend}
|
||||
onTradeOrderRequest={applyTradeFill}
|
||||
onDataLoaded={() => syncBacktestMarkersRef.current()}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* 캔들 pane 하단 시간축 — 거래량·보조지표가 아래에 있을 때 캔들 영역 바로 아래에도 시간 표시.
|
||||
*
|
||||
* 드래그 패닝 중에는 라벨 좌표를 재계산하지 않고 translate3d 로 차트와 동일 픽셀만큼 이동한다.
|
||||
* (setVisibleLogicalRange 직후 LWC 좌표 API가 한 프레임 늦게 갱신되는 문제 회피)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useEffect, useCallback, useRef } from 'react';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
import { subscribeChartTimeFormat } from '../utils/chartTimeFormat';
|
||||
|
||||
@@ -12,24 +15,81 @@ interface CandlePaneTimeAxisProps {
|
||||
containerEl: HTMLElement;
|
||||
}
|
||||
|
||||
const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, containerEl }) => {
|
||||
const [state, setState] = useState<ReturnType<ChartManager['getCandlePaneTimeAxisOverlay']>>(null);
|
||||
function applyOverlayLayout(
|
||||
root: HTMLDivElement,
|
||||
pool: HTMLSpanElement[],
|
||||
state: NonNullable<ReturnType<ChartManager['getCandlePaneTimeAxisOverlay']>>,
|
||||
): void {
|
||||
root.style.display = 'flex';
|
||||
root.style.top = `${state.topY}px`;
|
||||
root.style.height = `${state.height ?? AXIS_HEIGHT}px`;
|
||||
root.style.width = `${state.plotWidth}px`;
|
||||
|
||||
const sync = useCallback(() => {
|
||||
if (!containerEl.isConnected) return;
|
||||
setState(manager.getCandlePaneTimeAxisOverlay());
|
||||
state.labels.forEach((label, i) => {
|
||||
let span = pool[i];
|
||||
if (!span) {
|
||||
span = document.createElement('span');
|
||||
span.className = 'candle-pane-time-axis__label';
|
||||
root.appendChild(span);
|
||||
pool[i] = span;
|
||||
}
|
||||
span.style.display = '';
|
||||
span.style.left = `${label.x}px`;
|
||||
span.textContent = label.text;
|
||||
});
|
||||
for (let i = state.labels.length; i < pool.length; i++) {
|
||||
if (pool[i]) pool[i].style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, containerEl }) => {
|
||||
const axisRef = useRef<HTMLDivElement>(null);
|
||||
const labelPoolRef = useRef<HTMLSpanElement[]>([]);
|
||||
|
||||
const renderOverlay = useCallback(() => {
|
||||
const root = axisRef.current;
|
||||
if (!root || !containerEl.isConnected) return;
|
||||
|
||||
const panActive = manager.isCandleAxisPanActive();
|
||||
const panOffset = manager.getCandleAxisPanOffsetPx();
|
||||
|
||||
if (panActive) {
|
||||
const state = manager.getCandlePaneTimeAxisOverlay();
|
||||
if (!state || state.labels.length === 0) {
|
||||
root.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (panOffset === 0) {
|
||||
applyOverlayLayout(root, labelPoolRef.current, state);
|
||||
}
|
||||
root.style.transform = `translate3d(${panOffset}px, 0, 0)`;
|
||||
return;
|
||||
}
|
||||
|
||||
root.style.transform = '';
|
||||
|
||||
const state = manager.getCandlePaneTimeAxisOverlay();
|
||||
if (!state || state.labels.length === 0) {
|
||||
root.style.display = 'none';
|
||||
for (const span of labelPoolRef.current) {
|
||||
span.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
applyOverlayLayout(root, labelPoolRef.current, state);
|
||||
}, [manager, containerEl]);
|
||||
|
||||
useEffect(() => {
|
||||
sync();
|
||||
const ro = new ResizeObserver(sync);
|
||||
renderOverlay();
|
||||
const ro = new ResizeObserver(renderOverlay);
|
||||
ro.observe(containerEl);
|
||||
|
||||
const unsubLayout = manager.subscribePaneLayout(sync);
|
||||
const unsubRange = manager.subscribeViewport(sync);
|
||||
const unsubFormat = subscribeChartTimeFormat(sync);
|
||||
const unsubLayout = manager.subscribePaneLayout(renderOverlay);
|
||||
const unsubRange = manager.subscribeViewport(renderOverlay);
|
||||
const unsubFormat = subscribeChartTimeFormat(renderOverlay);
|
||||
|
||||
const timers = [0, 50, 150, 400].map(ms => window.setTimeout(sync, ms));
|
||||
const timers = [0, 50, 150, 400].map(ms => window.setTimeout(renderOverlay, ms));
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
unsubLayout();
|
||||
@@ -37,30 +97,15 @@ const CandlePaneTimeAxis: React.FC<CandlePaneTimeAxisProps> = ({ manager, contai
|
||||
unsubFormat();
|
||||
timers.forEach(clearTimeout);
|
||||
};
|
||||
}, [manager, containerEl, sync]);
|
||||
|
||||
if (!state || state.labels.length === 0) return null;
|
||||
}, [manager, containerEl, renderOverlay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={axisRef}
|
||||
className="candle-pane-time-axis"
|
||||
style={{
|
||||
top: state.topY,
|
||||
height: state.height ?? AXIS_HEIGHT,
|
||||
width: state.plotWidth,
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
aria-hidden
|
||||
>
|
||||
{state.labels.map((label, idx) => (
|
||||
<span
|
||||
key={`${label.x}-${idx}`}
|
||||
className="candle-pane-time-axis__label"
|
||||
style={{ left: label.x }}
|
||||
>
|
||||
{label.text}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isU
|
||||
|
||||
export interface ChartLegendBarProps {
|
||||
visibility: ChartLegendVisibility;
|
||||
/** 크로스헤어 이동 시 OHLC·지표 수치 표시 */
|
||||
crosshairInfoVisible?: boolean;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
useUpbit: boolean;
|
||||
@@ -43,6 +45,7 @@ export interface ChartLegendBarProps {
|
||||
|
||||
export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
||||
visibility: v,
|
||||
crosshairInfoVisible = true,
|
||||
symbol,
|
||||
timeframe,
|
||||
useUpbit,
|
||||
@@ -54,11 +57,12 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
||||
legend,
|
||||
}) => {
|
||||
const ohlcUp = ohlcBar ? ohlcBar.close >= ohlcBar.open : true;
|
||||
const showOhlc = ohlcBar && (v.open || v.high || v.low || v.close);
|
||||
const showOhlc = crosshairInfoVisible && ohlcBar && (v.open || v.high || v.low || v.close);
|
||||
const showLiveQuote = (v.close || v.change) && liveQuote.price != null;
|
||||
const showCrosshairIndicators = crosshairInfoVisible && v.indicators && indicators.length > 0;
|
||||
const showAny =
|
||||
v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge)
|
||||
|| showLiveQuote || showOhlc || (v.indicators && indicators.length > 0);
|
||||
|| showLiveQuote || showOhlc || showCrosshairIndicators;
|
||||
|
||||
if (!showAny) return null;
|
||||
|
||||
@@ -96,7 +100,7 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(showOhlc || (v.indicators && indicators.length > 0)) && (
|
||||
{(showOhlc || showCrosshairIndicators) && (
|
||||
<div className="tv-legend-bottom">
|
||||
{showOhlc && (
|
||||
<span className="tv-legend-ohlcv">
|
||||
@@ -114,7 +118,7 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{v.indicators && indicators.map(ind => {
|
||||
{showCrosshairIndicators && indicators.map(ind => {
|
||||
const def = getIndicatorDef(ind.type);
|
||||
const vals = legend?.indicatorValues?.[ind.type];
|
||||
const plots = ind.plots ?? def?.plots ?? [];
|
||||
@@ -128,7 +132,7 @@ export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
||||
return def ? (
|
||||
<span key={ind.id} className="tv-legend-ind">
|
||||
<span className="tv-legend-ind-name">{label}</span>
|
||||
{v.indicatorValues && vals && vals.length > 0 && (
|
||||
{crosshairInfoVisible && v.indicatorValues && vals && vals.length > 0 && (
|
||||
<span className="tv-legend-ind-vals">
|
||||
{vals.map((n, i) =>
|
||||
Number.isFinite(n) ? (
|
||||
|
||||
@@ -186,6 +186,8 @@ export interface ChartSlotProps {
|
||||
chartVisible?: boolean;
|
||||
/** pane 구분선 */
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 */
|
||||
chartCrosshairInfoVisible?: boolean;
|
||||
/** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */
|
||||
marketTickers?: Map<string, TickerData>;
|
||||
}
|
||||
@@ -212,6 +214,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
chartLiveReceiveHighlight = true,
|
||||
chartVisible = true,
|
||||
chartPaneSeparator,
|
||||
chartCrosshairInfoVisible = true,
|
||||
marketTickers,
|
||||
}, ref) {
|
||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||
@@ -853,6 +856,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
||||
showHoverToolbar={!compactMode}
|
||||
volumeVisible={chartVolumeVisible}
|
||||
paneSeparatorOptions={chartPaneSeparator}
|
||||
crosshairInfoVisible={chartCrosshairInfoVisible}
|
||||
onCrosshair={data => {
|
||||
setLegend(data);
|
||||
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface PaneLegendProps {
|
||||
focusedId?: string | null;
|
||||
/** 전체화면 → 전체 보기 복원 */
|
||||
onRestore?: () => void;
|
||||
/** 크로스헤어 이동 시 보조지표 수치 표시 */
|
||||
crosshairInfoVisible?: boolean;
|
||||
}
|
||||
|
||||
// ── 지그재그 배경색 — 홀짝 2단계로만 번갈아 적용 ───────────────────────────
|
||||
@@ -328,6 +330,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
manager, indicators, containerEl, paused = false,
|
||||
onHoverName, onLeaveName, onDoubleClick,
|
||||
onReorder, onMerge, onSplit, onExpand, onRemove, onDuplicate, focusedId, onRestore,
|
||||
crosshairInfoVisible = true,
|
||||
}) => {
|
||||
const [paneItems, setPaneItems] = useState<PaneItem[]>([]);
|
||||
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
|
||||
@@ -426,12 +429,16 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
}, [containerEl, refreshLayouts, syncPaneItems]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!crosshairInfoVisible) {
|
||||
setValues({});
|
||||
return;
|
||||
}
|
||||
const unsub = manager.subscribeCrosshair((p: MouseEventParams<Time>) => {
|
||||
if (!p.time) { setValues({}); return; }
|
||||
setValues(manager.getIndicatorValuesByIdFromParams(p));
|
||||
});
|
||||
return unsub;
|
||||
}, [manager]);
|
||||
}, [manager, crosshairInfoVisible]);
|
||||
|
||||
// ── 드롭 라인 Y ────────────────────────────────────────────────────────────
|
||||
const getDropLineY = useCallback((idx: number): number | null => {
|
||||
@@ -697,7 +704,7 @@ const PaneLegend: React.FC<PaneLegendProps> = ({
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
{itemVals.map((v, i) => isNaN(v) ? null : (
|
||||
{crosshairInfoVisible && itemVals.map((v, i) => isNaN(v) ? null : (
|
||||
<span key={i} className="pane-legend-val"
|
||||
style={{ color: item.plotColors[i] ?? 'var(--text)' }}>
|
||||
{formatVal(v)}
|
||||
|
||||
@@ -118,6 +118,8 @@ interface SettingsPageProps {
|
||||
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||||
chartLegendOptions?: ChartLegendVisibility;
|
||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||
chartCrosshairInfoVisible?: boolean;
|
||||
onChartCrosshairInfoVisible?: (v: boolean) => void;
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||
paperTradingEnabled?: boolean;
|
||||
@@ -672,6 +674,8 @@ interface ChartPanelProps {
|
||||
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||||
chartLegendOptions?: ChartLegendVisibility;
|
||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||
chartCrosshairInfoVisible?: boolean;
|
||||
onChartCrosshairInfoVisible?: (v: boolean) => void;
|
||||
chartPaneSeparator?: ChartPaneSeparatorOptions;
|
||||
onChartPaneSeparatorChange?: (opts: ChartPaneSeparatorOptions) => void;
|
||||
chartTimeFormat?: string;
|
||||
@@ -690,6 +694,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
onChartLiveReceiveHighlight,
|
||||
chartLegendOptions,
|
||||
onChartLegendOptionsChange,
|
||||
chartCrosshairInfoVisible = true,
|
||||
onChartCrosshairInfoVisible,
|
||||
chartPaneSeparator,
|
||||
onChartPaneSeparatorChange,
|
||||
chartTimeFormat = 'MM-dd HH:mm',
|
||||
@@ -881,6 +887,24 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
{chartLegendOptions && onChartLegendOptionsChange && (
|
||||
<SettingSection title="크로스헤어 정보">
|
||||
<SettingRow
|
||||
label="크로스헤어 정보 표시"
|
||||
desc="마우스로 차트를 가리킬 때 표시되는 시가·고가·저가·종가와 보조지표 수치를 켜거나 끕니다. 종목·현재가·보조지표 이름은 유지됩니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={chartCrosshairInfoVisible}
|
||||
onChange={e => onChartCrosshairInfoVisible?.(e.target.checked)}
|
||||
/>
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</SettingRow>
|
||||
</SettingSection>
|
||||
)}
|
||||
|
||||
{chartLegendOptions && onChartLegendOptionsChange && (
|
||||
<SettingSection title="상단 정보 표시">
|
||||
{CHART_LEGEND_SETTING_ITEMS.map(({ key, label, desc }) => (
|
||||
@@ -1571,6 +1595,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onChartLiveReceiveHighlight,
|
||||
chartLegendOptions,
|
||||
onChartLegendOptionsChange,
|
||||
chartCrosshairInfoVisible = true,
|
||||
onChartCrosshairInfoVisible,
|
||||
chartPaneSeparator,
|
||||
onChartPaneSeparatorChange,
|
||||
tradeAlertSoundEnabled = true,
|
||||
@@ -1667,6 +1693,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
||||
onChartLiveReceiveHighlight={onChartLiveReceiveHighlight}
|
||||
chartLegendOptions={chartLegendOptions}
|
||||
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
||||
chartCrosshairInfoVisible={chartCrosshairInfoVisible}
|
||||
onChartCrosshairInfoVisible={onChartCrosshairInfoVisible}
|
||||
chartPaneSeparator={chartPaneSeparator}
|
||||
onChartPaneSeparatorChange={onChartPaneSeparatorChange}
|
||||
chartTimeFormat={chartTimeFormat}
|
||||
|
||||
@@ -137,6 +137,8 @@ interface TradingChartProps {
|
||||
paneLayoutClamp?: boolean;
|
||||
/** 보조지표 pane 레전드(이름·값 오버레이) 표시 (기본 true, 알림 미니차트는 false) */
|
||||
showPaneLegend?: boolean;
|
||||
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
|
||||
crosshairInfoVisible?: boolean;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
@@ -170,6 +172,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
paneSeparatorOptions,
|
||||
paneLayoutClamp = false,
|
||||
showPaneLegend = true,
|
||||
crosshairInfoVisible = true,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
@@ -859,7 +862,10 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
|
||||
const onPanPointerUp = () => {
|
||||
if (!panStateRef.current.active) return;
|
||||
if (panStateRef.current.moved) suppressClickRef.current = true;
|
||||
if (panStateRef.current.moved) {
|
||||
suppressClickRef.current = true;
|
||||
}
|
||||
managerRef.current?.endCandleAxisPan();
|
||||
panStateRef.current.active = false;
|
||||
container.style.cursor = canPanRef.current ? 'grab' : '';
|
||||
};
|
||||
@@ -1423,6 +1429,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
|
||||
onDuplicate={onDuplicateIndicator}
|
||||
focusedId={focusedIndicatorId}
|
||||
onRestore={onRestoreIndicators}
|
||||
crosshairInfoVisible={crosshairInfoVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
||||
chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true,
|
||||
chartVolumeVisible: s.chartVolumeVisible ?? true,
|
||||
chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true,
|
||||
chartCrosshairInfoVisible: s.chartCrosshairInfoVisible ?? true,
|
||||
chartLegendOptions: resolveChartLegendOptions(
|
||||
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
|
||||
),
|
||||
|
||||
@@ -418,6 +418,7 @@ export class ChartManager {
|
||||
} else {
|
||||
ts.fitContent();
|
||||
}
|
||||
this._notifyViewport();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -430,6 +431,7 @@ export class ChartManager {
|
||||
from: bars[startIdx].time as Time,
|
||||
to: to as Time,
|
||||
});
|
||||
this._notifyViewport();
|
||||
}
|
||||
|
||||
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
||||
@@ -1929,11 +1931,83 @@ export class ChartManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** 가시 시간 범위 변경 알림 (커스텀 패닝·줌 등 LWC 이벤트 미발생 경로 포함) */
|
||||
private readonly _viewportListeners = new Set<() => void>();
|
||||
private _viewportLwcHooked = false;
|
||||
private readonly _lwcViewportHandler = (): void => { this._notifyViewport(); };
|
||||
|
||||
/** 캔들 pane 시간축 — 드래그 패닝 중 transform 동기화용 */
|
||||
private _candleAxisPanActive = false;
|
||||
private _candleAxisPanOffsetPx = 0;
|
||||
|
||||
private _notifyViewport(): void {
|
||||
for (const cb of this._viewportListeners) {
|
||||
try { cb(); } catch { /* ok */ }
|
||||
}
|
||||
}
|
||||
|
||||
private _hookViewportLwc(): void {
|
||||
if (this._viewportLwcHooked) return;
|
||||
this._viewportLwcHooked = true;
|
||||
this.chart.timeScale().subscribeVisibleTimeRangeChange(this._lwcViewportHandler);
|
||||
this.chart.timeScale().subscribeVisibleLogicalRangeChange(this._lwcViewportHandler);
|
||||
}
|
||||
|
||||
private _unhookViewportLwc(): void {
|
||||
if (!this._viewportLwcHooked) return;
|
||||
this._viewportLwcHooked = false;
|
||||
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(this._lwcViewportHandler); } catch { /* ok */ }
|
||||
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(this._lwcViewportHandler); } catch { /* ok */ }
|
||||
}
|
||||
|
||||
/** 논리 범위 변경 + 오버레이(시간축·드로잉) 동기화 */
|
||||
private _setVisibleLogicalRange(range: { from: number; to: number }): void {
|
||||
try {
|
||||
this.chart.timeScale().setVisibleLogicalRange(range);
|
||||
this._notifyViewport();
|
||||
} catch { /* 데이터 로드 전 */ }
|
||||
}
|
||||
|
||||
/** PaneLegend 등 — 보조지표 pane DOM 배치가 끝난 뒤 레이블 위치 재동기화 */
|
||||
notifyPaneLayoutChanged(): void {
|
||||
this._notifyPaneLayout();
|
||||
}
|
||||
|
||||
/** 시간축 오버레이 등 — 뷰포트 동기화 강제 (패닝 종료 시 등) */
|
||||
notifyViewportChanged(): void {
|
||||
this._notifyViewport();
|
||||
}
|
||||
|
||||
/** 캔들 pane 시간축 드래그 패닝 종료 — 라벨 위치를 최종 논리 범위로 재정렬 */
|
||||
endCandleAxisPan(): void {
|
||||
if (!this._candleAxisPanActive) return;
|
||||
this._candleAxisPanActive = false;
|
||||
this._candleAxisPanOffsetPx = 0;
|
||||
this._notifyViewport();
|
||||
}
|
||||
|
||||
isCandleAxisPanActive(): boolean {
|
||||
return this._candleAxisPanActive;
|
||||
}
|
||||
|
||||
getCandleAxisPanOffsetPx(): number {
|
||||
return this._candleAxisPanOffsetPx;
|
||||
}
|
||||
|
||||
private _ensureCandleAxisPan(): void {
|
||||
if (this._candleAxisPanActive) return;
|
||||
this._candleAxisPanActive = true;
|
||||
this._candleAxisPanOffsetPx = 0;
|
||||
this._notifyViewport();
|
||||
}
|
||||
|
||||
/** visible logical range → 플롯 X (LWC 좌표 API와 동일한 선형 매핑) */
|
||||
private _logicalToAxisX(logical: number, lr: { from: number; to: number }, plotWidth: number): number {
|
||||
const span = lr.to - lr.from;
|
||||
if (!Number.isFinite(span) || span <= 0) return 0;
|
||||
return ((logical - lr.from) / span) * plotWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* 차트 컨테이너 기준 각 pane 의 topY 와 height 배열 반환.
|
||||
* IPaneApi.getHTMLElement() 로 pane index ↔ 화면 위치를 직접 매칭한다.
|
||||
@@ -2340,7 +2414,7 @@ export class ChartManager {
|
||||
const span = lr.to - lr.from;
|
||||
const center = (lr.from + lr.to) / 2;
|
||||
const newSpan = Math.max(span * 0.8, 5); // 최소 5바
|
||||
ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
|
||||
this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
|
||||
}
|
||||
|
||||
/** 시간축 축소 (visible logical range 20% 확대) */
|
||||
@@ -2351,7 +2425,7 @@ export class ChartManager {
|
||||
const span = lr.to - lr.from;
|
||||
const center = (lr.from + lr.to) / 2;
|
||||
const newSpan = span * 1.25;
|
||||
ts.setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
|
||||
this._setVisibleLogicalRange({ from: center - newSpan / 2, to: center + newSpan / 2 });
|
||||
}
|
||||
|
||||
/** 시간축 왼쪽(과거)으로 스크롤 — visible 범위의 20%씩 이동 */
|
||||
@@ -2360,7 +2434,7 @@ export class ChartManager {
|
||||
const lr = ts.getVisibleLogicalRange();
|
||||
if (!lr) return;
|
||||
const shift = (lr.to - lr.from) * 0.2;
|
||||
ts.setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift });
|
||||
this._setVisibleLogicalRange({ from: lr.from - shift, to: lr.to - shift });
|
||||
}
|
||||
|
||||
/** 시간축 오른쪽(미래)으로 스크롤 — visible 범위의 20%씩 이동 */
|
||||
@@ -2369,7 +2443,7 @@ export class ChartManager {
|
||||
const lr = ts.getVisibleLogicalRange();
|
||||
if (!lr) return;
|
||||
const shift = (lr.to - lr.from) * 0.2;
|
||||
ts.setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift });
|
||||
this._setVisibleLogicalRange({ from: lr.from + shift, to: lr.to + shift });
|
||||
}
|
||||
|
||||
/** 캔들 전용(보조지표·거래량 숨김) 레이아웃 적용 중 여부 */
|
||||
@@ -2678,25 +2752,23 @@ export class ChartManager {
|
||||
const lr = ts.getVisibleLogicalRange();
|
||||
if (!lr) return null;
|
||||
|
||||
const plotWidth = this._candlePlotWidth();
|
||||
const from = Math.max(0, Math.ceil(lr.from));
|
||||
const to = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
|
||||
if (to <= from) return null;
|
||||
const plotWidth = Math.max(40, ts.width());
|
||||
const lrSpan = lr.to - lr.from;
|
||||
if (!Number.isFinite(lrSpan) || lrSpan <= 0) return null;
|
||||
|
||||
const span = to - from;
|
||||
const targetCount = Math.max(4, Math.min(12, Math.floor(plotWidth / 76)));
|
||||
const step = Math.max(1, Math.ceil(span / targetCount));
|
||||
const step = Math.max(1, Math.ceil((lr.to - Math.max(0, lr.from)) / targetCount));
|
||||
|
||||
const labels: Array<{ x: number; text: string }> = [];
|
||||
const fmt = this.chartTimeFormat;
|
||||
const tz = this.displayTimezone;
|
||||
const startLogical = Math.max(0, Math.ceil(lr.from));
|
||||
const endLogical = Math.min(this.rawBars.length - 1, Math.floor(lr.to));
|
||||
|
||||
for (let i = from; i <= to; i += step) {
|
||||
const bar = this.rawBars[i];
|
||||
for (let logical = startLogical; logical <= endLogical; logical += step) {
|
||||
const bar = this.rawBars[logical];
|
||||
if (!bar?.time) continue;
|
||||
const coord = ts.timeToCoordinate(bar.time as Time);
|
||||
if (coord == null) continue;
|
||||
const x = Number(coord);
|
||||
const x = this._logicalToAxisX(logical, lr, plotWidth);
|
||||
if (!Number.isFinite(x) || x < 12 || x > plotWidth - 12) continue;
|
||||
labels.push({
|
||||
x,
|
||||
@@ -2791,15 +2863,16 @@ export class ChartManager {
|
||||
options?: { allowVerticalPan?: boolean },
|
||||
): void {
|
||||
if (deltaX !== 0) {
|
||||
this._ensureCandleAxisPan();
|
||||
this._candleAxisPanOffsetPx += deltaX;
|
||||
|
||||
const ts = this.chart.timeScale();
|
||||
const lr = ts.getVisibleLogicalRange();
|
||||
if (lr) {
|
||||
const w = Math.max(1, this.container.clientWidth);
|
||||
const w = Math.max(1, ts.width());
|
||||
const span = lr.to - lr.from;
|
||||
const dLog = (deltaX / w) * span;
|
||||
try {
|
||||
ts.setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog });
|
||||
} catch { /* 데이터 로드 전 */ }
|
||||
this._setVisibleLogicalRange({ from: lr.from - dLog, to: lr.to - dLog });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2901,6 +2974,7 @@ export class ChartManager {
|
||||
if (this.rawBars.length === 0) return;
|
||||
try {
|
||||
this.chart.timeScale().setVisibleRange({ from: from as Time, to: to as Time });
|
||||
this._notifyViewport();
|
||||
} catch { /* 데이터 로딩 전 호출 무시 */ }
|
||||
}
|
||||
|
||||
@@ -2910,9 +2984,7 @@ export class ChartManager {
|
||||
*/
|
||||
applyVisibleLogicalRange(from: number, to: number): void {
|
||||
if (this.rawBars.length === 0) return;
|
||||
try {
|
||||
this.chart.timeScale().setVisibleLogicalRange({ from, to });
|
||||
} catch { /* 데이터 로딩 전 호출 무시 */ }
|
||||
this._setVisibleLogicalRange({ from, to });
|
||||
}
|
||||
|
||||
/** Time sync: 가시 시간 범위 변화 구독. unsubscribe fn 반환 */
|
||||
@@ -2949,7 +3021,10 @@ export class ChartManager {
|
||||
const t0 = this.xToTime(Math.min(x0, x1));
|
||||
const t1 = this.xToTime(Math.max(x0, x1));
|
||||
if (t0 === null || t1 === null || t0 >= t1) return;
|
||||
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
|
||||
try {
|
||||
this.chart.timeScale().setVisibleRange({ from: t0 as import('lightweight-charts').Time, to: t1 as import('lightweight-charts').Time });
|
||||
this._notifyViewport();
|
||||
} catch { /* ok */ }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3089,12 +3164,10 @@ export class ChartManager {
|
||||
|
||||
// 가시 논리 범위 복원: 앞에 추가된 bar 수만큼 오른쪽으로 이동
|
||||
if (logicalRange) {
|
||||
try {
|
||||
this.chart.timeScale().setVisibleLogicalRange({
|
||||
from: logicalRange.from + newBars.length,
|
||||
to: logicalRange.to + newBars.length,
|
||||
});
|
||||
} catch { /* 무시 */ }
|
||||
this._setVisibleLogicalRange({
|
||||
from: logicalRange.from + newBars.length,
|
||||
to: logicalRange.to + newBars.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 보조지표 전체 재계산 (새 데이터가 왼쪽에 추가됐으므로 전체 갱신 필요)
|
||||
@@ -3309,13 +3382,13 @@ export class ChartManager {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Subscribe to visible range changes (time scale + price scale). Returns unsubscribe fn. */
|
||||
/** 가시 시간 범위 변경 구독 (LWC 네이티브 스크롤 + 커스텀 패닝). unsubscribe fn 반환 */
|
||||
subscribeViewport(cb: () => void): () => void {
|
||||
this.chart.timeScale().subscribeVisibleTimeRangeChange(cb);
|
||||
this.chart.timeScale().subscribeVisibleLogicalRangeChange(cb);
|
||||
this._viewportListeners.add(cb);
|
||||
this._hookViewportLwc();
|
||||
return () => {
|
||||
try { this.chart.timeScale().unsubscribeVisibleTimeRangeChange(cb); } catch { /* ok */ }
|
||||
try { this.chart.timeScale().unsubscribeVisibleLogicalRangeChange(cb); } catch { /* ok */ }
|
||||
this._viewportListeners.delete(cb);
|
||||
if (this._viewportListeners.size === 0) this._unhookViewportLwc();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -392,6 +392,8 @@ export interface AppSettingsDto {
|
||||
chartVolumeVisible?: boolean;
|
||||
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */
|
||||
chartLiveReceiveHighlight?: boolean;
|
||||
/** 크로스헤어 이동 시 OHLC·보조지표 수치 표시 (기본 true) */
|
||||
chartCrosshairInfoVisible?: boolean;
|
||||
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
||||
chartLegendOptions?: Record<string, boolean> | null;
|
||||
/** 차트 pane(캔들·거래량·보조지표) 구분선 */
|
||||
|
||||
Reference in New Issue
Block a user