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