실시간 차트 멀티모드 수정
This commit is contained in:
@@ -97,6 +97,11 @@ public class GcAppSettings {
|
|||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Boolean chartVolumeVisible = true;
|
private Boolean chartVolumeVisible = true;
|
||||||
|
|
||||||
|
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */
|
||||||
|
@Column(name = "chart_live_receive_highlight", nullable = false)
|
||||||
|
@Builder.Default
|
||||||
|
private Boolean chartLiveReceiveHighlight = 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)
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ public class AppSettingsService {
|
|||||||
Boolean.parseBoolean(d.get("chartSeriesPriceLabels").toString()));
|
Boolean.parseBoolean(d.get("chartSeriesPriceLabels").toString()));
|
||||||
if (d.containsKey("chartVolumeVisible")) s.setChartVolumeVisible(
|
if (d.containsKey("chartVolumeVisible")) s.setChartVolumeVisible(
|
||||||
Boolean.parseBoolean(d.get("chartVolumeVisible").toString()));
|
Boolean.parseBoolean(d.get("chartVolumeVisible").toString()));
|
||||||
|
if (d.containsKey("chartLiveReceiveHighlight")) s.setChartLiveReceiveHighlight(
|
||||||
|
Boolean.parseBoolean(d.get("chartLiveReceiveHighlight").toString()));
|
||||||
if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions")));
|
if (d.containsKey("chartLegendOptions")) s.setChartLegendOptionsJson(toJson(d.get("chartLegendOptions")));
|
||||||
if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup(
|
if (d.containsKey("tradeAlertPopup")) s.setTradeAlertPopup(
|
||||||
Boolean.parseBoolean(d.get("tradeAlertPopup").toString()));
|
Boolean.parseBoolean(d.get("tradeAlertPopup").toString()));
|
||||||
@@ -179,6 +181,7 @@ public class AppSettingsService {
|
|||||||
m.put("btShowPrice", s.getBtShowPrice() != null ? s.getBtShowPrice() : true);
|
m.put("btShowPrice", s.getBtShowPrice() != null ? s.getBtShowPrice() : true);
|
||||||
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("chartLegendOptions", parseJson(s.getChartLegendOptionsJson()));
|
m.put("chartLegendOptions", parseJson(s.getChartLegendOptionsJson()));
|
||||||
m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true);
|
m.put("tradeAlertPopup", s.getTradeAlertPopup() != null ? s.getTradeAlertPopup() : true);
|
||||||
m.put("tradeAlertSoundEnabled", s.getTradeAlertSoundEnabled() != null ? s.getTradeAlertSoundEnabled() : true);
|
m.put("tradeAlertSoundEnabled", s.getTradeAlertSoundEnabled() != null ? s.getTradeAlertSoundEnabled() : true);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- V30: gc_app_settings — 실시간 수신 카드 음영(형광 연두 아웃라인)
|
||||||
|
-- ON(1, 기본값): 수신 시 카드박스 아웃라인 glow
|
||||||
|
-- OFF(0) : 음영 효과 비표시
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE gc_app_settings
|
||||||
|
ADD COLUMN chart_live_receive_highlight TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
COMMENT '실시간 수신 시 카드 아웃라인 형광 연두 음영 (1=ON, 0=OFF)';
|
||||||
+51
-4
@@ -1519,6 +1519,37 @@ html.theme-blue {
|
|||||||
/* top/left/width/height 는 JS 인라인 스타일로 지정됨 */
|
/* top/left/width/height 는 JS 인라인 스타일로 지정됨 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.msp-panel--dropdown {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.48);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msp-header--compact,
|
||||||
|
.msp-item--compact {
|
||||||
|
grid-template-columns: minmax(160px, 1fr) 92px 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msp-header--compact .msp-col-name {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msp-item--compact .msp-col-name {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msp-item--compact .msp-kor {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* 검색 입력 행 */
|
/* 검색 입력 행 */
|
||||||
.msp-search-row {
|
.msp-search-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -4256,22 +4287,38 @@ html.theme-blue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.slot-compact-name {
|
.slot-compact-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
max-width: 38%;
|
max-width: 42%;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: 1px solid transparent;
|
||||||
padding: 0;
|
border-radius: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin: -2px -6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--text, #d1d4dc);
|
color: var(--text, #d1d4dc);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
transition: background 0.12s, border-color 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.slot-compact-name-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.slot-compact-name:hover {
|
||||||
|
background: color-mix(in srgb, var(--accent, #2962ff) 8%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent, #2962ff) 22%, transparent);
|
||||||
}
|
}
|
||||||
.slot-compact-name.open {
|
.slot-compact-name.open {
|
||||||
color: var(--accent, #2962ff);
|
color: var(--accent, #2962ff);
|
||||||
|
background: color-mix(in srgb, var(--accent, #2962ff) 12%, transparent);
|
||||||
|
border-color: color-mix(in srgb, var(--accent, #2962ff) 35%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slot-compact-quote {
|
.slot-compact-quote {
|
||||||
|
|||||||
@@ -581,6 +581,7 @@ function App() {
|
|||||||
|
|
||||||
const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true;
|
const chartSeriesPriceLabels = appDefaults.chartSeriesPriceLabels ?? true;
|
||||||
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
|
const chartVolumeVisible = appDefaults.chartVolumeVisible ?? true;
|
||||||
|
const chartLiveReceiveHighlight = appDefaults.chartLiveReceiveHighlight ?? true;
|
||||||
const chartLegendOptions = appDefaults.chartLegendOptions;
|
const chartLegendOptions = appDefaults.chartLegendOptions;
|
||||||
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
|
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
|
||||||
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
|
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
|
||||||
@@ -1700,6 +1701,8 @@ function App() {
|
|||||||
managerRef.current?.setVolumeVisible(v, wrapper?.clientHeight);
|
managerRef.current?.setVolumeVisible(v, wrapper?.clientHeight);
|
||||||
slotRefs.current.forEach(slot => slot?.setVolumeVisible(v));
|
slotRefs.current.forEach(slot => slot?.setVolumeVisible(v));
|
||||||
}}
|
}}
|
||||||
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
|
onChartLiveReceiveHighlight={v => saveAppDef({ chartLiveReceiveHighlight: v })}
|
||||||
chartLegendOptions={chartLegendOptions}
|
chartLegendOptions={chartLegendOptions}
|
||||||
onChartLegendOptionsChange={patch => {
|
onChartLegendOptionsChange={patch => {
|
||||||
saveAppDef({
|
saveAppDef({
|
||||||
@@ -2002,6 +2005,7 @@ function App() {
|
|||||||
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
|
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
chartVolumeVisible={chartVolumeVisible}
|
chartVolumeVisible={chartVolumeVisible}
|
||||||
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
compactMode
|
compactMode
|
||||||
@@ -2038,6 +2042,7 @@ function App() {
|
|||||||
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
|
onTradeOrderRequest={(req, idx) => applyTradeFill(req, idx)}
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
chartVolumeVisible={chartVolumeVisible}
|
chartVolumeVisible={chartVolumeVisible}
|
||||||
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
chartRealtimeSource={chartRealtimeSource as 'BACKEND_STOMP' | 'UPBIT_DIRECT'}
|
||||||
displayTimezone={displayTimezone}
|
displayTimezone={displayTimezone}
|
||||||
compactMode
|
compactMode
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { useLiveReceiveFlash } from '../hooks/useLiveReceiveFlash';
|
|||||||
import VirtualLiveBadge from './virtual/VirtualLiveBadge';
|
import VirtualLiveBadge from './virtual/VirtualLiveBadge';
|
||||||
import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
import type { VirtualLiveStatus } from '../hooks/useVirtualTargetLiveStatus';
|
||||||
import { invalidateMarketCache } from '../utils/requestCache';
|
import { invalidateMarketCache } from '../utils/requestCache';
|
||||||
|
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||||
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
||||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||||
import {
|
import {
|
||||||
@@ -162,6 +163,8 @@ export interface ChartSlotProps {
|
|||||||
displayTimezone?: string;
|
displayTimezone?: string;
|
||||||
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
|
/** 멀티차트 — 가상투자 카드 차트처럼 컴팩트 카드 UI */
|
||||||
compactMode?: boolean;
|
compactMode?: boolean;
|
||||||
|
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (설정 연동) */
|
||||||
|
chartLiveReceiveHighlight?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||||
@@ -183,6 +186,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
chartRealtimeSource = 'BACKEND_STOMP',
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
displayTimezone,
|
displayTimezone,
|
||||||
compactMode = false,
|
compactMode = false,
|
||||||
|
chartLiveReceiveHighlight = true,
|
||||||
}, ref) {
|
}, ref) {
|
||||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||||
@@ -200,10 +204,11 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
const [showMainChartModal, setShowMainChartModal] = useState(false);
|
const [showMainChartModal, setShowMainChartModal] = useState(false);
|
||||||
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(DEFAULT_MAIN_CHART_STYLE);
|
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(DEFAULT_MAIN_CHART_STYLE);
|
||||||
const [showMarket, setShowMarket] = useState(false);
|
const [showMarket, setShowMarket] = useState(false);
|
||||||
// 슬롯 컨테이너 rect: MarketSearchPanel 위치 계산에 사용
|
// MarketSearchPanel 위치: compact=종목명 버튼, 일반=슬롯 전체
|
||||||
const [slotAnchorRect, setSlotAnchorRect] = useState<DOMRect | null>(null);
|
const [slotAnchorRect, setSlotAnchorRect] = useState<DOMRect | null>(null);
|
||||||
|
|
||||||
const managerRef = useRef<ChartManager | null>(null);
|
const managerRef = useRef<ChartManager | null>(null);
|
||||||
|
const compactNameRef = useRef<HTMLButtonElement>(null);
|
||||||
// initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장
|
// initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장
|
||||||
const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
|
const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
|
||||||
const timeframeRef = useRef<Timeframe>(forcedTf ?? '1D');
|
const timeframeRef = useRef<Timeframe>(forcedTf ?? '1D');
|
||||||
@@ -362,6 +367,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
// → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결)
|
// → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결)
|
||||||
const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null);
|
const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null);
|
||||||
const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null);
|
const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null);
|
||||||
|
/** 종목·TF 전환 직후 sync range 재적용 스킵 (이전 종목 뷰포트가 새 차트에 적용되는 것 방지) */
|
||||||
|
const skipSyncApplyRef = useRef(false);
|
||||||
|
|
||||||
// ── Crosshair sync: syncTime → setCrosshairAtTime ────────────────────
|
// ── Crosshair sync: syncTime → setCrosshairAtTime ────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -371,7 +378,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
|
|
||||||
// ── Time sync: syncVisibleRange 외부 적용 ────────────────────────────
|
// ── Time sync: syncVisibleRange 외부 적용 ────────────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!syncVisibleRange) return;
|
if (!syncVisibleRange || skipSyncApplyRef.current) return;
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
if (!mgr || mgr.getRawBarsLength() === 0) {
|
if (!mgr || mgr.getRawBarsLength() === 0) {
|
||||||
// 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용
|
// 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용
|
||||||
@@ -385,7 +392,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
|
|
||||||
// ── Date range sync: syncLogicalRange 외부 적용 ──────────────────────
|
// ── Date range sync: syncLogicalRange 외부 적용 ──────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!syncLogicalRange) return;
|
if (!syncLogicalRange || skipSyncApplyRef.current) return;
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
if (!mgr || mgr.getRawBarsLength() === 0) {
|
if (!mgr || mgr.getRawBarsLength() === 0) {
|
||||||
pendingSyncLogicalRef.current = syncLogicalRange;
|
pendingSyncLogicalRef.current = syncLogicalRange;
|
||||||
@@ -409,6 +416,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
reloadTriggeredRef.current = false;
|
reloadTriggeredRef.current = false;
|
||||||
chartLiveReadyRef.current = false;
|
chartLiveReadyRef.current = false;
|
||||||
pendingRealtimeBarRef.current = null;
|
pendingRealtimeBarRef.current = null;
|
||||||
|
pendingSyncVisibleRef.current = null;
|
||||||
|
pendingSyncLogicalRef.current = null;
|
||||||
|
skipSyncApplyRef.current = true;
|
||||||
}, [symbol, timeframe]);
|
}, [symbol, timeframe]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checks = [200, 500, 1000, 2000].map(delay =>
|
const checks = [200, 500, 1000, 2000].map(delay =>
|
||||||
@@ -519,6 +529,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
}, [useUpbit, wsStatus, latestBar]);
|
}, [useUpbit, wsStatus, latestBar]);
|
||||||
|
|
||||||
const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected');
|
const receiving = useLiveReceiveFlash(receiveSignal, compactMode && useUpbit && wsStatus === 'connected');
|
||||||
|
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
||||||
|
|
||||||
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
|
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
|
||||||
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
|
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
|
||||||
@@ -590,11 +601,29 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
// ── 심볼 선택 ─────────────────────────────────────────────────────────
|
// ── 심볼 선택 ─────────────────────────────────────────────────────────
|
||||||
const handleSymbolSelect = useCallback((s: string) => {
|
const handleSymbolSelect = useCallback((s: string) => {
|
||||||
if (isUpbitMarket(s)) invalidateMarketCache(s);
|
if (isUpbitMarket(s)) invalidateMarketCache(s);
|
||||||
|
pendingSyncVisibleRef.current = null;
|
||||||
|
pendingSyncLogicalRef.current = null;
|
||||||
|
skipSyncApplyRef.current = true;
|
||||||
setSymbol(s);
|
setSymbol(s);
|
||||||
setLegend(null);
|
setLegend(null);
|
||||||
setShowMarket(false);
|
setShowMarket(false);
|
||||||
|
setSlotAnchorRect(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openMarketSearch = useCallback((e?: React.MouseEvent) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
onActivate();
|
||||||
|
if (!showMarket) {
|
||||||
|
const anchorEl = compactMode
|
||||||
|
? compactNameRef.current
|
||||||
|
: (slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null);
|
||||||
|
setSlotAnchorRect(anchorEl?.getBoundingClientRect() ?? null);
|
||||||
|
} else {
|
||||||
|
setSlotAnchorRect(null);
|
||||||
|
}
|
||||||
|
setShowMarket(v => !v);
|
||||||
|
}, [compactMode, onActivate, showMarket]);
|
||||||
|
|
||||||
const displayKoName = compactKoName(symbol);
|
const displayKoName = compactKoName(symbol);
|
||||||
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
|
const trendUp = compactMode && currentBar != null && dailyChangePct >= 0;
|
||||||
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
|
const trendDown = compactMode && currentBar != null && dailyChangePct < 0;
|
||||||
@@ -605,7 +634,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
'chart-slot',
|
'chart-slot',
|
||||||
active ? 'active' : '',
|
active ? 'active' : '',
|
||||||
compactMode ? 'chart-slot--compact' : '',
|
compactMode ? 'chart-slot--compact' : '',
|
||||||
receiving ? 'chart-slot--receiving' : '',
|
highlightReceiving ? 'chart-slot--receiving' : '',
|
||||||
trendUp ? 'chart-slot--trend-up' : '',
|
trendUp ? 'chart-slot--trend-up' : '',
|
||||||
trendDown ? 'chart-slot--trend-down' : '',
|
trendDown ? 'chart-slot--trend-down' : '',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
@@ -620,17 +649,17 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
<div className="slot-compact-row">
|
<div className="slot-compact-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
ref={compactNameRef}
|
||||||
className={`slot-compact-name${showMarket ? ' open' : ''}`}
|
className={`slot-compact-name${showMarket ? ' open' : ''}`}
|
||||||
title={`${displayKoName} — 종목 변경`}
|
title={`${displayKoName} — 종목 변경`}
|
||||||
onClick={() => {
|
aria-expanded={showMarket}
|
||||||
if (!showMarket) {
|
aria-haspopup="listbox"
|
||||||
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
|
onClick={openMarketSearch}
|
||||||
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
|
|
||||||
}
|
|
||||||
setShowMarket(v => !v);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{displayKoName}
|
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }} aria-hidden>
|
||||||
|
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
||||||
|
</svg>
|
||||||
|
<span className="slot-compact-name-text">{displayKoName}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{currentBar && (
|
{currentBar && (
|
||||||
@@ -676,13 +705,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
<button
|
<button
|
||||||
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
|
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
|
||||||
title="종목 변경"
|
title="종목 변경"
|
||||||
onClick={() => {
|
onClick={openMarketSearch}
|
||||||
if (!showMarket) {
|
|
||||||
const slotEl = slotContainerRef.current?.closest('.chart-slot') as HTMLElement | null;
|
|
||||||
setSlotAnchorRect(slotEl?.getBoundingClientRect() ?? null);
|
|
||||||
}
|
|
||||||
setShowMarket(v => !v);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
|
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.6" style={{ flexShrink: 0 }}>
|
||||||
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
||||||
@@ -720,8 +743,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
<MarketSearchPanel
|
<MarketSearchPanel
|
||||||
currentMarket={symbol}
|
currentMarket={symbol}
|
||||||
onSelect={s => { handleSymbolSelect(s); }}
|
onSelect={s => { handleSymbolSelect(s); }}
|
||||||
onClose={() => setShowMarket(false)}
|
onClose={() => { setShowMarket(false); setSlotAnchorRect(null); }}
|
||||||
anchorRect={slotAnchorRect}
|
anchorRect={slotAnchorRect}
|
||||||
|
anchorPlacement={compactMode ? 'dropdown' : 'slot'}
|
||||||
/>,
|
/>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
@@ -809,6 +833,12 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
|
|||||||
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
|
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
|
||||||
const mgr = managerRef.current;
|
const mgr = managerRef.current;
|
||||||
if (!mgr) return;
|
if (!mgr) return;
|
||||||
|
if (skipSyncApplyRef.current) {
|
||||||
|
skipSyncApplyRef.current = false;
|
||||||
|
const fb = mgr.hasIchimoku() ? 28 : 0;
|
||||||
|
mgr.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (pendingSyncVisibleRef.current) {
|
if (pendingSyncVisibleRef.current) {
|
||||||
isSyncingTimeRef.current = true;
|
isSyncingTimeRef.current = true;
|
||||||
mgr.applyVisibleTimeRange(
|
mgr.applyVisibleTimeRange(
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ interface MarketSearchPanelProps {
|
|||||||
onSelect: (market: string) => void;
|
onSelect: (market: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/**
|
/**
|
||||||
* 슬롯 내 심볼 버튼 클릭 시 전달되는 슬롯 컨테이너 rect.
|
* 슬롯/버튼 rect. anchorPlacement 에 따라 패널 위치가 결정된다.
|
||||||
* 제공되면 해당 슬롯 영역 안에서 패널이 표시된다.
|
|
||||||
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
|
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
|
||||||
*/
|
*/
|
||||||
anchorRect?: DOMRect | null;
|
anchorRect?: DOMRect | null;
|
||||||
|
/** slot=슬롯 영역 채움, dropdown=클릭 지점 아래 드롭다운 */
|
||||||
|
anchorPlacement?: 'slot' | 'dropdown';
|
||||||
/** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */
|
/** overlay=팝업/고정패널, embedded=좌측 패널 인라인 목록 */
|
||||||
variant?: 'overlay' | 'embedded';
|
variant?: 'overlay' | 'embedded';
|
||||||
/** 외부 입력과 검색어 동기화 */
|
/** 외부 입력과 검색어 동기화 */
|
||||||
@@ -67,7 +68,7 @@ interface MarketSearchPanelProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||||
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
|
currentMarket, onSelect, onClose, anchorRect, anchorPlacement = 'slot', initialQuery = '',
|
||||||
variant = 'overlay', query: controlledQuery, onQueryChange,
|
variant = 'overlay', query: controlledQuery, onQueryChange,
|
||||||
actionMode = 'favorite', onAdd, addedMarkets,
|
actionMode = 'favorite', onAdd, addedMarkets,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -233,36 +234,60 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
|
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
|
||||||
const PANEL_Z = 99999;
|
const PANEL_Z = 99999;
|
||||||
|
|
||||||
|
const DROPDOWN_H = 480;
|
||||||
|
const isCompactDropdown = anchorPlacement === 'dropdown' && !embedded;
|
||||||
|
|
||||||
const panelStyle: React.CSSProperties | undefined = embedded
|
const panelStyle: React.CSSProperties | undefined = embedded
|
||||||
? undefined
|
? undefined
|
||||||
: anchorRect
|
: anchorRect
|
||||||
? {
|
? anchorPlacement === 'dropdown'
|
||||||
position: 'fixed',
|
? {
|
||||||
top: anchorRect.top + HEADER_H,
|
position: 'fixed',
|
||||||
left: anchorRect.left,
|
top: anchorRect.bottom + 4,
|
||||||
width: Math.max(anchorRect.width, MIN_W),
|
left: anchorRect.left,
|
||||||
height: anchorRect.height - HEADER_H,
|
width: Math.max(420, anchorRect.width, MIN_W),
|
||||||
maxWidth: anchorRect.width,
|
height: Math.min(DROPDOWN_H, window.innerHeight - anchorRect.bottom - 12),
|
||||||
zIndex: PANEL_Z,
|
maxWidth: Math.min(520, window.innerWidth - anchorRect.left - 8),
|
||||||
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
|
minHeight: 320,
|
||||||
background: 'var(--bg2, #24283b)',
|
zIndex: PANEL_Z,
|
||||||
color: 'var(--text, #c0caf5)',
|
background: 'var(--bg2, #24283b)',
|
||||||
border: '1px solid var(--border, rgba(122,162,247,0.15))',
|
color: 'var(--text, #c0caf5)',
|
||||||
}
|
border: '1px solid var(--border, rgba(122,162,247,0.15))',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: 'fixed',
|
||||||
|
top: anchorRect.top + HEADER_H,
|
||||||
|
left: anchorRect.left,
|
||||||
|
width: Math.max(anchorRect.width, MIN_W),
|
||||||
|
height: anchorRect.height - HEADER_H,
|
||||||
|
maxWidth: anchorRect.width,
|
||||||
|
zIndex: PANEL_Z,
|
||||||
|
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
|
||||||
|
background: 'var(--bg2, #24283b)',
|
||||||
|
color: 'var(--text, #c0caf5)',
|
||||||
|
border: '1px solid var(--border, rgba(122,162,247,0.15))',
|
||||||
|
}
|
||||||
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
|
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
|
||||||
|
|
||||||
// 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함
|
// backdrop: dropdown=전체 화면, slot=해당 슬롯 영역만
|
||||||
const backdropStyle: React.CSSProperties | undefined = anchorRect
|
const backdropStyle: React.CSSProperties | undefined = anchorRect
|
||||||
? {
|
? anchorPlacement === 'dropdown'
|
||||||
position: 'fixed',
|
? {
|
||||||
top: anchorRect.top,
|
position: 'fixed',
|
||||||
left: anchorRect.left,
|
inset: 0,
|
||||||
width: anchorRect.width,
|
zIndex: PANEL_Z - 1,
|
||||||
height: anchorRect.height,
|
background: 'rgba(0,0,0,0.28)',
|
||||||
zIndex: PANEL_Z - 1,
|
}
|
||||||
background: 'rgba(0,0,0,0.45)',
|
: {
|
||||||
backdropFilter: 'none',
|
position: 'fixed',
|
||||||
}
|
top: anchorRect.top,
|
||||||
|
left: anchorRect.left,
|
||||||
|
width: anchorRect.width,
|
||||||
|
height: anchorRect.height,
|
||||||
|
zIndex: PANEL_Z - 1,
|
||||||
|
background: 'rgba(0,0,0,0.45)',
|
||||||
|
backdropFilter: 'none',
|
||||||
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const handleRowSelect = useCallback((market: string) => {
|
const handleRowSelect = useCallback((market: string) => {
|
||||||
@@ -280,7 +305,12 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}${embedded ? ' msp-panel--embedded' : ''}`}
|
className={[
|
||||||
|
'msp-panel',
|
||||||
|
anchorRect && anchorPlacement === 'dropdown' ? 'msp-panel--dropdown' : '',
|
||||||
|
anchorRect && anchorPlacement === 'slot' ? 'msp-panel--slot' : '',
|
||||||
|
embedded ? 'msp-panel--embedded' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
style={panelStyle}
|
style={panelStyle}
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -317,14 +347,18 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}`}>
|
<div className={`msp-header${embedded ? ' msp-header--embedded' : ''}${isCompactDropdown ? ' msp-header--compact' : ''}`}>
|
||||||
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
|
{!isCompactDropdown && (
|
||||||
<span className="msp-col msp-col-name">한글명</span>
|
<span className="msp-col msp-col-fav">{embedded ? '추가' : ''}</span>
|
||||||
|
)}
|
||||||
|
<span className="msp-col msp-col-name">{isCompactDropdown ? '종목명' : '한글명'}</span>
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
<>
|
<>
|
||||||
<span className="msp-col msp-col-price">현재가</span>
|
<span className="msp-col msp-col-price">현재가</span>
|
||||||
<span className="msp-col msp-col-change">전일대비</span>
|
<span className="msp-col msp-col-change">전일대비</span>
|
||||||
<span className="msp-col msp-col-vol">거래대금</span>
|
{!isCompactDropdown && (
|
||||||
|
<span className="msp-col msp-col-vol">거래대금</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -348,40 +382,47 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.market}
|
key={m.market}
|
||||||
className={`msp-item${embedded ? ' msp-item--embedded' : ''}${isActive ? ' active' : ''}`}
|
className={[
|
||||||
|
'msp-item',
|
||||||
|
embedded ? 'msp-item--embedded' : '',
|
||||||
|
isCompactDropdown ? 'msp-item--compact' : '',
|
||||||
|
isActive ? 'active' : '',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
onClick={() => handleRowSelect(m.market)}
|
onClick={() => handleRowSelect(m.market)}
|
||||||
>
|
>
|
||||||
{actionMode === 'add' ? (
|
{!isCompactDropdown && (
|
||||||
<button
|
actionMode === 'add' ? (
|
||||||
type="button"
|
<button
|
||||||
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
|
type="button"
|
||||||
onClick={e => handleAddClick(m.market, e)}
|
className={`msp-add-btn${isAdded ? ' msp-add-btn--done' : ''}`}
|
||||||
disabled={isAdded}
|
onClick={e => handleAddClick(m.market, e)}
|
||||||
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
|
disabled={isAdded}
|
||||||
>
|
title={isAdded ? '이미 추가됨' : '투자대상에 추가'}
|
||||||
{isAdded ? (
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
{isAdded ? (
|
||||||
<polyline points="20 6 9 17 4 12"/>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
</svg>
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
) : (
|
</svg>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
) : (
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
</svg>
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
)}
|
</svg>
|
||||||
</button>
|
)}
|
||||||
) : (
|
</button>
|
||||||
<span
|
) : (
|
||||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
<span
|
||||||
onClick={e => toggleFav(m.market, e)}
|
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
onClick={e => toggleFav(m.market, e)}
|
||||||
>
|
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||||
{isFav ? '★' : '☆'}
|
>
|
||||||
</span>
|
{isFav ? '★' : '☆'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="msp-col msp-col-name">
|
<span className="msp-col msp-col-name">
|
||||||
<span className="msp-kor">{m.korean_name}</span>
|
<span className="msp-kor">{m.korean_name}</span>
|
||||||
<span className="msp-sym">{m.symbol}</span>
|
{!isCompactDropdown && <span className="msp-sym">{m.symbol}</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
@@ -396,9 +437,11 @@ export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
|||||||
? '-'
|
? '-'
|
||||||
: `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`}
|
: `${isUp ? '+' : ''}${safeToFixed(rate, 2)}%`}
|
||||||
</span>
|
</span>
|
||||||
<span className="msp-col msp-col-vol">
|
{!isCompactDropdown && (
|
||||||
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
<span className="msp-col msp-col-vol">
|
||||||
</span>
|
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ interface SettingsPageProps {
|
|||||||
onChartSeriesPriceLabels?: (v: boolean) => void;
|
onChartSeriesPriceLabels?: (v: boolean) => void;
|
||||||
chartVolumeVisible?: boolean;
|
chartVolumeVisible?: boolean;
|
||||||
onChartVolumeVisible?: (v: boolean) => void;
|
onChartVolumeVisible?: (v: boolean) => void;
|
||||||
|
chartLiveReceiveHighlight?: boolean;
|
||||||
|
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||||||
chartLegendOptions?: ChartLegendVisibility;
|
chartLegendOptions?: ChartLegendVisibility;
|
||||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||||
paperTradingEnabled?: boolean;
|
paperTradingEnabled?: boolean;
|
||||||
@@ -565,6 +567,8 @@ interface ChartPanelProps {
|
|||||||
onChartSeriesPriceLabels?: (v: boolean) => void;
|
onChartSeriesPriceLabels?: (v: boolean) => void;
|
||||||
chartVolumeVisible?: boolean;
|
chartVolumeVisible?: boolean;
|
||||||
onChartVolumeVisible?: (v: boolean) => void;
|
onChartVolumeVisible?: (v: boolean) => void;
|
||||||
|
chartLiveReceiveHighlight?: boolean;
|
||||||
|
onChartLiveReceiveHighlight?: (v: boolean) => void;
|
||||||
chartLegendOptions?: ChartLegendVisibility;
|
chartLegendOptions?: ChartLegendVisibility;
|
||||||
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
onChartLegendOptionsChange?: (patch: Partial<ChartLegendVisibility>) => void;
|
||||||
}
|
}
|
||||||
@@ -577,6 +581,8 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
|||||||
onChartSeriesPriceLabels,
|
onChartSeriesPriceLabels,
|
||||||
chartVolumeVisible = true,
|
chartVolumeVisible = true,
|
||||||
onChartVolumeVisible,
|
onChartVolumeVisible,
|
||||||
|
chartLiveReceiveHighlight = true,
|
||||||
|
onChartLiveReceiveHighlight,
|
||||||
chartLegendOptions,
|
chartLegendOptions,
|
||||||
onChartLegendOptionsChange,
|
onChartLegendOptionsChange,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -598,6 +604,19 @@ const ChartPanel: React.FC<ChartPanelProps> = ({
|
|||||||
<option value="UPBIT_DIRECT">업비트 직연결 (기존)</option>
|
<option value="UPBIT_DIRECT">업비트 직연결 (기존)</option>
|
||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
<SettingRow
|
||||||
|
label="실시간 수신 음영처리"
|
||||||
|
desc="켜면 실시간 데이터 수신 순간 멀티차트·가상투자 카드 아웃라인에 형광 연두색 glow가 표시됩니다. 끄면 아웃라인 음영 효과가 비활성화됩니다."
|
||||||
|
>
|
||||||
|
<label className="stg-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={chartLiveReceiveHighlight}
|
||||||
|
onChange={e => onChartLiveReceiveHighlight?.(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className="stg-toggle-slider" />
|
||||||
|
</label>
|
||||||
|
</SettingRow>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
<SettingSection title="캔들 색상">
|
<SettingSection title="캔들 색상">
|
||||||
<SettingRow label="상승 캔들 색상" desc="양봉(상승) 캔들의 기본 색상입니다.">
|
<SettingRow label="상승 캔들 색상" desc="양봉(상승) 캔들의 기본 색상입니다.">
|
||||||
@@ -1351,6 +1370,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onChartSeriesPriceLabels,
|
onChartSeriesPriceLabels,
|
||||||
chartVolumeVisible = true,
|
chartVolumeVisible = true,
|
||||||
onChartVolumeVisible,
|
onChartVolumeVisible,
|
||||||
|
chartLiveReceiveHighlight = true,
|
||||||
|
onChartLiveReceiveHighlight,
|
||||||
chartLegendOptions,
|
chartLegendOptions,
|
||||||
onChartLegendOptionsChange,
|
onChartLegendOptionsChange,
|
||||||
tradeAlertSoundEnabled = true,
|
tradeAlertSoundEnabled = true,
|
||||||
@@ -1428,6 +1449,8 @@ const SettingsPage: React.FC<SettingsPageProps> = ({
|
|||||||
onChartSeriesPriceLabels={onChartSeriesPriceLabels}
|
onChartSeriesPriceLabels={onChartSeriesPriceLabels}
|
||||||
chartVolumeVisible={chartVolumeVisible}
|
chartVolumeVisible={chartVolumeVisible}
|
||||||
onChartVolumeVisible={onChartVolumeVisible}
|
onChartVolumeVisible={onChartVolumeVisible}
|
||||||
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
|
onChartLiveReceiveHighlight={onChartLiveReceiveHighlight}
|
||||||
chartLegendOptions={chartLegendOptions}
|
chartLegendOptions={chartLegendOptions}
|
||||||
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
onChartLegendOptionsChange={onChartLegendOptionsChange}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
|
const chartRealtimeSource = (appChartDefaults.chartRealtimeSource
|
||||||
?? 'BACKEND_STOMP') as ChartRealtimeSource;
|
?? 'BACKEND_STOMP') as ChartRealtimeSource;
|
||||||
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
const chartSeriesPriceLabels = appChartDefaults.chartSeriesPriceLabels;
|
||||||
|
const chartLiveReceiveHighlight = appChartDefaults.chartLiveReceiveHighlight;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
@@ -340,6 +341,7 @@ const VirtualTradingPage: React.FC<Props> = ({
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
chartRealtimeSource={chartRealtimeSource}
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
tickers={tickers}
|
tickers={tickers}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
globalDisplayMode={globalDisplayMode}
|
globalDisplayMode={globalDisplayMode}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ interface Props {
|
|||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
chartRealtimeSource?: ChartRealtimeSource;
|
chartRealtimeSource?: ChartRealtimeSource;
|
||||||
chartSeriesPriceLabels?: boolean;
|
chartSeriesPriceLabels?: boolean;
|
||||||
|
chartLiveReceiveHighlight?: boolean;
|
||||||
ticker?: TickerData;
|
ticker?: TickerData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
chartRealtimeSource = 'BACKEND_STOMP',
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
chartSeriesPriceLabels = true,
|
chartSeriesPriceLabels = true,
|
||||||
|
chartLiveReceiveHighlight = true,
|
||||||
ticker,
|
ticker,
|
||||||
}) => {
|
}) => {
|
||||||
const ko = getKoreanName(market);
|
const ko = getKoreanName(market);
|
||||||
@@ -79,6 +81,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
|
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
|
||||||
}, [running, lastTickAt, snapshot?.updatedAt]);
|
}, [running, lastTickAt, snapshot?.updatedAt]);
|
||||||
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
||||||
|
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -86,7 +89,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
'vtd-card',
|
'vtd-card',
|
||||||
'vtd-card--signal',
|
'vtd-card--signal',
|
||||||
running ? 'vtd-card--live' : '',
|
running ? 'vtd-card--live' : '',
|
||||||
receiving ? 'vtd-card--receiving' : '',
|
highlightReceiving ? 'vtd-card--receiving' : '',
|
||||||
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
isDetail ? 'vtd-card--detail' : 'vtd-card--summary',
|
||||||
isChart ? 'vtd-card--chart-mode' : '',
|
isChart ? 'vtd-card--chart-mode' : '',
|
||||||
selected ? 'vtd-card--selected' : '',
|
selected ? 'vtd-card--selected' : '',
|
||||||
@@ -187,7 +190,7 @@ const VirtualTargetCard: React.FC<Props> = ({
|
|||||||
<VirtualTargetSignalPanel
|
<VirtualTargetSignalPanel
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
receiving={receiving}
|
receiving={highlightReceiving}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface Props {
|
|||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
chartRealtimeSource?: ChartRealtimeSource;
|
chartRealtimeSource?: ChartRealtimeSource;
|
||||||
chartSeriesPriceLabels?: boolean;
|
chartSeriesPriceLabels?: boolean;
|
||||||
|
chartLiveReceiveHighlight?: boolean;
|
||||||
ticker?: TickerData;
|
ticker?: TickerData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,6 +50,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
|||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
chartRealtimeSource = 'BACKEND_STOMP',
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
chartSeriesPriceLabels = true,
|
chartSeriesPriceLabels = true,
|
||||||
|
chartLiveReceiveHighlight = true,
|
||||||
ticker,
|
ticker,
|
||||||
}) => {
|
}) => {
|
||||||
const ko = getKoreanName(market);
|
const ko = getKoreanName(market);
|
||||||
@@ -60,9 +62,10 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
|||||||
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
|
return `${lastTickAt ?? 0}|${snapshot?.updatedAt ?? 0}`;
|
||||||
}, [running, lastTickAt, snapshot?.updatedAt]);
|
}, [running, lastTickAt, snapshot?.updatedAt]);
|
||||||
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
const receiving = useLiveReceiveFlash(receiveSignal, running);
|
||||||
|
const highlightReceiving = chartLiveReceiveHighlight && receiving;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`vtd-focus-wrap${receiving ? ' vtd-focus-wrap--receiving' : ''}`}>
|
<div className={`vtd-focus-wrap${highlightReceiving ? ' vtd-focus-wrap--receiving' : ''}`}>
|
||||||
<div className="vtd-focus-head">
|
<div className="vtd-focus-head">
|
||||||
<div className="vtd-focus-head-main">
|
<div className="vtd-focus-head-main">
|
||||||
<div className="vtd-focus-title">
|
<div className="vtd-focus-title">
|
||||||
@@ -129,7 +132,7 @@ const VirtualTargetFocusView: React.FC<Props> = ({
|
|||||||
<VirtualTargetSignalPanel
|
<VirtualTargetSignalPanel
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
receiving={receiving}
|
receiving={highlightReceiving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface Props {
|
|||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
chartRealtimeSource?: ChartRealtimeSource;
|
chartRealtimeSource?: ChartRealtimeSource;
|
||||||
chartSeriesPriceLabels?: boolean;
|
chartSeriesPriceLabels?: boolean;
|
||||||
|
chartLiveReceiveHighlight?: boolean;
|
||||||
tickers?: Map<string, TickerData>;
|
tickers?: Map<string, TickerData>;
|
||||||
viewMode: VirtualCardViewMode;
|
viewMode: VirtualCardViewMode;
|
||||||
globalDisplayMode: VirtualCardDisplayMode;
|
globalDisplayMode: VirtualCardDisplayMode;
|
||||||
@@ -36,6 +37,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
theme = 'dark',
|
theme = 'dark',
|
||||||
chartRealtimeSource = 'BACKEND_STOMP',
|
chartRealtimeSource = 'BACKEND_STOMP',
|
||||||
chartSeriesPriceLabels = true,
|
chartSeriesPriceLabels = true,
|
||||||
|
chartLiveReceiveHighlight = true,
|
||||||
tickers,
|
tickers,
|
||||||
viewMode,
|
viewMode,
|
||||||
globalDisplayMode,
|
globalDisplayMode,
|
||||||
@@ -116,6 +118,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
chartRealtimeSource={chartRealtimeSource}
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
ticker={tickers?.get(focusTarget.market)}
|
ticker={tickers?.get(focusTarget.market)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -144,6 +147,7 @@ const VirtualTargetGrid: React.FC<Props> = ({
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
chartRealtimeSource={chartRealtimeSource}
|
chartRealtimeSource={chartRealtimeSource}
|
||||||
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
chartSeriesPriceLabels={chartSeriesPriceLabels}
|
||||||
|
chartLiveReceiveHighlight={chartLiveReceiveHighlight}
|
||||||
ticker={tickers?.get(t.market)}
|
ticker={tickers?.get(t.market)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function resolveAppDefaults(s: AppSettingsDto) {
|
|||||||
btShowPrice: s.btShowPrice ?? true,
|
btShowPrice: s.btShowPrice ?? true,
|
||||||
chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true,
|
chartSeriesPriceLabels: s.chartSeriesPriceLabels ?? true,
|
||||||
chartVolumeVisible: s.chartVolumeVisible ?? true,
|
chartVolumeVisible: s.chartVolumeVisible ?? true,
|
||||||
|
chartLiveReceiveHighlight: s.chartLiveReceiveHighlight ?? true,
|
||||||
chartLegendOptions: resolveChartLegendOptions(
|
chartLegendOptions: resolveChartLegendOptions(
|
||||||
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
|
s.chartLegendOptions as Partial<ChartLegendVisibility> | null | undefined,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -363,19 +363,43 @@ export class ChartManager {
|
|||||||
|
|
||||||
this._reapplyAllPatternMarkers();
|
this._reapplyAllPatternMarkers();
|
||||||
|
|
||||||
// 이전 timeScale visible range를 완전히 리셋
|
// 종목 전환·초기 로드 — bar 수가 적을 때 음수 logical from 은 캔들이 우측으로 몰림
|
||||||
|
this._applyDefaultVisibleRange(200, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 표시용 visible range 설정.
|
||||||
|
* bar < displayCount 이면 fitContent (음수 logical index 사용 금지).
|
||||||
|
*/
|
||||||
|
private _applyDefaultVisibleRange(displayCount: number, extendFutureBars = 0): void {
|
||||||
|
const bars = this.rawBars;
|
||||||
|
if (!bars || bars.length < 2) return;
|
||||||
|
|
||||||
const ts = this.chart.timeScale();
|
const ts = this.chart.timeScale();
|
||||||
if (bars.length >= 2) {
|
|
||||||
if (bars.length < 20) {
|
if (bars.length <= displayCount) {
|
||||||
ts.setVisibleLogicalRange({ from: bars.length - 200, to: bars.length + 5 });
|
if (extendFutureBars > 0) {
|
||||||
} else {
|
let to: number = bars[bars.length - 1].time as number;
|
||||||
|
to += extendFutureBars * this._barIntervalSecs();
|
||||||
ts.setVisibleRange({
|
ts.setVisibleRange({
|
||||||
from: bars[0].time as Time,
|
from: bars[0].time as Time,
|
||||||
to: bars[bars.length - 1].time as Time,
|
to: to as Time,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
ts.fitContent();
|
ts.fitContent();
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const startIdx = Math.max(0, bars.length - displayCount);
|
||||||
|
let to: number = bars[bars.length - 1].time as number;
|
||||||
|
if (extendFutureBars > 0) {
|
||||||
|
to += extendFutureBars * this._barIntervalSecs();
|
||||||
|
}
|
||||||
|
ts.setVisibleRange({
|
||||||
|
from: bars[startIdx].time as Time,
|
||||||
|
to: to as Time,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
private _createMainSeries(chartType: ChartType, t: ThemeTokens): ISeriesApi<SeriesType> {
|
||||||
@@ -2397,30 +2421,7 @@ export class ChartManager {
|
|||||||
* @param extendFutureBars 마지막 바 이후 추가로 보여줄 바 수 (기본 0)
|
* @param extendFutureBars 마지막 바 이후 추가로 보여줄 바 수 (기본 0)
|
||||||
*/
|
*/
|
||||||
setInitialVisibleRange(displayCount: number, extendFutureBars = 0): void {
|
setInitialVisibleRange(displayCount: number, extendFutureBars = 0): void {
|
||||||
const bars = this.rawBars;
|
this._applyDefaultVisibleRange(displayCount, extendFutureBars);
|
||||||
if (!bars || bars.length === 0) return;
|
|
||||||
|
|
||||||
const ts = this.chart.timeScale();
|
|
||||||
|
|
||||||
if (bars.length <= displayCount && extendFutureBars === 0) {
|
|
||||||
if (bars.length < 20) {
|
|
||||||
ts.setVisibleLogicalRange({ from: bars.length - displayCount, to: bars.length + 5 });
|
|
||||||
} else {
|
|
||||||
ts.fitContent();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startIdx = Math.max(0, bars.length - displayCount);
|
|
||||||
const from = bars[startIdx].time as number;
|
|
||||||
let to: number = bars[bars.length - 1].time as number;
|
|
||||||
|
|
||||||
if (extendFutureBars > 0) {
|
|
||||||
const interval = this._barIntervalSecs();
|
|
||||||
to = to + extendFutureBars * interval;
|
|
||||||
}
|
|
||||||
|
|
||||||
ts.setVisibleRange({ from: from as Time, to: to as Time });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 일목균형표가 활성화되어 있는지 여부 */
|
/** 일목균형표가 활성화되어 있는지 여부 */
|
||||||
|
|||||||
@@ -388,6 +388,8 @@ export interface AppSettingsDto {
|
|||||||
chartSeriesPriceLabels?: boolean;
|
chartSeriesPriceLabels?: boolean;
|
||||||
/** 차트 하단 거래량 바 표시 (기본 true) */
|
/** 차트 하단 거래량 바 표시 (기본 true) */
|
||||||
chartVolumeVisible?: boolean;
|
chartVolumeVisible?: boolean;
|
||||||
|
/** 실시간 수신 시 카드 아웃라인 형광 연두 음영 (기본 true) */
|
||||||
|
chartLiveReceiveHighlight?: boolean;
|
||||||
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
/** 차트 상단 범례(tv-legend) 항목별 표시 옵션 */
|
||||||
chartLegendOptions?: Record<string, boolean> | null;
|
chartLegendOptions?: Record<string, boolean> | null;
|
||||||
/** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */
|
/** 매매 시그널 발생 시 알림 팝업 표시 여부 (기본 true) */
|
||||||
|
|||||||
Reference in New Issue
Block a user