실시간 차트 신고가,신저가 표시

This commit is contained in:
Macbook
2026-06-12 21:00:52 +09:00
parent d54dc6e2ff
commit 046bcc6379
15 changed files with 509 additions and 39 deletions
+32 -3
View File
@@ -45,6 +45,9 @@ export interface ChartRightToolbarProps {
onOpenCustom1OverlaySettings?: () => void;
/** true — 캔들 pane 상단 오버레이 토글만 (미니 차트 등) */
overlayControlsOnly?: boolean;
/** 9·20일 신고가·신저가 라벨 표시 (실시간 차트) */
priceExtremeLabelsVisible?: boolean;
onTogglePriceExtremeLabels?: () => void;
}
const BTN_SIZE = 32;
@@ -150,6 +153,14 @@ const IconIchimoku = () => (
</svg>
);
/** 신고가(상향)·신저가(하향) */
const IconPriceExtreme = () => (
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
<path d="M7 1.5 L10.5 6 H3.5 Z" fill="#4dabf7"/>
<path d="M7 12.5 L3.5 8 H10.5 Z" fill="#ef5350"/>
</svg>
);
const OVERLAY_ICONS: Record<ChartOverlayToggleKey, React.ReactNode> = {
ma: <IconMa />,
bollinger: <IconBollinger />,
@@ -360,6 +371,8 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
onExpand, onRestore, focusedId,
onDragStart, onToggleHidden, onSettings,
overlayControlsOnly = false,
priceExtremeLabelsVisible = false,
onTogglePriceExtremeLabels,
}) => {
const [paneLayouts, setPaneLayouts] = useState<Array<{ paneIndex: number; topY: number; height: number }>>([]);
const [paneItems, setPaneItems] = useState<ReturnType<typeof buildPaneItems>>([]);
@@ -418,9 +431,11 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
const candlePane = paneLayouts.find(l => l.paneIndex === 0);
const showOverlayIcons = !!(candleOverlayToggles?.length && candlePane && onToggleCandleOverlay);
const showPriceExtremeBtn = !!(candlePane && onTogglePriceExtremeLabels);
const showOverlayCluster = showOverlayIcons || showPriceExtremeBtn;
if (paused || chartHeight <= 0) return null;
if (overlayControlsOnly && !showOverlayIcons) return null;
if (overlayControlsOnly && !showOverlayCluster) return null;
const innerHeight = paneLayouts.reduce(
(max, l) => Math.max(max, l.topY + l.height),
@@ -475,12 +490,12 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
</div>
)}
{showOverlayIcons && (
{showOverlayCluster && (
<div
className="chart-right-toolbar-cluster chart-right-toolbar-cluster--overlay-icons"
style={{ top: candlePane!.topY + 4 }}
>
{candleOverlayToggles!.map((item, idx) => (
{candleOverlayToggles?.map((item, idx) => (
<button
key={item.key}
type="button"
@@ -495,6 +510,20 @@ const ChartRightToolbar: React.FC<ChartRightToolbarProps> = ({
{OVERLAY_ICONS[item.key]}
</button>
))}
{showPriceExtremeBtn && (
<button
type="button"
className={`tv-side-btn chart-right-toolbar-btn chart-right-toolbar-btn--overlay chart-right-toolbar-btn--price-extreme${priceExtremeLabelsVisible ? '' : ' chart-right-toolbar-btn--overlay-off'}`}
title={priceExtremeLabelsVisible ? '9·20일 신고가·신저가 숨기기' : '9·20일 신고가·신저가 표시'}
style={{ marginTop: (candleOverlayToggles?.length ?? 0) > 0 ? BTN_GAP : 0 }}
onClick={e => {
e.stopPropagation();
onTogglePriceExtremeLabels?.();
}}
>
<IconPriceExtreme />
</button>
)}
{showCustomBtn && (
<button
type="button"
+17
View File
@@ -139,6 +139,8 @@ export interface ChartSlotHandle {
setIndicatorAreaPriceLabelsEnabled: (enabled: boolean) => void;
/** 거래량 pane on/off */
setVolumeVisible: (visible: boolean) => void;
/** 9·20일 신고가·신저가 표시 */
setPriceExtremeLabelsVisible: (visible: boolean) => void;
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => void;
/** 차트 시간축·크로스헤어 날짜 형식 */
setChartTimeFormat: (format: string, timeframe?: Timeframe) => void;
@@ -210,6 +212,9 @@ export interface ChartSlotProps {
chartCrosshairInfoVisible?: boolean;
/** 차트 마우스오버 줌·이동 플로팅 툴바 */
chartHoverToolbarVisible?: boolean;
/** 9·20일 신고가·신저가 차트 표시 */
chartPriceExtremeLabelsVisible?: boolean;
onTogglePriceExtremeLabels?: () => void;
/** 업비트 실시간 티커 맵 (슬롯 심볼별 현재가·등락) */
marketTickers?: Map<string, TickerData>;
}
@@ -239,6 +244,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
chartPaneSeparator,
chartCrosshairInfoVisible = true,
chartHoverToolbarVisible = true,
chartPriceExtremeLabelsVisible = false,
onTogglePriceExtremeLabels,
marketTickers,
}, ref) {
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
@@ -364,6 +371,9 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
setVolumeVisible: (visible: boolean) => {
managerRef.current?.setVolumeVisible(visible);
},
setPriceExtremeLabelsVisible: (visible: boolean) => {
managerRef.current?.setPriceExtremeLabelsVisible(visible);
},
setPaneSeparatorOptions: (opts: ChartPaneSeparatorOptions) => {
managerRef.current?.setPaneSeparatorOptions(opts);
managerRef.current?.refreshPaneSeparatorOverlay();
@@ -391,6 +401,10 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
managerRef.current?.setVolumeVisible(chartVolumeVisible);
}, [chartVolumeVisible]);
useEffect(() => {
managerRef.current?.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible);
}, [chartPriceExtremeLabelsVisible]);
useEffect(() => {
if (!chartPaneSeparator) return;
managerRef.current?.setPaneSeparatorOptions(chartPaneSeparator);
@@ -1010,6 +1024,7 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
mgr.setCandleAreaPriceLabelsEnabled(chartCandleAreaPriceLabels);
mgr.setIndicatorAreaPriceLabelsEnabled(chartSeriesPriceLabels);
mgr.setVolumeVisible(chartVolumeVisible);
mgr.setPriceExtremeLabelsVisible(chartPriceExtremeLabelsVisible ?? false);
if (chartPaneSeparator) mgr.setPaneSeparatorOptions(chartPaneSeparator);
mgr.setChartOverlayVisibility(overlayVisibilityRef.current);
syncCustomOverlaysToManager(mgr);
@@ -1099,6 +1114,8 @@ const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot
custom1OverlayActive={compactMode ? undefined : custom1OverlayActive}
onCustom1OverlayActiveChange={compactMode ? undefined : handleCustom1OverlayActiveChange}
onOpenCustom1OverlaySettings={compactMode ? undefined : () => setCustomOverlaySettingsSlot('custom1')}
priceExtremeLabelsVisible={chartPriceExtremeLabelsVisible}
onTogglePriceExtremeLabels={onTogglePriceExtremeLabels}
/>
{customOverlaySettingsSlot === 'custom' && !compactMode && (
+37 -9
View File
@@ -52,6 +52,10 @@ import {
loadPaletteItems,
type PaletteItem,
} from '../utils/strategyPaletteStorage';
import {
buildCompositePaletteTemplateRows,
buildSidewaysPaletteTemplateRows,
} from '../utils/strategyEditorTemplatePaletteRows';
import {
buildPriceExtremeBreakoutTree,
isPriceExtremeBreakoutPairPaletteValue,
@@ -1307,22 +1311,22 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
const templateRows = useMemo(() => {
const userRows = userTemplates.map(t => ({
const userRows: TemplatePaletteRow[] = userTemplates.map(t => ({
key: t.id,
source: 'user' as const,
source: 'user',
label: t.label,
description: t.description,
signal: t.signal,
user: t,
}));
const hidden = new Set(hiddenBuiltinTemplateIds);
const builtinRows = templates
const builtinRows: TemplatePaletteRow[] = templates
.filter(t => !hidden.has(getBuiltinTemplateId(t)))
.map(t => {
const builtinId = getBuiltinTemplateId(t);
return {
key: builtinId,
source: 'builtin' as const,
source: 'builtin',
label: t.label,
description: 'description' in t ? t.description : undefined,
signal: t.signal,
@@ -1330,19 +1334,26 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
builtin: t,
};
});
return [...userRows, ...builtinRows];
}, [userTemplates, templates, hiddenBuiltinTemplateIds]);
const compositeRows: TemplatePaletteRow[] = buildCompositePaletteTemplateRows(compositePalette, hidden);
const sidewaysRows: TemplatePaletteRow[] = buildSidewaysPaletteTemplateRows(sidewaysPalette, hidden);
return [...userRows, ...builtinRows, ...compositeRows, ...sidewaysRows];
}, [userTemplates, templates, hiddenBuiltinTemplateIds, compositePalette, sidewaysPalette]);
const filteredTemplateRows = useMemo(() => {
const q = templateSearch.trim().toLowerCase();
if (!q) return templateRows;
return templateRows.filter(row => {
const signalLabel = row.signal === 'buy' ? '매수' : '매도';
const badge = row.source === 'user' ? '내 템플릿'
: row.source === 'composite-palette' ? '복합지표'
: row.source === 'sideways-palette' ? '횡보지표'
: '';
return (
row.label.toLowerCase().includes(q)
|| row.description?.toLowerCase().includes(q)
|| signalLabel.includes(q)
|| row.signal.includes(q)
|| badge.includes(q)
);
});
}, [templateRows, templateSearch]);
@@ -1468,14 +1479,31 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop
handleDeleteUserTemplate(row.user.id, row.label);
} else if (row.source === 'builtin' && row.builtinId) {
handleDeleteBuiltinTemplate(row.builtinId, row.label);
} else if (row.source === 'composite-palette' || row.source === 'sideways-palette') {
handleDeleteBuiltinTemplate(row.key, row.label);
}
setSelectedTemplateKey(null);
}, [selectedTemplateKey, templateRows, handleDeleteUserTemplate, handleDeleteBuiltinTemplate]);
const handleTemplateApply = useCallback((row: TemplatePaletteRow) => {
if (row.source === 'user' && row.user) applyUserTemplate(row.user);
else if (row.builtin) handleTemplate(row.builtin);
}, [applyUserTemplate, handleTemplate]);
if (row.source === 'user' && row.user) {
applyUserTemplate(row.user);
return;
}
if (row.source === 'builtin' && row.builtin) {
handleTemplate(row.builtin);
return;
}
if (row.source === 'composite-palette' && row.paletteItem) {
applyPaletteItem(row.paletteItem);
showSnack(`"${row.label}" 조건이 추가됐습니다`);
return;
}
if (row.source === 'sideways-palette' && row.sidewaysFilterId) {
applySidewaysFilter(row.sidewaysFilterId);
showSnack(`"${row.label}" 필터가 추가됐습니다`);
}
}, [applyUserTemplate, handleTemplate, applyPaletteItem, applySidewaysFilter]);
const handleTemplateSaveConfirm = useCallback(() => {
const label = templateSaveLabel.trim();
+22
View File
@@ -143,6 +143,9 @@ export interface ToolbarProps {
chartLiveQuote?: ChartLiveQuote | null;
chartQuoteLoading?: boolean;
chartQuoteError?: string | null;
/** 9·20일 신고가·신저가 차트 표시 (실시간 차트 상단 툴바) */
priceExtremeLabelsVisible?: boolean;
onTogglePriceExtremeLabels?: () => void;
}
// ─── SVG 아이콘 ────────────────────────────────────────────────────────────
@@ -240,6 +243,12 @@ const IcTradeSignal = () => (
<path d="M8.5 1.5 L4 8.5 H7.5 L6 13.5 L11 6 H7.5 Z" fill="currentColor" stroke="none" opacity="0.85"/>
</svg>
);
const IcPriceExtreme = () => (
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden>
<path d="M7 1.5 L10.5 6 H3.5 Z" fill="#4dabf7"/>
<path d="M7 12.5 L3.5 8 H10.5 Z" fill="#ef5350"/>
</svg>
);
const IcGridIcon = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.3">
<rect x="1" y="1" width="13" height="13" rx="1"/>
@@ -826,6 +835,8 @@ const Toolbar: React.FC<ToolbarProps> = ({
chartLiveQuote = null,
chartQuoteLoading = false,
chartQuoteError = null,
priceExtremeLabelsVisible = false,
onTogglePriceExtremeLabels,
}) => {
const [showMarket, setShowMarket] = React.useState(false);
const [showTfMenu, setShowTfMenu] = React.useState(false);
@@ -1097,6 +1108,17 @@ const Toolbar: React.FC<ToolbarProps> = ({
<IcFibAuto />
</button>
{/* 9·20일 신고가·신저가 표시 */}
{onTogglePriceExtremeLabels && (
<button
className={`tv-icon-btn${priceExtremeLabelsVisible ? ' active' : ''}`}
onClick={onTogglePriceExtremeLabels}
title={priceExtremeLabelsVisible ? '9·20일 신고가·신저가 숨기기' : '9·20일 신고가·신저가 표시'}
>
<IcPriceExtreme />
</button>
)}
{/* 그리드 토글 */}
<button
className={`tv-icon-btn${showGrid ? ' active' : ''}`}
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import type { ChartManager } from '../utils/ChartManager';
import {
layoutStackedSignalLabels,
@@ -95,6 +96,18 @@ function renderTradeSignalLabels(
const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [portalTarget, setPortalTarget] = useState<HTMLElement | null>(null);
const redrawRafRef = useRef(0);
useEffect(() => {
const container = manager.getContainer();
if (container) {
setPortalTarget(container);
return;
}
const tid = window.setTimeout(() => setPortalTarget(manager.getContainer()), 100);
return () => clearTimeout(tid);
}, [manager]);
const redraw = useCallback(() => {
const canvas = canvasRef.current;
@@ -120,11 +133,18 @@ const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
}, [manager]);
useEffect(() => {
if (!portalTarget) return;
redraw();
let layoutRaf = 0;
/** 패닝·스크롤 중 매 이벤트 즉시 + 다음 프레임 보정 (cancelAnimationFrame 미사용) */
const scheduleRedraw = () => {
cancelAnimationFrame(layoutRaf);
layoutRaf = requestAnimationFrame(redraw);
redraw();
if (redrawRafRef.current) return;
redrawRafRef.current = requestAnimationFrame(() => {
redrawRafRef.current = 0;
redraw();
});
};
const unsubs = [
@@ -137,19 +157,23 @@ const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
const ro = new ResizeObserver(scheduleRedraw);
if (container) ro.observe(container);
const raf1 = requestAnimationFrame(redraw);
const raf2 = requestAnimationFrame(() => requestAnimationFrame(redraw));
const scrollWrap = container?.closest('.tv-chart-wrap') as HTMLElement | null;
scrollWrap?.addEventListener('scroll', scheduleRedraw, { passive: true });
return () => {
unsubs.forEach(u => u());
ro.disconnect();
cancelAnimationFrame(layoutRaf);
cancelAnimationFrame(raf1);
cancelAnimationFrame(raf2);
scrollWrap?.removeEventListener('scroll', scheduleRedraw);
if (redrawRafRef.current) {
cancelAnimationFrame(redrawRafRef.current);
redrawRafRef.current = 0;
}
};
}, [manager, redraw]);
}, [manager, portalTarget, redraw]);
return (
if (!portalTarget) return null;
return createPortal(
<canvas
ref={canvasRef}
className="trade-signal-label-overlay"
@@ -160,7 +184,8 @@ const TradeSignalLabelOverlay: React.FC<Props> = ({ manager }) => {
pointerEvents: 'none',
zIndex: 8,
}}
/>
/>,
portalTarget,
);
};
+8
View File
@@ -180,6 +180,9 @@ interface TradingChartProps {
custom1OverlayActive?: boolean;
onCustom1OverlayActiveChange?: (active: boolean) => void;
onOpenCustom1OverlaySettings?: () => void;
/** 9·20일 신고가·신저가 라벨 표시 (실시간 차트 우측 툴바) */
priceExtremeLabelsVisible?: boolean;
onTogglePriceExtremeLabels?: () => void;
/** 미니 차트 — plot 너비에 맞는 초기 x축 캔들 수 (미지정 시 DISPLAY_COUNT) */
resolveInitialDisplayCount?: (plotWidth: number) => number;
/** true — reload·layout 복구 타이머의 반복 viewport 재조정 생략 (미니 카드 깜빡임 방지) */
@@ -243,6 +246,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
custom1OverlayActive,
onCustom1OverlayActiveChange,
onOpenCustom1OverlaySettings,
priceExtremeLabelsVisible,
onTogglePriceExtremeLabels,
resolveInitialDisplayCount,
skipViewportRecovery = false,
auxIndicatorOnly = false,
@@ -1161,6 +1166,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
managerRef.current?.panByPixelDelta(dx, dy, {
allowVerticalPan: allowVertical,
});
managerRef.current?.notifyViewportChanged();
};
const onPanPointerUp = (e: PointerEvent) => {
@@ -1827,6 +1833,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
custom1OverlayActive={custom1OverlayActive}
onCustom1OverlayActiveChange={onCustom1OverlayActiveChange}
onOpenCustom1OverlaySettings={onOpenCustom1OverlaySettings}
priceExtremeLabelsVisible={priceExtremeLabelsVisible}
onTogglePriceExtremeLabels={onTogglePriceExtremeLabels}
onSplit={onSplitIndicatorPane}
onRemove={onRemoveIndicator}
onDuplicate={onDuplicateIndicator}
@@ -1,16 +1,25 @@
import React from 'react';
import type { StrategyTemplateDef } from '../../utils/strategyPresets';
import type { UserStrategyTemplate } from '../../utils/strategyTemplateStorage';
import type { PaletteItem } from '../../utils/strategyPaletteStorage';
import {
templateBadgeForSource,
type TemplatePaletteSource,
} from '../../utils/strategyEditorTemplatePaletteRows';
export type { TemplatePaletteSource };
export type TemplatePaletteRow = {
key: string;
source: 'user' | 'builtin';
source: TemplatePaletteSource;
label: string;
description?: string;
signal: 'buy' | 'sell';
user?: UserStrategyTemplate;
builtinId?: string;
builtin?: StrategyTemplateDef;
paletteItem?: PaletteItem;
sidewaysFilterId?: string;
};
interface Props {
@@ -91,7 +100,9 @@ export default function TemplatePaletteTab({
{rows.length === 0 ? (
<div className="se-template-empty"> </div>
) : (
rows.map(row => (
rows.map(row => {
const sourceBadge = templateBadgeForSource(row.source);
return (
<div
key={row.key}
className={`se-template-row${selectedKey === row.key ? ' se-template-row--selected' : ''}`}
@@ -108,7 +119,11 @@ export default function TemplatePaletteTab({
>
<div className="se-template-item">
<span className="se-template-label">
{row.source === 'user' && <span className="se-template-user-badge"> 릿</span>}
{sourceBadge && (
<span className={`se-template-source-badge se-template-source-badge--${row.source}`}>
{sourceBadge}
</span>
)}
{row.label}
</span>
{row.description && (
@@ -119,7 +134,8 @@ export default function TemplatePaletteTab({
</span>
</div>
</div>
))
);
})
)}
</div>
</div>
@@ -481,6 +481,7 @@ const StrategyEvaluationChart: React.FC<Props> = ({
crosshairInfoVisible={false}
showHoverToolbar={false}
showChartRightToolbar={false}
showCandlePaneControls={false}
/>
{chartMgr && (
<StrategyEvaluationBarSelector