goldenChat base source add
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 관리자 설정 패널 — 비밀번호 확인 후 콘텐츠 표시
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { verifyAdminPassword } from '../utils/backendApi';
|
||||
import { isAdminUnlocked, setAdminUnlocked } from '../utils/adminUnlock';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const AdminPasswordGate: React.FC<Props> = ({ children }) => {
|
||||
const [unlocked, setUnlocked] = useState(isAdminUnlocked);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
if (unlocked) return <>{children}</>;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await verifyAdminPassword(password);
|
||||
setAdminUnlocked();
|
||||
setUnlocked(true);
|
||||
setPassword('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '비밀번호 확인에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-gate">
|
||||
<div className="admin-gate-card">
|
||||
<h3 className="admin-gate-title">관리자 인증</h3>
|
||||
<p className="admin-gate-desc">
|
||||
관리자 설정을 보려면 로그인한 관리자 계정의 비밀번호를 입력하세요.
|
||||
(기본: admin / admin)
|
||||
</p>
|
||||
<form onSubmit={submit} className="admin-gate-form">
|
||||
<label className="admin-gate-field">
|
||||
<span>관리자 비밀번호</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
className="admin-gate-field--visible"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="비밀번호"
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="admin-gate-error">{error}</p>}
|
||||
<button type="submit" className="admin-gate-btn" disabled={loading || !password}>
|
||||
{loading ? '확인 중…' : '확인'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPasswordGate;
|
||||
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* 관리자 설정 — 사용자 CRUD · 역할별 메뉴 권한
|
||||
*/
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
createAdminUser,
|
||||
deleteAdminUser,
|
||||
fetchRolePermissions,
|
||||
listAdminUsers,
|
||||
saveRolePermissions,
|
||||
updateAdminUser,
|
||||
type UserDto,
|
||||
} from '../utils/backendApi';
|
||||
import {
|
||||
ALL_MENU_IDS,
|
||||
MENU_LABELS,
|
||||
ROLE_LABELS,
|
||||
type UserRole,
|
||||
} from '../utils/permissions';
|
||||
|
||||
const ROLES: UserRole[] = ['ADMIN', 'USER', 'GUEST'];
|
||||
|
||||
const AdminSettingsPanel: React.FC = () => {
|
||||
const [users, setUsers] = useState<UserDto[]>([]);
|
||||
const [rolePerms, setRolePerms] = useState<Record<string, Record<string, boolean>>>({});
|
||||
const [permRole, setPermRole] = useState<UserRole>('USER');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const [formMode, setFormMode] = useState<'create' | 'edit' | null>(null);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [fUsername, setFUsername] = useState('');
|
||||
const [fPassword, setFPassword] = useState('');
|
||||
const [fDisplayName, setFDisplayName] = useState('');
|
||||
const [fRole, setFRole] = useState<UserRole>('USER');
|
||||
const [fEnabled, setFEnabled] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const [u, p] = await Promise.all([listAdminUsers(), fetchRolePermissions()]);
|
||||
setUsers(u);
|
||||
setRolePerms(p);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : '데이터 로드 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void refresh(); }, [refresh]);
|
||||
|
||||
const openCreate = () => {
|
||||
setFormMode('create');
|
||||
setEditId(null);
|
||||
setFUsername('');
|
||||
setFPassword('');
|
||||
setFDisplayName('');
|
||||
setFRole('USER');
|
||||
setFEnabled(true);
|
||||
};
|
||||
|
||||
const openEdit = (u: UserDto) => {
|
||||
setFormMode('edit');
|
||||
setEditId(u.id);
|
||||
setFUsername(u.username);
|
||||
setFPassword('');
|
||||
setFDisplayName(u.displayName);
|
||||
setFRole((u.role as UserRole) || 'USER');
|
||||
setFEnabled(u.enabled);
|
||||
};
|
||||
|
||||
const submitUser = async () => {
|
||||
setErr(null);
|
||||
setMsg(null);
|
||||
try {
|
||||
if (formMode === 'create') {
|
||||
await createAdminUser({
|
||||
username: fUsername.trim(),
|
||||
password: fPassword,
|
||||
displayName: fDisplayName.trim() || fUsername.trim(),
|
||||
role: fRole,
|
||||
enabled: fEnabled,
|
||||
});
|
||||
setMsg('사용자가 등록되었습니다.');
|
||||
} else if (formMode === 'edit' && editId != null) {
|
||||
await updateAdminUser(editId, {
|
||||
displayName: fDisplayName.trim(),
|
||||
role: fRole,
|
||||
enabled: fEnabled,
|
||||
...(fPassword ? { password: fPassword } : {}),
|
||||
});
|
||||
setMsg('사용자 정보가 저장되었습니다.');
|
||||
}
|
||||
setFormMode(null);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : '저장 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const removeUser = async (id: number) => {
|
||||
if (!window.confirm('이 사용자를 삭제하시겠습니까?')) return;
|
||||
setErr(null);
|
||||
try {
|
||||
await deleteAdminUser(id);
|
||||
setMsg('삭제되었습니다.');
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : '삭제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const savePerms = async () => {
|
||||
const perms = rolePerms[permRole];
|
||||
if (!perms) return;
|
||||
setErr(null);
|
||||
try {
|
||||
await saveRolePermissions(permRole, perms);
|
||||
setMsg(`${ROLE_LABELS[permRole]} 역할 권한이 저장되었습니다.`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : '권한 저장 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const togglePerm = (menuId: string) => {
|
||||
setRolePerms(prev => ({
|
||||
...prev,
|
||||
[permRole]: {
|
||||
...(prev[permRole] ?? {}),
|
||||
[menuId]: !(prev[permRole]?.[menuId]),
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
if (loading && users.length === 0) {
|
||||
return <p className="stg-loading">관리자 데이터 로딩 중…</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-settings">
|
||||
{msg && <p className="admin-msg admin-msg--ok">{msg}</p>}
|
||||
{err && <p className="admin-msg admin-msg--err">{err}</p>}
|
||||
|
||||
<section className="stg-section">
|
||||
<h3 className="stg-section-title">사용자 관리</h3>
|
||||
<div className="admin-toolbar">
|
||||
<button type="button" className="stg-btn stg-btn--primary" onClick={openCreate}>
|
||||
+ 사용자 등록
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>아이디</th>
|
||||
<th>표시명</th>
|
||||
<th>역할</th>
|
||||
<th>상태</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.displayName}</td>
|
||||
<td><span className={`admin-role admin-role--${u.role.toLowerCase()}`}>{ROLE_LABELS[u.role as UserRole] ?? u.role}</span></td>
|
||||
<td>{u.enabled ? '활성' : '비활성'}</td>
|
||||
<td className="admin-actions">
|
||||
<button type="button" className="admin-link" onClick={() => openEdit(u)}>수정</button>
|
||||
<button type="button" className="admin-link admin-link--danger" onClick={() => removeUser(u.id)}>삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{formMode && (
|
||||
<section className="stg-section admin-form-section">
|
||||
<h3 className="stg-section-title">{formMode === 'create' ? '사용자 등록' : '사용자 수정'}</h3>
|
||||
<div className="admin-form-grid">
|
||||
{formMode === 'create' && (
|
||||
<label>
|
||||
<span>아이디</span>
|
||||
<input value={fUsername} onChange={e => setFUsername(e.target.value)} />
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
<span>{formMode === 'create' ? '비밀번호' : '새 비밀번호 (변경 시만)'}</span>
|
||||
<input type="text" autoComplete="off" className="stg-input stg-input--sensitive" value={fPassword} onChange={e => setFPassword(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>표시명</span>
|
||||
<input value={fDisplayName} onChange={e => setFDisplayName(e.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>역할</span>
|
||||
<select value={fRole} onChange={e => setFRole(e.target.value as UserRole)}>
|
||||
{ROLES.map(r => (
|
||||
<option key={r} value={r}>{ROLE_LABELS[r]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-check">
|
||||
<input type="checkbox" checked={fEnabled} onChange={e => setFEnabled(e.target.checked)} />
|
||||
<span>활성 계정</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-form-actions">
|
||||
<button type="button" className="stg-btn stg-btn--primary" onClick={() => void submitUser()}>저장</button>
|
||||
<button type="button" className="stg-btn" onClick={() => setFormMode(null)}>취소</button>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="stg-section">
|
||||
<h3 className="stg-section-title">역할별 메뉴 접근 권한</h3>
|
||||
<p className="stg-row-desc" style={{ marginBottom: 12 }}>
|
||||
역할마다 상단 메뉴·설정 하위 메뉴·알림 접근을 제어합니다. 비로그인 사용자는 게스트(GUEST) 권한이 적용됩니다.
|
||||
</p>
|
||||
<div className="admin-perm-role-tabs">
|
||||
{ROLES.map(r => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
className={`admin-perm-tab${permRole === r ? ' active' : ''}`}
|
||||
onClick={() => setPermRole(r)}
|
||||
>{ROLE_LABELS[r]}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-perm-grid">
|
||||
{ALL_MENU_IDS.map(menuId => (
|
||||
<label key={menuId} className="admin-perm-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rolePerms[permRole]?.[menuId] === true}
|
||||
onChange={() => togglePerm(menuId)}
|
||||
/>
|
||||
<span>{MENU_LABELS[menuId] ?? menuId}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="stg-btn stg-btn--primary" style={{ marginTop: 12 }} onClick={() => void savePerms()}>
|
||||
{ROLE_LABELS[permRole]} 권한 저장
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminSettingsPanel;
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* 채도·명도 영역 + Hue 슬라이더 + 스포이드 + RGB 입력 (TradingView 스타일)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
hexToHsv,
|
||||
hexToRgb,
|
||||
hsvToHex,
|
||||
hsvToRgb,
|
||||
normalizeHex6,
|
||||
rgbToHex,
|
||||
rgbToHsv,
|
||||
svPanelBackground,
|
||||
HUE_SLIDER_BG,
|
||||
type Hsv,
|
||||
} from '../utils/colorSpaceUtils';
|
||||
|
||||
export interface AdvancedColorPickerProps {
|
||||
hex6: string;
|
||||
onChange: (hex6: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
function useHueDrag(onHue: (h: number) => void) {
|
||||
const dragging = useRef(false);
|
||||
|
||||
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
dragging.current = true;
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const t = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0;
|
||||
onHue(clamp01(t) * 360);
|
||||
}, [onHue]);
|
||||
|
||||
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragging.current) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const t = rect.width > 0 ? (e.clientX - rect.left) / rect.width : 0;
|
||||
onHue(clamp01(t) * 360);
|
||||
}, [onHue]);
|
||||
|
||||
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
||||
dragging.current = false;
|
||||
try { e.currentTarget.releasePointerCapture(e.pointerId); } catch { /* noop */ }
|
||||
}, []);
|
||||
|
||||
return { onPointerDown, onPointerMove, onPointerUp };
|
||||
}
|
||||
|
||||
const AdvancedColorPicker: React.FC<AdvancedColorPickerProps> = ({
|
||||
hex6,
|
||||
onChange,
|
||||
className = '',
|
||||
}) => {
|
||||
const [hsv, setHsv] = useState<Hsv>(() => hexToHsv(normalizeHex6(hex6)));
|
||||
const [rgb, setRgb] = useState(() => hexToRgb(normalizeHex6(hex6)));
|
||||
const [colorMode, setColorMode] = useState<'rgb' | 'hex'>('rgb');
|
||||
const skipSync = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipSync.current) {
|
||||
skipSync.current = false;
|
||||
return;
|
||||
}
|
||||
const h = hexToHsv(normalizeHex6(hex6));
|
||||
setHsv(h);
|
||||
setRgb(hexToRgb(normalizeHex6(hex6)));
|
||||
}, [hex6]);
|
||||
|
||||
const emitHsv = useCallback((next: Hsv) => {
|
||||
const h = { h: next.h, s: clamp01(next.s), v: clamp01(next.v) };
|
||||
setHsv(h);
|
||||
const r = hsvToRgb(h);
|
||||
setRgb(r);
|
||||
skipSync.current = true;
|
||||
onChange(hsvToHex(h));
|
||||
}, [onChange]);
|
||||
|
||||
const emitRgb = useCallback((r: number, g: number, b: number) => {
|
||||
const next = { r, g, b };
|
||||
setRgb(next);
|
||||
setHsv(rgbToHsv(next));
|
||||
skipSync.current = true;
|
||||
onChange(rgbToHex(r, g, b));
|
||||
}, [onChange]);
|
||||
|
||||
const hueDrag = useHueDrag(h => emitHsv({ ...hsv, h }));
|
||||
|
||||
const handleSvPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const update = (cx: number, cy: number) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const s = clamp01(rect.width > 0 ? (cx - rect.left) / rect.width : 0);
|
||||
const v = clamp01(rect.height > 0 ? 1 - (cy - rect.top) / rect.height : 0);
|
||||
emitHsv({ ...hsv, s, v });
|
||||
};
|
||||
update(e.clientX, e.clientY);
|
||||
const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY);
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
};
|
||||
|
||||
const pickEyedropper = async () => {
|
||||
const EyeDropperCtor = (window as Window & { EyeDropper?: new () => { open: () => Promise<{ sRGBHex: string }> } }).EyeDropper;
|
||||
if (!EyeDropperCtor) return;
|
||||
try {
|
||||
const result = await new EyeDropperCtor().open();
|
||||
if (result?.sRGBHex) {
|
||||
const hex = normalizeHex6(result.sRGBHex);
|
||||
emitHsv(hexToHsv(hex));
|
||||
}
|
||||
} catch {
|
||||
/* 사용자 취소 */
|
||||
}
|
||||
};
|
||||
|
||||
const pointerX = `${hsv.s * 100}%`;
|
||||
const pointerY = `${(1 - hsv.v) * 100}%`;
|
||||
const hueLeft = `${(hsv.h / 360) * 100}%`;
|
||||
|
||||
return (
|
||||
<div className={`acp-root ${className}`.trim()}>
|
||||
<div
|
||||
className="acp-sv-panel"
|
||||
style={{ background: svPanelBackground(hsv.h) }}
|
||||
onPointerDown={handleSvPointerDown}
|
||||
role="presentation"
|
||||
>
|
||||
<span
|
||||
className="acp-sv-pointer"
|
||||
style={{ left: pointerX, top: pointerY }}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="acp-controls-row">
|
||||
<button
|
||||
type="button"
|
||||
className="acp-eyedropper"
|
||||
title="스포이드"
|
||||
aria-label="스포이드로 색상 선택"
|
||||
onClick={() => void pickEyedropper()}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M20.71 5.63l-2.34-2.34a1 1 0 00-1.42 0l-3.12 3.12-1.93-1.91a1 1 0 00-1.41 0l-1.83 1.83a1 1 0 000 1.41l1.91 1.91-6.36 6.36a1 1 0 000 1.41l2.12 2.12a1 1 0 001.41 0l6.36-6.36 1.91 1.91a1 1 0 001.41 0l1.83-1.83a1 1 0 000-1.41l-1.92-1.9 3.12-3.12a1 1 0 000-1.42z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<span
|
||||
className="acp-preview"
|
||||
style={{ background: normalizeHex6(hex6) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="acp-hue-track"
|
||||
style={{ background: HUE_SLIDER_BG }}
|
||||
onPointerDown={hueDrag.onPointerDown}
|
||||
onPointerMove={hueDrag.onPointerMove}
|
||||
onPointerUp={hueDrag.onPointerUp}
|
||||
role="slider"
|
||||
aria-label="색상(Hue)"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={360}
|
||||
aria-valuenow={Math.round(hsv.h)}
|
||||
>
|
||||
<span className="acp-hue-thumb" style={{ left: hueLeft }} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="acp-rgb-row">
|
||||
{colorMode === 'rgb' ? (
|
||||
<>
|
||||
{(['r', 'g', 'b'] as const).map(ch => (
|
||||
<label key={ch} className="acp-rgb-field">
|
||||
<input
|
||||
type="number"
|
||||
className="acp-rgb-input"
|
||||
min={0}
|
||||
max={255}
|
||||
value={rgb[ch]}
|
||||
onChange={e => {
|
||||
const v = Number(e.target.value);
|
||||
if (Number.isNaN(v)) return;
|
||||
emitRgb(
|
||||
ch === 'r' ? v : rgb.r,
|
||||
ch === 'g' ? v : rgb.g,
|
||||
ch === 'b' ? v : rgb.b,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="acp-rgb-label">{ch.toUpperCase()}</span>
|
||||
</label>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<label className="acp-rgb-field acp-hex-field">
|
||||
<input
|
||||
type="text"
|
||||
className="acp-rgb-input acp-hex-input"
|
||||
value={normalizeHex6(hex6).slice(1)}
|
||||
maxLength={6}
|
||||
onChange={e => {
|
||||
const raw = e.target.value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 6);
|
||||
if (raw.length === 6) {
|
||||
emitHsv(hexToHsv('#' + raw));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="acp-rgb-label">HEX</span>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="acp-mode-toggle"
|
||||
title={colorMode === 'rgb' ? 'HEX 입력' : 'RGB 입력'}
|
||||
aria-label="색상 입력 방식 전환"
|
||||
onClick={() => setColorMode(m => (m === 'rgb' ? 'hex' : 'rgb'))}
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdvancedColorPicker;
|
||||
@@ -0,0 +1,59 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface AlertManagerProps {
|
||||
alerts: Array<{ price: number; label: string }>;
|
||||
onAdd: (price: number, label: string) => void;
|
||||
onRemove: (price: number) => void;
|
||||
onClose: () => void;
|
||||
currentPrice: number;
|
||||
}
|
||||
|
||||
const AlertManager: React.FC<AlertManagerProps> = ({ alerts, onAdd, onRemove, onClose, currentPrice }) => {
|
||||
const [priceInput, setPriceInput] = useState(currentPrice.toFixed(2));
|
||||
const [labelInput, setLabelInput] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
const price = parseFloat(priceInput);
|
||||
if (!isNaN(price) && price > 0) {
|
||||
onAdd(price, labelInput.trim());
|
||||
setLabelInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="alert-panel">
|
||||
<div className="alert-header">
|
||||
<span className="alert-title">🔔 가격 알림</span>
|
||||
<button className="alert-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="alert-add">
|
||||
<input
|
||||
className="alert-input"
|
||||
type="number"
|
||||
value={priceInput}
|
||||
onChange={e => setPriceInput(e.target.value)}
|
||||
placeholder="가격"
|
||||
/>
|
||||
<input
|
||||
className="alert-input"
|
||||
value={labelInput}
|
||||
onChange={e => setLabelInput(e.target.value)}
|
||||
placeholder="메모 (선택)"
|
||||
/>
|
||||
<button className="alert-add-btn" onClick={handleAdd}>추가</button>
|
||||
</div>
|
||||
<div className="alert-body">
|
||||
{alerts.length === 0 && <div className="alert-empty">설정된 알림 없음</div>}
|
||||
{alerts.map((a, i) => (
|
||||
<div key={i} className="alert-item">
|
||||
<span className="alert-price">{a.price.toFixed(2)}</span>
|
||||
{a.label && <span className="alert-label">{a.label}</span>}
|
||||
<button className="alert-remove" onClick={() => onRemove(a.price)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertManager;
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 전역 알림 UI — 모든 화면 위에 표시 (body 포털)
|
||||
*/
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import TradeSignalSnackbar from './TradeSignalSnackbar';
|
||||
import TradeAlertModal from './TradeAlertModal';
|
||||
import { useTradeNotification } from '../contexts/TradeNotificationContext';
|
||||
import {
|
||||
normalizeTradeAlertGridCols,
|
||||
normalizeTradeAlertPopupLayout,
|
||||
normalizeTradeAlertPopupPosition,
|
||||
} from '../utils/tradeAlertPopupLayout';
|
||||
import type { MenuPage } from './TopMenuBar';
|
||||
|
||||
interface Props {
|
||||
menuPage: MenuPage;
|
||||
onPage: (page: MenuPage) => void;
|
||||
onGoToChart: (market: string) => void;
|
||||
tradingMode?: string;
|
||||
hasUpbitKeys?: boolean;
|
||||
paperTradingEnabled?: boolean;
|
||||
liveAutoTradeBudgetPct?: number;
|
||||
paperAutoTradeBudgetPct?: number;
|
||||
tradeAlertPopupPosition?: string;
|
||||
tradeAlertPopupLayout?: string;
|
||||
tradeAlertPopupGridCols?: number;
|
||||
onOrderDone?: () => void;
|
||||
}
|
||||
|
||||
export const AppNotificationLayer: React.FC<Props> = ({
|
||||
menuPage,
|
||||
onPage,
|
||||
onGoToChart,
|
||||
tradingMode,
|
||||
hasUpbitKeys,
|
||||
paperTradingEnabled,
|
||||
liveAutoTradeBudgetPct,
|
||||
paperAutoTradeBudgetPct,
|
||||
tradeAlertPopupPosition = 'right',
|
||||
tradeAlertPopupLayout = 'stack',
|
||||
tradeAlertPopupGridCols = 2,
|
||||
onOrderDone,
|
||||
}) => {
|
||||
const { detailSignal, detailCentered, closeDetail } = useTradeNotification();
|
||||
const popupPosition = normalizeTradeAlertPopupPosition(tradeAlertPopupPosition);
|
||||
const popupLayout = normalizeTradeAlertPopupLayout(tradeAlertPopupLayout);
|
||||
const gridCols = normalizeTradeAlertGridCols(tradeAlertPopupGridCols);
|
||||
|
||||
const content = (
|
||||
<div className="app-notification-layer" aria-hidden={false}>
|
||||
<TradeSignalSnackbar
|
||||
onGoToChart={market => {
|
||||
onGoToChart(market);
|
||||
onPage('chart');
|
||||
}}
|
||||
onOpenList={() => onPage('notifications')}
|
||||
popupPosition={popupPosition}
|
||||
popupLayout={popupLayout}
|
||||
gridCols={gridCols}
|
||||
/>
|
||||
{detailSignal && (
|
||||
<TradeAlertModal
|
||||
key={`${detailSignal.market}-${detailSignal.candleTime}-${detailSignal.signalType}`}
|
||||
signal={detailSignal}
|
||||
centered={detailCentered}
|
||||
onClose={closeDetail}
|
||||
tradingMode={tradingMode}
|
||||
hasUpbitKeys={hasUpbitKeys}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
liveAutoTradeBudgetPct={liveAutoTradeBudgetPct}
|
||||
paperAutoTradeBudgetPct={paperAutoTradeBudgetPct}
|
||||
onOrderDone={onOrderDone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(content, document.body);
|
||||
};
|
||||
|
||||
export default AppNotificationLayer;
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* BacktestHistoryPage
|
||||
* ─────────────────────────────────────────────────────
|
||||
* [좌측] 체크박스 선택 + 삭제 가능한 이력 목록
|
||||
* [우측] 카드 기반 대시보드 결과 뷰
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
loadBacktestResults,
|
||||
deleteBacktestResult,
|
||||
type BacktestResultRecord,
|
||||
type BacktestAnalysis,
|
||||
type BacktestSignal,
|
||||
} from '../utils/backendApi';
|
||||
import { BacktestDashboard } from './BacktestResultModal';
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const pct = (v: number) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '–';
|
||||
|
||||
const fmtDateTime = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return {
|
||||
date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
|
||||
time: `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`,
|
||||
};
|
||||
};
|
||||
|
||||
// ── 아이콘 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const IcTrash = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<polyline points="1,3 13,3"/><line x1="5" y1="3" x2="5" y2="1"/><line x1="9" y1="3" x2="9" y2="1"/>
|
||||
<line x1="6.5" y1="1" x2="7.5" y2="1"/>
|
||||
<path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
|
||||
<line x1="5.5" y1="6" x2="5.5" y2="10"/><line x1="7" y1="6" x2="7" y2="10"/><line x1="8.5" y1="6" x2="8.5" y2="10"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcTrashAll = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<polyline points="1,3 13,3"/><path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
|
||||
<line x1="5.5" y1="6" x2="5.5" y2="10"/>
|
||||
<line x1="8.5" y1="6" x2="8.5" y2="10"/>
|
||||
<line x1="4" y1="1.5" x2="10" y2="1.5" strokeDasharray="1.5 1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcRefresh = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
|
||||
<path d="M12 7 A5 5 0 1 1 9.5 2.5"/>
|
||||
<polyline points="10,1 10,4 13,4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── 목록 아이템 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ListItemProps {
|
||||
r: BacktestResultRecord;
|
||||
checked: boolean;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ListItem({ r, checked, active, onToggle, onClick }: ListItemProps) {
|
||||
const ret = r.totalReturn ?? 0;
|
||||
const dt = fmtDateTime(r.createdAt);
|
||||
return (
|
||||
<div
|
||||
className={`bh-item ${active ? 'bh-item--active' : ''} ${checked ? 'bh-item--checked' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="bh-item-cb-wrap" onClick={e => { e.stopPropagation(); onToggle(); }}>
|
||||
<div className={`bh-cb ${checked ? 'bh-cb--on' : ''}`}>
|
||||
{checked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bh-item-body">
|
||||
<div className="bh-item-row1">
|
||||
<span className="bh-item-name">{r.strategyName || '전략 없음'}</span>
|
||||
<span className={`bh-item-ret ${ret > 0 ? 'bh-pos' : ret < 0 ? 'bh-neg' : ''}`}>{pct(ret)}</span>
|
||||
</div>
|
||||
<div className="bh-item-row2">
|
||||
<span className="bh-item-symbol">{r.symbol}</span>
|
||||
<span className="bh-item-sep">·</span>
|
||||
<span className="bh-item-tf">{r.timeframe}</span>
|
||||
<span className="bh-item-sep">·</span>
|
||||
<span className="bh-item-trades">{r.totalTrades}회</span>
|
||||
</div>
|
||||
<div className="bh-item-row3">
|
||||
<span className="bh-item-date">{dt.date}</span>
|
||||
<span className="bh-item-time">{dt.time}</span>
|
||||
{(r.winRate ?? 0) > 0 && (
|
||||
<span className="bh-item-wr">승률 {((r.winRate ?? 0) * 100).toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 메인 페이지 ───────────────────────────────────────────────────────────
|
||||
|
||||
export function BacktestHistoryPage() {
|
||||
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
|
||||
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
|
||||
const [checked, setChecked] = useState<Set<number>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// ── 로드 ────────────────────────────────────────────────────────────────
|
||||
const fetchList = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const list = await loadBacktestResults();
|
||||
setRecords(list);
|
||||
if (list.length > 0) setSelected(prev => prev ?? list[0]);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
// ── 체크박스 ──────────────────────────────────────────────────────────
|
||||
const toggleCheck = useCallback((id: number) => {
|
||||
setChecked(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setChecked(prev =>
|
||||
prev.size === records.length
|
||||
? new Set()
|
||||
: new Set(records.map(r => r.id))
|
||||
);
|
||||
}, [records]);
|
||||
|
||||
const allChecked = records.length > 0 && checked.size === records.length;
|
||||
const someChecked = checked.size > 0 && !allChecked;
|
||||
|
||||
// ── 삭제 ─────────────────────────────────────────────────────────────
|
||||
const deleteSelected = useCallback(async () => {
|
||||
if (checked.size === 0) return;
|
||||
if (!window.confirm(`선택한 ${checked.size}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
||||
await Promise.all([...checked].map(id => deleteBacktestResult(id)));
|
||||
setRecords(prev => {
|
||||
const next = prev.filter(r => !checked.has(r.id));
|
||||
if (selected && checked.has(selected.id)) setSelected(next[0] ?? null);
|
||||
return next;
|
||||
});
|
||||
setChecked(new Set());
|
||||
}, [checked, selected]);
|
||||
|
||||
const deleteAll = useCallback(async () => {
|
||||
if (records.length === 0) return;
|
||||
if (!window.confirm(`전체 ${records.length}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
|
||||
await Promise.all(records.map(r => deleteBacktestResult(r.id)));
|
||||
setRecords([]);
|
||||
setSelected(null);
|
||||
setChecked(new Set());
|
||||
}, [records]);
|
||||
|
||||
// ── 상세 데이터 파싱 ──────────────────────────────────────────────────
|
||||
const getDetail = (r: BacktestResultRecord) => {
|
||||
let analysis: BacktestAnalysis | null = null;
|
||||
let signals: BacktestSignal[] = [];
|
||||
try {
|
||||
if (r.analysisJson) analysis = JSON.parse(r.analysisJson);
|
||||
if (r.signalsJson) signals = JSON.parse(r.signalsJson);
|
||||
} catch (_) {}
|
||||
return { analysis, signals };
|
||||
};
|
||||
|
||||
// ── 렌더 ─────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="bh-page">
|
||||
|
||||
{/* ── 좌측 목록 ── */}
|
||||
<aside className="bh-sidebar">
|
||||
|
||||
{/* 사이드바 헤더 */}
|
||||
<div className="bh-sidebar-header">
|
||||
<div className="bh-sidebar-title-row">
|
||||
<span className="bh-sidebar-title">백테스팅 이력</span>
|
||||
<span className="bh-sidebar-count">{records.length}</span>
|
||||
</div>
|
||||
<div className="bh-sidebar-actions">
|
||||
<button
|
||||
className="bh-act-btn"
|
||||
title="선택 항목 삭제"
|
||||
disabled={checked.size === 0}
|
||||
onClick={deleteSelected}
|
||||
>
|
||||
<IcTrash />
|
||||
{checked.size > 0 && <span className="bh-act-badge">{checked.size}</span>}
|
||||
</button>
|
||||
<button
|
||||
className="bh-act-btn bh-act-btn--danger"
|
||||
title="전체 삭제"
|
||||
disabled={records.length === 0}
|
||||
onClick={deleteAll}
|
||||
>
|
||||
<IcTrashAll />
|
||||
</button>
|
||||
<button
|
||||
className="bh-act-btn"
|
||||
title="새로고침"
|
||||
onClick={fetchList}
|
||||
>
|
||||
<IcRefresh />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 전체 선택 바 */}
|
||||
{records.length > 0 && (
|
||||
<div className="bh-select-all-bar" onClick={toggleAll}>
|
||||
<div className={`bh-cb ${allChecked ? 'bh-cb--on' : someChecked ? 'bh-cb--indeterminate' : ''}`}>
|
||||
{allChecked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
|
||||
{someChecked && <div className="bh-cb-dash"/>}
|
||||
</div>
|
||||
<span className="bh-select-all-label">
|
||||
{someChecked || allChecked ? `${checked.size}개 선택됨` : '전체 선택'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 목록 */}
|
||||
{loading ? (
|
||||
<div className="bh-loading">
|
||||
<div className="bh-loading-spinner"/>
|
||||
<span>로딩 중…</span>
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="bh-empty-list">
|
||||
<div className="bh-empty-icon">📊</div>
|
||||
<p>백테스팅 이력이 없습니다.</p>
|
||||
<p className="bh-empty-sub">차트 화면에서 전략을 선택하고<br/>백테스팅을 실행하세요.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bh-list">
|
||||
{records.map(r => (
|
||||
<ListItem
|
||||
key={r.id}
|
||||
r={r}
|
||||
checked={checked.has(r.id)}
|
||||
active={selected?.id === r.id}
|
||||
onToggle={() => toggleCheck(r.id)}
|
||||
onClick={() => setSelected(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* ── 우측 대시보드 ── */}
|
||||
<main className="bh-content">
|
||||
{selected ? (
|
||||
(() => {
|
||||
const { analysis, signals } = getDetail(selected);
|
||||
return (
|
||||
<div className="bh-detail">
|
||||
<BacktestDashboard
|
||||
analysis={analysis}
|
||||
signals={signals}
|
||||
strategyName={selected.strategyName}
|
||||
symbol={selected.symbol}
|
||||
timeframe={selected.timeframe}
|
||||
barCount={selected.barCount}
|
||||
createdAt={selected.createdAt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="bh-no-selection">
|
||||
<div className="bh-no-sel-icon">📈</div>
|
||||
<p>좌측 목록에서 백테스팅 결과를 선택하세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BacktestHistoryPage;
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 백테스팅 결과 통계 배지 — 차트 위에 오버레이로 표시.
|
||||
* 전략 선택 / 실행은 Toolbar의 드롭다운에서 담당.
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { BacktestStats } from '../utils/backendApi';
|
||||
|
||||
interface BacktestPanelProps {
|
||||
stats: BacktestStats | null;
|
||||
signalCount: number;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
const BacktestPanel: React.FC<BacktestPanelProps> = ({ stats, signalCount, onClear }) => {
|
||||
const fmt = (n: number) => (n * 100).toFixed(2) + '%';
|
||||
const fmtWon = (n: number) => n >= 1000 ? (n / 10000).toFixed(0) + '만원' : n.toFixed(0) + '원';
|
||||
const retColor = stats && stats.totalReturn > 0 ? '#26A69A' : stats && stats.totalReturn < 0 ? '#EF5350' : 'var(--text2)';
|
||||
|
||||
return (
|
||||
<div className="bt-result-badge">
|
||||
<div className="bt-result-row">
|
||||
<span className="bt-result-label">시그널</span>
|
||||
<span className="bt-result-val">{signalCount}개</span>
|
||||
</div>
|
||||
{stats && (
|
||||
<>
|
||||
<div className="bt-result-sep" />
|
||||
<div className="bt-result-row">
|
||||
<span className="bt-result-label">거래</span>
|
||||
<span className="bt-result-val">{stats.totalTrades}회</span>
|
||||
</div>
|
||||
<div className="bt-result-row">
|
||||
<span className="bt-result-label">승률</span>
|
||||
<span className="bt-result-val">{fmt(stats.winRate)}</span>
|
||||
</div>
|
||||
<div className="bt-result-row">
|
||||
<span className="bt-result-label">수익</span>
|
||||
<span className="bt-result-val" style={{ color: retColor }}>
|
||||
{stats.totalReturn >= 0 ? '+' : ''}{fmt(stats.totalReturn)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bt-result-row">
|
||||
<span className="bt-result-label">낙폭</span>
|
||||
<span className="bt-result-val" style={{ color: '#EF5350' }}>{fmt(stats.maxDrawdown)}</span>
|
||||
</div>
|
||||
{stats.finalEquity > 0 && (
|
||||
<div className="bt-result-row">
|
||||
<span className="bt-result-label">최종자산</span>
|
||||
<span className="bt-result-val" style={{ color: retColor }}>{fmtWon(stats.finalEquity)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button className="bt-result-clear" onClick={onClear} title="결과 지우기">✕</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacktestPanel;
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* BacktestResultModal / BacktestDashboard
|
||||
* ─────────────────────────────────────────────────────────────────────
|
||||
* BacktestDashboard — 카드 기반 대시보드 (재사용 가능)
|
||||
* BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
// ── 포맷 유틸 ────────────────────────────────────────────────────────────────
|
||||
|
||||
const pct = (v: number, dec = 2) =>
|
||||
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '–';
|
||||
const pctAbs = (v: number, dec = 1) =>
|
||||
isFinite(v) && v !== 0 ? `${(v * 100).toFixed(dec)}%` : '–';
|
||||
const num = (v: number, dec = 2) =>
|
||||
isFinite(v) && v !== 0 ? v.toFixed(dec) : '–';
|
||||
const wonFmt = (v: number) => {
|
||||
if (!isFinite(v) || v === 0) return '–';
|
||||
const abs = Math.abs(Math.round(v));
|
||||
const s = abs >= 100_000_000 ? `${(abs/100_000_000).toFixed(2)}억`
|
||||
: abs >= 10_000 ? `${(abs/10_000).toFixed(1)}만`
|
||||
: abs.toLocaleString();
|
||||
return (v >= 0 ? '+' : '-') + s + '원';
|
||||
};
|
||||
const fmtDate = (ts: number) => {
|
||||
const d = new Date(ts * 1000);
|
||||
return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')}`;
|
||||
};
|
||||
const fmtDateStr = (iso: string) => {
|
||||
const d = new Date(iso);
|
||||
return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||
};
|
||||
|
||||
const colorCls = (v: number) => v > 0 ? 'brd-pos' : v < 0 ? 'brd-neg' : '';
|
||||
const colorStyle = (v: number): React.CSSProperties => ({
|
||||
color: v > 0 ? 'var(--brd-pos)' : v < 0 ? 'var(--brd-neg)' : undefined,
|
||||
});
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BacktestResultModalProps {
|
||||
analysis?: BacktestAnalysis | null;
|
||||
stats?: BacktestStats | null;
|
||||
signals?: BacktestSignal[];
|
||||
strategyName: string;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
barCount: number;
|
||||
onClose: () => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export interface BacktestDashboardProps {
|
||||
analysis: BacktestAnalysis | null;
|
||||
signals: BacktestSignal[];
|
||||
strategyName: string;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
barCount: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
// ── 섹션 제목 ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SectionTitle({ icon, title, right }: { icon: string; title: string; right?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="brd-section-title">
|
||||
<span className="brd-section-icon">{icon}</span>
|
||||
<span className="brd-section-text">{title}</span>
|
||||
{right && <span className="brd-section-right">{right}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── KPI 카드 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function KpiCard({ icon, label, value, sub, valueColor, accent }:
|
||||
{ icon: string; label: string; value: string; sub: string; valueColor?: string; accent?: string }) {
|
||||
return (
|
||||
<div className={`brd-kpi-card ${accent ? `brd-kpi-card--${accent}` : ''}`}>
|
||||
<div className="brd-kpi-top">
|
||||
<span className="brd-kpi-icon">{icon}</span>
|
||||
<span className="brd-kpi-label">{label}</span>
|
||||
</div>
|
||||
<div className={`brd-kpi-value ${valueColor ?? ''}`}>{value}</div>
|
||||
<div className="brd-kpi-sub">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 미니 지표 행 (테이블 스타일) ─────────────────────────────────────────
|
||||
|
||||
function MetricRow({ label, value, cls, note }: { label: string; value: string; cls?: string; note?: string }) {
|
||||
return (
|
||||
<div className="brd-metric-row">
|
||||
<span className="brd-metric-label">
|
||||
{label}
|
||||
{note && <span className="brd-metric-note">{note}</span>}
|
||||
</span>
|
||||
<span className={`brd-metric-value ${cls ?? ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 승률 도넛 (CSS 원) ────────────────────────────────────────────────────
|
||||
|
||||
function WinRateCircle({ winRate, winning, total }: { winRate: number; winning: number; total: number }) {
|
||||
const r = 28;
|
||||
const circ = 2 * Math.PI * r;
|
||||
const pct2 = Math.max(0, Math.min(1, winRate));
|
||||
const dash = pct2 * circ;
|
||||
return (
|
||||
<div className="brd-donut-wrap">
|
||||
<svg width="72" height="72" viewBox="0 0 72 72">
|
||||
<circle cx="36" cy="36" r={r} fill="none" stroke="var(--bg4)" strokeWidth="8"/>
|
||||
<circle
|
||||
cx="36" cy="36" r={r} fill="none"
|
||||
stroke={pct2 >= 0.5 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
|
||||
strokeWidth="8"
|
||||
strokeDasharray={`${dash} ${circ}`}
|
||||
strokeLinecap="round"
|
||||
strokeDashoffset={circ * 0.25}
|
||||
style={{ transform: 'rotate(-90deg)', transformOrigin: '50% 50%' }}
|
||||
/>
|
||||
<text x="36" y="33" textAnchor="middle" fontSize="11" fontWeight="800" fill="var(--text)">{pctAbs(winRate)}</text>
|
||||
<text x="36" y="45" textAnchor="middle" fontSize="8.5" fill="var(--text3)">승률</text>
|
||||
</svg>
|
||||
<div className="brd-donut-detail">
|
||||
<span className="brd-pos">▲ {winning}</span>
|
||||
<span className="brd-neg">▼ {total - winning}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 수평 비교 바 ─────────────────────────────────────────────────────────
|
||||
|
||||
function CompareBar({ labelA, valA, labelB, valB, colorA, colorB }:
|
||||
{ labelA: string; valA: number; labelB: string; valB: number; colorA: string; colorB: string }) {
|
||||
const max = Math.max(Math.abs(valA), Math.abs(valB), 0.01);
|
||||
const pctA2 = Math.min(100, Math.abs(valA) / max * 100);
|
||||
const pctB2 = Math.min(100, Math.abs(valB) / max * 100);
|
||||
return (
|
||||
<div className="brd-compare">
|
||||
<div className="brd-compare-row">
|
||||
<span className="brd-compare-label">{labelA}</span>
|
||||
<div className="brd-compare-track">
|
||||
<div className="brd-compare-bar" style={{ width: `${pctA2}%`, background: colorA }}/>
|
||||
</div>
|
||||
<span className="brd-compare-val" style={{ color: colorA }}>{pct(valA)}</span>
|
||||
</div>
|
||||
<div className="brd-compare-row">
|
||||
<span className="brd-compare-label">{labelB}</span>
|
||||
<div className="brd-compare-track">
|
||||
<div className="brd-compare-bar" style={{ width: `${pctB2}%`, background: colorB }}/>
|
||||
</div>
|
||||
<span className="brd-compare-val" style={{ color: colorB }}>{pct(valB)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 시그널 타입 ───────────────────────────────────────────────────────────
|
||||
|
||||
const SIG_LABEL: Record<string, string> = {
|
||||
BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도',
|
||||
};
|
||||
const SIG_COLOR: Record<string, string> = {
|
||||
BUY:'brd-pos', SELL:'brd-neg', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn',
|
||||
};
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// BacktestDashboard — 메인 대시보드 컴포넌트 (재사용 가능)
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function BacktestDashboard({
|
||||
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
|
||||
}: BacktestDashboardProps) {
|
||||
const [sigExpanded, setSigExpanded] = useState(false);
|
||||
|
||||
const a = analysis;
|
||||
if (!a) {
|
||||
return (
|
||||
<div className="brd-empty-state">
|
||||
<div>📊</div>
|
||||
<p>결과 데이터가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sigLimit = sigExpanded ? signals.length : 8;
|
||||
const periodStr = signals.length >= 2
|
||||
? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}`
|
||||
: `${barCount}봉`;
|
||||
|
||||
const sharpeLevel = a.sharpeRatio >= 2 ? '탁월' : a.sharpeRatio >= 1 ? '우수' : a.sharpeRatio >= 0 ? '보통' : '미흡';
|
||||
|
||||
return (
|
||||
<div className="brd-dashboard">
|
||||
|
||||
{/* ── 대시보드 헤더 ── */}
|
||||
<div className="brd-dash-header">
|
||||
<div className="brd-dash-header-left">
|
||||
<div className="brd-dash-strategy">{strategyName}</div>
|
||||
<div className="brd-dash-meta">
|
||||
<span className="brd-dash-badge">{symbol}</span>
|
||||
<span className="brd-dash-badge">{timeframe}</span>
|
||||
<span className="brd-dash-badge">{periodStr}</span>
|
||||
{createdAt && <span className="brd-dash-badge brd-dash-badge--time">실행 {fmtDateStr(createdAt)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="brd-dash-header-kpi">
|
||||
<div className="brd-dash-big-kpi">
|
||||
<div className={`brd-dash-big-val ${colorCls(a.totalReturnPct)}`}>{pct(a.totalReturnPct)}</div>
|
||||
<div className="brd-dash-big-label">총 수익률</div>
|
||||
</div>
|
||||
<div className="brd-dash-big-kpi">
|
||||
<div className={`brd-dash-big-val ${colorCls(a.finalEquity - a.initialCapital)}`}>
|
||||
{wonFmt(a.finalEquity)}
|
||||
</div>
|
||||
<div className="brd-dash-big-label">최종 자산</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Row 1: KPI 카드 6개 ── */}
|
||||
<div className="brd-row">
|
||||
<div className="brd-kpi-grid">
|
||||
<KpiCard icon="🎯" label="승률" value={pctAbs(a.winRate)} sub={`${a.numberOfWinning}승 ${a.numberOfLosing}패`} valueColor={colorCls(a.winRate - 0.5)} accent="blue"/>
|
||||
<KpiCard icon="📉" label="최대 낙폭" value={pct(a.maxDrawdownPct)} sub="기간 중 최대 손실" valueColor={a.maxDrawdownPct < 0 ? 'brd-neg' : ''} accent="red"/>
|
||||
<KpiCard icon="⚡" label="샤프 비율" value={num(a.sharpeRatio)} sub={sharpeLevel} valueColor={colorCls(a.sharpeRatio - 1)} accent="yellow"/>
|
||||
<KpiCard icon="🔄" label="소르티노" value={num(a.sortinoRatio)} sub="하방 변동성 기준" valueColor={colorCls(a.sortinoRatio - 1)} accent="purple"/>
|
||||
<KpiCard icon="⚖️" label="손익비" value={num(a.profitLossRatio)} sub="이익/손실 배수" valueColor={colorCls(a.profitLossRatio - 1)} accent="green"/>
|
||||
<KpiCard icon="📐" label="칼마 비율" value={num(a.calmarRatio)} sub="수익 / |MDD|" valueColor={colorCls(a.calmarRatio - 1)} accent="teal"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Row 2: 수익성 + 거래 통계 + 승률 도넛 ── */}
|
||||
<div className="brd-row brd-row--3col">
|
||||
|
||||
{/* 수익성 지표 카드 */}
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="📊" title="수익성 지표"/>
|
||||
<div className="brd-card-body">
|
||||
<MetricRow label="총 수익률" value={pct(a.totalReturnPct)} cls={colorCls(a.totalReturnPct)}/>
|
||||
<MetricRow label="총 손익 (금액)" value={wonFmt(a.totalProfitLoss)} cls={colorCls(a.totalProfitLoss)}/>
|
||||
<MetricRow label="총 이익" value={wonFmt(a.grossProfit)} cls="brd-pos"/>
|
||||
<MetricRow label="총 손실" value={wonFmt(a.grossLoss)} cls="brd-neg"/>
|
||||
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
|
||||
<MetricRow label="최대 상승폭" value={pct(a.maxRunupPct)} cls="brd-pos"/>
|
||||
<MetricRow label="손익분기 거래" value={`${a.numberOfBreakEven}회`}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 리스크 지표 카드 */}
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="🛡" title="리스크 지표"/>
|
||||
<div className="brd-card-body">
|
||||
<MetricRow label="최대 낙폭 (MDD)" value={pct(a.maxDrawdownPct)} cls={colorCls(a.maxDrawdownPct)} note="고점 대비"/>
|
||||
<MetricRow label="샤프 비율" value={num(a.sharpeRatio)} cls={colorCls(a.sharpeRatio - 1)} note="≥1 우수"/>
|
||||
<MetricRow label="소르티노 비율" value={num(a.sortinoRatio)} cls={colorCls(a.sortinoRatio - 1)} note="하방 기준"/>
|
||||
<MetricRow label="칼마 비율" value={num(a.calmarRatio)} cls={colorCls(a.calmarRatio - 1)}/>
|
||||
<MetricRow label="VaR 95%" value={pct(a.valueAtRisk95)} cls="brd-neg" note="최대 손실 예상"/>
|
||||
<MetricRow label="기대 손실 (CVaR)" value={pct(a.expectedShortfall)} cls="brd-neg" note="꼬리 리스크"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 승률 + 자본 카드 */}
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="🔢" title="거래 통계"/>
|
||||
<div className="brd-card-body brd-card-body--center">
|
||||
<WinRateCircle winRate={a.winRate} winning={a.numberOfWinning} total={a.numberOfPositions}/>
|
||||
<div className="brd-trade-stats">
|
||||
<MetricRow label="총 거래" value={`${a.numberOfPositions}회`}/>
|
||||
<MetricRow label="수익 거래" value={`${a.numberOfWinning}회`} cls="brd-pos"/>
|
||||
<MetricRow label="손실 거래" value={`${a.numberOfLosing}회`} cls="brd-neg"/>
|
||||
<MetricRow label="초기 자본" value={`${Math.round(a.initialCapital/10000).toLocaleString()}만원`}/>
|
||||
<MetricRow label="최종 자산" value={`${Math.round(a.finalEquity/10000).toLocaleString()}만원`} cls={colorCls(a.finalEquity - a.initialCapital)}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Row 3: 벤치마크 비교 + 자본 분석 ── */}
|
||||
<div className="brd-row brd-row--2col">
|
||||
|
||||
{/* 벤치마크 비교 */}
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="📐" title="벤치마크 비교" right={
|
||||
<span className={`brd-badge-inline ${colorCls(a.vsBuyAndHold - 1)}`}>
|
||||
{isFinite(a.vsBuyAndHold) && a.vsBuyAndHold !== 0
|
||||
? `${a.vsBuyAndHold >= 1 ? '전략 우위 ' : ''}${a.vsBuyAndHold.toFixed(2)}×`
|
||||
: '–'
|
||||
}
|
||||
</span>
|
||||
}/>
|
||||
<div className="brd-card-body">
|
||||
<CompareBar
|
||||
labelA="전략 수익률" valA={a.totalReturnPct}
|
||||
labelB="바이앤홀드" valB={a.buyAndHoldReturnPct}
|
||||
colorA={a.totalReturnPct >= 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
|
||||
colorB="var(--text3)"
|
||||
/>
|
||||
<div className="brd-bench-summary">
|
||||
<div className="brd-bench-item">
|
||||
<span className="brd-bench-label">전략</span>
|
||||
<span className={`brd-bench-val ${colorCls(a.totalReturnPct)}`}>{pct(a.totalReturnPct)}</span>
|
||||
</div>
|
||||
<div className="brd-bench-divider"/>
|
||||
<div className="brd-bench-item">
|
||||
<span className="brd-bench-label">바이앤홀드</span>
|
||||
<span className={`brd-bench-val ${colorCls(a.buyAndHoldReturnPct)}`}>{pct(a.buyAndHoldReturnPct)}</span>
|
||||
</div>
|
||||
<div className="brd-bench-divider"/>
|
||||
<div className="brd-bench-item">
|
||||
<span className="brd-bench-label">초과 수익</span>
|
||||
<span className={`brd-bench-val ${colorCls(a.totalReturnPct - a.buyAndHoldReturnPct)}`}>
|
||||
{pct(a.totalReturnPct - a.buyAndHoldReturnPct)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수익/손실 상세 */}
|
||||
<div className="brd-card">
|
||||
<SectionTitle icon="💰" title="손익 분석"/>
|
||||
<div className="brd-card-body">
|
||||
<div className="brd-pnl-bar-section">
|
||||
<div className="brd-pnl-row">
|
||||
<span className="brd-pnl-label brd-pos">총 이익</span>
|
||||
<div className="brd-pnl-track">
|
||||
<div className="brd-pnl-bar brd-pnl-bar--pos" style={{
|
||||
width: `${a.grossProfit > 0 && Math.abs(a.grossLoss) > 0
|
||||
? Math.min(100, a.grossProfit / (a.grossProfit + Math.abs(a.grossLoss)) * 100)
|
||||
: a.grossProfit > 0 ? 100 : 0}%`
|
||||
}}/>
|
||||
</div>
|
||||
<span className="brd-pnl-val brd-pos">{wonFmt(a.grossProfit)}</span>
|
||||
</div>
|
||||
<div className="brd-pnl-row">
|
||||
<span className="brd-pnl-label brd-neg">총 손실</span>
|
||||
<div className="brd-pnl-track">
|
||||
<div className="brd-pnl-bar brd-pnl-bar--neg" style={{
|
||||
width: `${Math.abs(a.grossLoss) > 0 && a.grossProfit > 0
|
||||
? Math.min(100, Math.abs(a.grossLoss) / (a.grossProfit + Math.abs(a.grossLoss)) * 100)
|
||||
: Math.abs(a.grossLoss) > 0 ? 100 : 0}%`
|
||||
}}/>
|
||||
</div>
|
||||
<span className="brd-pnl-val brd-neg">{wonFmt(a.grossLoss)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="brd-pnl-net">
|
||||
<span className="brd-pnl-net-label">순 손익</span>
|
||||
<span className={`brd-pnl-net-val ${colorCls(a.totalProfitLoss)}`}>{wonFmt(a.totalProfitLoss)}</span>
|
||||
</div>
|
||||
<MetricRow label="손익비" value={num(a.profitLossRatio)} cls={colorCls(a.profitLossRatio - 1)}/>
|
||||
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Row 4: 거래 시그널 목록 ── */}
|
||||
<div className="brd-row">
|
||||
<div className="brd-card brd-card--full">
|
||||
<SectionTitle icon="📋" title="거래 시그널" right={
|
||||
<span className="brd-section-count">{signals.length}건</span>
|
||||
}/>
|
||||
{signals.length === 0 ? (
|
||||
<div className="brd-empty-msg">시그널이 없습니다.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="brd-sig-scroll">
|
||||
<table className="brd-sig-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{width:36}}>#</th>
|
||||
<th>날짜</th>
|
||||
<th>구분</th>
|
||||
<th style={{textAlign:'right'}}>가격</th>
|
||||
<th style={{textAlign:'center',width:50}}>봉#</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.slice(0, sigLimit).map((s, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? 'brd-sig-even' : ''}>
|
||||
<td style={{textAlign:'center',color:'var(--text3)'}}>{i+1}</td>
|
||||
<td style={{fontVariantNumeric:'tabular-nums'}}>{fmtDate(s.time)}</td>
|
||||
<td>
|
||||
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
|
||||
{SIG_LABEL[s.type] ?? s.type}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',fontWeight:600}}>
|
||||
{Math.round(s.price).toLocaleString()}
|
||||
</td>
|
||||
<td style={{textAlign:'center',color:'var(--text3)'}}>{s.barIndex}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{signals.length > 8 && (
|
||||
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
|
||||
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
// BacktestResultModal — 팝업 래퍼
|
||||
// ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export function BacktestResultModal({
|
||||
analysis, stats, signals = [], strategyName, symbol, timeframe, barCount, onClose, embedded = false,
|
||||
}: BacktestResultModalProps) {
|
||||
|
||||
const {
|
||||
panelRef: modalRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: !embedded });
|
||||
|
||||
const a = analysis ?? (stats ? buildAnalysisFromStats(stats) : null);
|
||||
if (!a && !stats) return null;
|
||||
|
||||
const inner = (
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`brm-modal ${embedded ? 'brm-modal--embedded' : ''}`}
|
||||
style={embedded ? undefined : {
|
||||
...panelStyle,
|
||||
zIndex: 9200,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={embedded ? undefined : e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header brm-header"
|
||||
onPointerDown={embedded ? undefined : onHeaderPointerDown}
|
||||
style={embedded ? undefined : { cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<div className="brm-header-left">
|
||||
<span className="brm-header-title">백테스팅 결과</span>
|
||||
</div>
|
||||
{!embedded && (
|
||||
<button className="brm-close" onClick={onClose} title="닫기">✕</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="brm-body">
|
||||
<BacktestDashboard
|
||||
analysis={a}
|
||||
signals={signals}
|
||||
strategyName={strategyName}
|
||||
symbol={symbol}
|
||||
timeframe={timeframe}
|
||||
barCount={barCount}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (embedded) return inner;
|
||||
|
||||
return (
|
||||
<div className="brm-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
{inner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildAnalysisFromStats(s: BacktestStats): BacktestAnalysis {
|
||||
return {
|
||||
initialCapital: 10_000_000,
|
||||
finalEquity: s.finalEquity ?? 0,
|
||||
totalReturnPct: s.totalReturn ?? 0,
|
||||
totalProfitLoss: (s.finalEquity ?? 0) - 10_000_000,
|
||||
grossProfit: 0, grossLoss: 0,
|
||||
avgReturnPct: s.avgReturn ?? 0,
|
||||
profitLossRatio: 0,
|
||||
numberOfPositions: s.totalTrades ?? 0,
|
||||
numberOfWinning: s.winTrades ?? 0,
|
||||
numberOfLosing: (s.totalTrades ?? 0) - (s.winTrades ?? 0),
|
||||
numberOfBreakEven: 0,
|
||||
winRate: s.winRate ?? 0,
|
||||
maxDrawdownPct: s.maxDrawdown ?? 0,
|
||||
maxRunupPct: 0, sharpeRatio: 0, sortinoRatio: 0, calmarRatio: 0,
|
||||
valueAtRisk95: 0, expectedShortfall: 0,
|
||||
buyAndHoldReturnPct: 0, vsBuyAndHold: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default BacktestResultModal;
|
||||
@@ -0,0 +1,428 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import {
|
||||
BacktestSettingsDto,
|
||||
DEFAULT_BACKTEST_SETTINGS,
|
||||
loadBacktestSettings,
|
||||
saveBacktestSettings,
|
||||
} from '../utils/backendApi';
|
||||
|
||||
// ── 설명 메타데이터 ─────────────────────────────────────────────────────────
|
||||
|
||||
interface FieldMeta {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FIELD_META: Partial<Record<keyof BacktestSettingsDto, FieldMeta>> = {
|
||||
initialCapital: {
|
||||
label: '초기 자본',
|
||||
description:
|
||||
'백테스팅 시뮬레이션의 시작 자본금(원)입니다. 수익/손실 금액과 최종 자산을 이 금액 기준으로 계산합니다.\n예) 1,000만원 설정 → 총 수익률 +15% 달성 시 최종 자산 = 1,150만원으로 표시됩니다.',
|
||||
},
|
||||
commissionType: {
|
||||
label: '수수료 유형',
|
||||
description:
|
||||
'매매 수수료 계산 방식을 선택합니다.\n• LINEAR: 거래금액 × 수수료율로 계산합니다. 국내 거래소 현물거래에 적합합니다.\n• ZERO: 수수료 없이 계산합니다. 순수 전략 성과만 검증할 때 사용합니다.',
|
||||
},
|
||||
commissionRate: {
|
||||
label: '수수료율',
|
||||
description:
|
||||
'매수·매도 각 1회당 부과되는 수수료율입니다.\n예) 0.15% = 0.0015, 거래금액 100만원일 때 수수료 1,500원\n• 업비트 현물: 약 0.05% (0.0005)\n• 빗썸 현물: 약 0.25% (0.0025)\n왕복(매수+매도) 합산 수수료가 손익에서 차감됩니다.',
|
||||
},
|
||||
slippageRate: {
|
||||
label: '슬리피지',
|
||||
description:
|
||||
'주문을 낼 때 의도한 가격과 실제 체결 가격의 차이 비율입니다.\n• 매수: 목표가보다 슬리피지만큼 높게 체결 (불리)\n• 매도: 목표가보다 슬리피지만큼 낮게 체결 (불리)\n예) 슬리피지 0.05%, 100만원 매수 → 실제 체결 1,000,500원\n거래량이 적거나 변동성이 높을수록 슬리피지가 커집니다.',
|
||||
},
|
||||
entryPriceType: {
|
||||
label: '진입 가격 기준',
|
||||
description:
|
||||
'매수 신호 발생 시 어느 가격에 진입하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 진입. 구현이 단순하지만 후행성(look-ahead bias) 우려가 있습니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 진입. 실제 트레이딩과 가장 유사한 현실적 시뮬레이션입니다.',
|
||||
},
|
||||
exitPriceType: {
|
||||
label: '청산 가격 기준',
|
||||
description:
|
||||
'매도 신호 발생 시 어느 가격에 청산하는지 결정합니다.\n• CLOSE(종가): 신호 발생 봉의 종가로 즉시 청산합니다.\n• NEXT_OPEN(다음봉 시가): 신호 다음 봉 시가에 청산. 야간 갭 하락 등 실제 리스크를 반영합니다.',
|
||||
},
|
||||
positionDirection: {
|
||||
label: '포지션 방향',
|
||||
description:
|
||||
'어떤 방향의 거래를 시뮬레이션할지 선택합니다.\n• LONG(매수만): 매수 후 가격 상승을 기대하는 전략입니다. 가장 일반적인 방식입니다.\n• SHORT(공매도): 매도 후 가격 하락을 기대합니다. 선물/마진 거래 전략 검증에 사용합니다.\n• BOTH(양방향): 매수 신호는 롱 진입, 매도 신호는 숏 진입으로 처리합니다.',
|
||||
},
|
||||
tradeSizeType: {
|
||||
label: '거래 규모 유형',
|
||||
description:
|
||||
'매 거래마다 사용할 자금 규모 방식을 선택합니다.\n• CAPITAL_PCT(자본 비율): 현재 보유 자본의 N%를 거래에 사용합니다. 복리 효과가 반영됩니다.\n• FIXED_AMOUNT(고정 금액): 매 거래마다 동일한 금액(원)을 사용합니다. 단리 방식과 유사합니다.',
|
||||
},
|
||||
tradeSizeValue: {
|
||||
label: '거래 규모 값',
|
||||
description:
|
||||
'자본 비율 선택 시: 매 거래에 사용할 자본 비율(%)입니다.\n예) 100 = 전체 자본 사용, 50 = 절반만 사용\n\n고정 금액 선택 시: 매 거래마다 사용할 원화 금액입니다.\n예) 1,000,000 = 매 거래당 100만원 고정 투자',
|
||||
},
|
||||
stopLossEnabled: {
|
||||
label: '손절 활성화',
|
||||
description:
|
||||
'진입가 대비 N% 하락 시 자동으로 포지션을 청산하는 기능입니다 (Ta4j StopLossRule).\n전략의 매도 조건보다 손절이 먼저 발동될 경우 손절로 청산됩니다.\n예) 손절 비율 2% 설정, 100만원 매수 → 가격이 98만원으로 하락 시 자동 손절',
|
||||
},
|
||||
stopLossPct: {
|
||||
label: '손절 비율 (%)',
|
||||
description:
|
||||
'진입가 대비 이 비율만큼 가격이 하락하면 즉시 포지션을 청산합니다.\n예) 2.0 설정 → 진입가 100,000원일 때 98,000원 이하로 하락 시 손절\n낮은 값: 손실 최소화 but 잦은 손절로 기회 상실 가능\n높은 값: 큰 손실 허용 but 반전 기회 포착 가능',
|
||||
},
|
||||
takeProfitEnabled: {
|
||||
label: '익절 활성화',
|
||||
description:
|
||||
'진입가 대비 N% 상승 시 자동으로 포지션을 청산하는 기능입니다 (Ta4j StopGainRule).\n목표 수익률에 도달하면 확실히 이익을 실현합니다.\n예) 익절 비율 5% 설정, 100만원 매수 → 가격이 105만원으로 상승 시 자동 익절',
|
||||
},
|
||||
takeProfitPct: {
|
||||
label: '익절 비율 (%)',
|
||||
description:
|
||||
'진입가 대비 이 비율만큼 가격이 상승하면 즉시 포지션을 청산합니다.\n예) 5.0 설정 → 진입가 100,000원일 때 105,000원 이상 상승 시 익절\n낮은 값: 자주 이익 실현 but 대세 상승장 추세 이탈 가능\n높은 값: 큰 수익 추구 but 수익 반납 위험 증가',
|
||||
},
|
||||
trailingStopEnabled: {
|
||||
label: '트레일링 스탑 활성화',
|
||||
description:
|
||||
'포지션 보유 중 도달한 최고가 대비 N% 하락 시 자동 청산하는 기능입니다 (Ta4j TrailingStopLossRule).\n고정 손절과 달리 추세를 따라 기준점이 올라가므로 상승 추세를 최대한 활용하면서 수익을 보호합니다.\n예) 100만원 매수, 트레일링 2% → 가격이 120만원까지 상승 후 2% 하락한 117.6만원에서 청산',
|
||||
},
|
||||
trailingStopPct: {
|
||||
label: '트레일링 비율 (%)',
|
||||
description:
|
||||
'포지션 진입 후 기록된 최고가 대비 이 비율만큼 하락하면 청산합니다.\n예) 2.0 설정, 최고가 120,000원 → 117,600원 (= 120,000 × 0.98) 이하 하락 시 청산\n낮은 값: 소폭 하락에도 청산, 수익 보호에 유리\n높은 값: 큰 조정을 허용, 장기 추세 추종에 유리',
|
||||
},
|
||||
reentryWaitBars: {
|
||||
label: '재진입 대기 봉 수',
|
||||
description:
|
||||
'포지션 청산(매도) 후 다음 매수 신호를 받을 때까지 최소 대기해야 하는 봉(캔들) 수입니다.\n연속 매매를 방지하고 시장이 안정되길 기다리는 효과가 있습니다.\n예) 3 설정 → 매도 후 3봉 동안은 매수 신호 무시\n0: 즉시 재진입 허용 (기본값)',
|
||||
},
|
||||
maxOpenTrades: {
|
||||
label: '최대 동시 포지션',
|
||||
description:
|
||||
'동시에 보유할 수 있는 최대 포지션(거래) 수입니다.\n현재 구현에서는 단일 포지션(1)만 완전히 지원됩니다.\n1로 설정 시: 이미 포지션을 보유 중이면 새 매수 신호를 무시합니다.',
|
||||
},
|
||||
partialExitEnabled: {
|
||||
label: '분할 청산 활성화',
|
||||
description:
|
||||
'첫 매도 신호 발생 시 포지션 전부가 아닌 일부만 청산하는 기능입니다.\n리스크를 줄이면서도 나머지 포지션으로 추가 상승을 노릴 수 있습니다.\n예) 분할 청산 50% 설정 → 첫 매도 신호에 50% 청산, 이후 다음 신호 또는 손절/익절 시 나머지 50% 청산',
|
||||
},
|
||||
partialExitPct: {
|
||||
label: '1차 청산 비율 (%)',
|
||||
description:
|
||||
'첫 번째 매도 신호 발생 시 현재 포지션의 몇 퍼센트를 청산할지 설정합니다.\n예) 50 설정 → 보유량의 절반을 1차 청산, 나머지는 계속 보유\n예) 30 설정 → 보유량의 30%만 1차 청산 (더 공격적 운용)',
|
||||
},
|
||||
};
|
||||
|
||||
// ── 섹션 정의 ─────────────────────────────────────────────────────────────
|
||||
|
||||
interface SettingField {
|
||||
key: keyof BacktestSettingsDto;
|
||||
type: 'number' | 'select' | 'toggle' | 'percent';
|
||||
opts?: { value: string; label: string }[];
|
||||
min?: number; max?: number; step?: number;
|
||||
/** 이 필드가 보이려면 이 조건이 true여야 함 */
|
||||
visibleWhen?: (cfg: BacktestSettingsDto) => boolean;
|
||||
}
|
||||
|
||||
interface SettingSection {
|
||||
title: string;
|
||||
fields: SettingField[];
|
||||
}
|
||||
|
||||
const SECTIONS: SettingSection[] = [
|
||||
{
|
||||
title: '💰 자본 설정',
|
||||
fields: [
|
||||
{ key: 'initialCapital', type: 'number', min: 100000, step: 1000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '💸 비용 모델',
|
||||
fields: [
|
||||
{
|
||||
key: 'commissionType', type: 'select',
|
||||
opts: [{ value:'LINEAR', label:'LINEAR (비율 수수료)' }, { value:'ZERO', label:'ZERO (수수료 없음)' }],
|
||||
},
|
||||
{ key: 'commissionRate', type: 'percent', min: 0, max: 5, step: 0.01,
|
||||
visibleWhen: c => c.commissionType === 'LINEAR' },
|
||||
{ key: 'slippageRate', type: 'percent', min: 0, max: 5, step: 0.01 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '📈 거래 가격',
|
||||
fields: [
|
||||
{
|
||||
key: 'entryPriceType', type: 'select',
|
||||
opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }],
|
||||
},
|
||||
{
|
||||
key: 'exitPriceType', type: 'select',
|
||||
opts: [{ value:'CLOSE', label:'종가 (Close)' }, { value:'NEXT_OPEN', label:'다음봉 시가 (Next Open)' }],
|
||||
},
|
||||
{
|
||||
key: 'positionDirection', type: 'select',
|
||||
opts: [
|
||||
{ value:'LONG', label:'LONG (매수만)' },
|
||||
{ value:'SHORT', label:'SHORT (공매도)' },
|
||||
{ value:'BOTH', label:'BOTH (양방향)' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '📦 거래 규모',
|
||||
fields: [
|
||||
{
|
||||
key: 'tradeSizeType', type: 'select',
|
||||
opts: [{ value:'CAPITAL_PCT', label:'자본 비율 (%)' }, { value:'FIXED_AMOUNT', label:'고정 금액 (원)' }],
|
||||
},
|
||||
{ key: 'tradeSizeValue', type: 'number', min: 1, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '🛑 손절',
|
||||
fields: [
|
||||
{ key: 'stopLossEnabled', type: 'toggle' },
|
||||
{ key: 'stopLossPct', type: 'number', min: 0.1, max: 50, step: 0.1,
|
||||
visibleWhen: c => !!c.stopLossEnabled },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '🎯 익절',
|
||||
fields: [
|
||||
{ key: 'takeProfitEnabled', type: 'toggle' },
|
||||
{ key: 'takeProfitPct', type: 'number', min: 0.1, max: 200, step: 0.1,
|
||||
visibleWhen: c => !!c.takeProfitEnabled },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '📉 트레일링 스탑',
|
||||
fields: [
|
||||
{ key: 'trailingStopEnabled', type: 'toggle' },
|
||||
{ key: 'trailingStopPct', type: 'number', min: 0.1, max: 50, step: 0.1,
|
||||
visibleWhen: c => !!c.trailingStopEnabled },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '🔁 재진입 제어',
|
||||
fields: [
|
||||
{ key: 'reentryWaitBars', type: 'number', min: 0, max: 100, step: 1 },
|
||||
{ key: 'maxOpenTrades', type: 'number', min: 1, max: 10, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '✂️ 분할 청산',
|
||||
fields: [
|
||||
{ key: 'partialExitEnabled', type: 'toggle' },
|
||||
{ key: 'partialExitPct', type: 'number', min: 10, max: 90, step: 5,
|
||||
visibleWhen: c => !!c.partialExitEnabled },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ── 컴포넌트 ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onSettingsChange: (s: BacktestSettingsDto) => void;
|
||||
}
|
||||
|
||||
export default function BacktestSettingsModal({ onClose, onSettingsChange }: Props) {
|
||||
const [cfg, setCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [focused, setFocused] = useState<keyof BacktestSettingsDto | null>('initialCapital');
|
||||
|
||||
const {
|
||||
panelRef: modalRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
// 설정 로드
|
||||
useEffect(() => {
|
||||
loadBacktestSettings().then(s => { if (s) setCfg(s); });
|
||||
}, []);
|
||||
|
||||
const set = <K extends keyof BacktestSettingsDto>(k: K, v: BacktestSettingsDto[K]) =>
|
||||
setCfg(prev => ({ ...prev, [k]: v }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const result = await saveBacktestSettings(cfg);
|
||||
if (result) {
|
||||
onSettingsChange({ ...cfg, ...result });
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1500);
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const focusMeta = focused ? FIELD_META[focused] : null;
|
||||
|
||||
// 모든 섹션의 보이는 필드를 1차원 배열로 수집 (2열 그리드용)
|
||||
const allFields: Array<{ section: string; field: SettingField }> = [];
|
||||
for (const sec of SECTIONS) {
|
||||
for (const f of sec.fields) {
|
||||
if (!f.visibleWhen || f.visibleWhen(cfg)) {
|
||||
allFields.push({ section: sec.title, field: f });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bts-modal"
|
||||
ref={modalRef}
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 9100,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* ── 타이틀바 (드래그 핸들) ── */}
|
||||
<div
|
||||
className="gc-popup-header bts-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" style={{opacity:.6}}>
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<path d="M8 1.5L8 3.5 M8 12.5L8 14.5 M1.5 8L3.5 8 M12.5 8L14.5 8
|
||||
M3.4 3.4L4.8 4.8 M11.2 11.2L12.6 12.6 M12.6 3.4L11.2 4.8 M4.8 11.2L3.4 12.6"
|
||||
strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span className="bts-title">백테스팅 설정</span>
|
||||
<button className="bts-close" onMouseDown={e => e.stopPropagation()} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{/* ── 2열 설정 그리드 ── */}
|
||||
<div className="bts-body">
|
||||
{SECTIONS.map(sec => {
|
||||
const visibleFields = sec.fields.filter(f => !f.visibleWhen || f.visibleWhen(cfg));
|
||||
if (visibleFields.length === 0) return null;
|
||||
return (
|
||||
<div key={sec.title} className="bts-section">
|
||||
<div className="bts-section-title">{sec.title}</div>
|
||||
<div className="bts-section-grid">
|
||||
{visibleFields.map(f => (
|
||||
<FieldRow
|
||||
key={f.key}
|
||||
field={f}
|
||||
cfg={cfg}
|
||||
focused={focused}
|
||||
onFocus={setFocused}
|
||||
onChange={set}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ── 하단 설명 패널 ── */}
|
||||
<div className="bts-desc-panel">
|
||||
{focusMeta ? (
|
||||
<>
|
||||
<span className="bts-desc-label">{focusMeta.label}</span>
|
||||
<span className="bts-desc-sep">—</span>
|
||||
<span className="bts-desc-text">{focusMeta.description}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="bts-desc-placeholder">설정 항목을 클릭하면 상세 설명이 표시됩니다.</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 푸터 ── */}
|
||||
<div className="bts-footer">
|
||||
<button className="bts-btn bts-btn-reset" onClick={() => setCfg(DEFAULT_BACKTEST_SETTINGS)}>
|
||||
기본값
|
||||
</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="bts-btn bts-btn-cancel" onClick={onClose}>취소</button>
|
||||
<button className="bts-btn bts-btn-save" onClick={handleSave} disabled={saving}>
|
||||
{saving ? '저장 중...' : saved ? '✓ 저장됨' : '확인'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── FieldRow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function FieldRow({
|
||||
field, cfg, focused, onFocus, onChange,
|
||||
}: {
|
||||
field: SettingField;
|
||||
cfg: BacktestSettingsDto;
|
||||
focused: keyof BacktestSettingsDto | null;
|
||||
onFocus: (k: keyof BacktestSettingsDto) => void;
|
||||
onChange: <K extends keyof BacktestSettingsDto>(k: K, v: BacktestSettingsDto[K]) => void;
|
||||
}) {
|
||||
const meta = FIELD_META[field.key];
|
||||
const label = meta?.label ?? field.key;
|
||||
const isFocused = focused === field.key;
|
||||
|
||||
const renderCtrl = () => {
|
||||
const val = cfg[field.key];
|
||||
switch (field.type) {
|
||||
case 'toggle':
|
||||
return (
|
||||
<button
|
||||
className={`bts-toggle${val ? ' bts-toggle-on' : ''}`}
|
||||
onClick={e => { e.stopPropagation(); onChange(field.key, !val as BacktestSettingsDto[typeof field.key]); }}
|
||||
>
|
||||
{val ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
className="bts-select"
|
||||
value={String(val)}
|
||||
onChange={e => onChange(field.key, e.target.value as BacktestSettingsDto[typeof field.key])}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{field.opts?.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
);
|
||||
case 'percent':
|
||||
return (
|
||||
<div className="bts-pct-wrap">
|
||||
<input
|
||||
type="number"
|
||||
className="bts-input"
|
||||
value={((val as number) * 100).toFixed(3)}
|
||||
min={field.min} max={field.max} step={field.step ?? 0.01}
|
||||
onChange={e => onChange(field.key, Number(e.target.value) / 100 as BacktestSettingsDto[typeof field.key])}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
<span className="bts-unit">%</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
className="bts-input"
|
||||
value={val as number}
|
||||
min={field.min} max={field.max} step={field.step ?? 1}
|
||||
onChange={e => onChange(field.key, Number(e.target.value) as BacktestSettingsDto[typeof field.key])}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`bts-row${isFocused ? ' bts-row-focused' : ''}`}
|
||||
onClick={() => onFocus(field.key)}
|
||||
>
|
||||
<span className="bts-row-label">{label}</span>
|
||||
<span className="bts-row-ctrl">{renderCtrl()}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* 설정 화면 — 백테스팅 설정 패널 (2열 카드 레이아웃)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
type BacktestSettingsDto,
|
||||
DEFAULT_BACKTEST_SETTINGS,
|
||||
loadBacktestSettings,
|
||||
saveBacktestSettings,
|
||||
} from '../utils/backendApi';
|
||||
|
||||
// ── 백테스팅 전용 UI 컴포넌트 ───────────────────────────────────────────────
|
||||
|
||||
const BtSection: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<section className="stg-bt-section">
|
||||
<h3 className="stg-bt-section-title">{title}</h3>
|
||||
<div className="stg-bt-section-body">{children}</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const BtGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div className="stg-bt-grid">{children}</div>
|
||||
);
|
||||
|
||||
const BtField: React.FC<{
|
||||
label: string;
|
||||
desc?: string;
|
||||
full?: boolean;
|
||||
children: React.ReactNode;
|
||||
}> = ({ label, desc, full, children }) => (
|
||||
<div className={`stg-bt-field${full ? ' stg-bt-field--full' : ''}`}>
|
||||
<div className="stg-bt-field-head">
|
||||
<span className="stg-bt-field-label">{label}</span>
|
||||
{desc && <span className="stg-bt-field-desc">{desc}</span>}
|
||||
</div>
|
||||
<div className="stg-bt-field-ctrl">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export interface BacktestSettingsPanelProps {
|
||||
btAutoPopup?: boolean;
|
||||
onBtAutoPopup?: (v: boolean) => void;
|
||||
btShowPrice?: boolean;
|
||||
onBtShowPrice?: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const BacktestSettingsPanel: React.FC<BacktestSettingsPanelProps> = ({
|
||||
btAutoPopup = true,
|
||||
onBtAutoPopup,
|
||||
btShowPrice = true,
|
||||
onBtShowPrice,
|
||||
}) => {
|
||||
const [cfg, setCfg] = useState<BacktestSettingsDto>(DEFAULT_BACKTEST_SETTINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadBacktestSettings().then(s => { if (s) setCfg(s); });
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (patch: Partial<BacktestSettingsDto>) => {
|
||||
const next = { ...cfg, ...patch };
|
||||
setCfg(next);
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
try {
|
||||
await saveBacktestSettings(next);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 1500);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [cfg]);
|
||||
|
||||
const field = <K extends keyof BacktestSettingsDto>(key: K, value: BacktestSettingsDto[K]) =>
|
||||
update({ [key]: value } as Partial<BacktestSettingsDto>);
|
||||
|
||||
return (
|
||||
<div className="stg-bt-panel">
|
||||
{(saving || saved) && (
|
||||
<div className={`stg-bt-status${saved ? ' stg-bt-status--ok' : ''}`}>
|
||||
{saving ? '저장 중…' : '✓ 저장됨'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BtSection title="표시 설정">
|
||||
<BtGrid>
|
||||
<BtField
|
||||
label="결과 팝업 자동 표시"
|
||||
desc="백테스팅 실행 완료 후 결과 팝업을 자동으로 띄웁니다. OFF이면 툴바의 '결과보기' 버튼을 직접 클릭해야 합니다."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={btAutoPopup} onChange={e => onBtAutoPopup?.(e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</BtField>
|
||||
<BtField
|
||||
label="매수/매도 금액 표시"
|
||||
desc="차트 마커에 체결 금액을 함께 표시합니다. ON: '매수 : 1,234,000' / OFF: '매수'만 표시."
|
||||
>
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={btShowPrice} onChange={e => onBtShowPrice?.(e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</BtField>
|
||||
</BtGrid>
|
||||
</BtSection>
|
||||
|
||||
<BtSection title="자본 · 거래 규모">
|
||||
<BtGrid>
|
||||
<BtField
|
||||
label="초기 자본 (원)"
|
||||
desc="백테스팅 시뮬레이션의 시작 자본금입니다. 수익/손실과 최종 자산을 이 금액 기준으로 계산합니다."
|
||||
>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number" min={100000} step={1000000}
|
||||
value={cfg.initialCapital}
|
||||
onChange={e => field('initialCapital', Number(e.target.value))}
|
||||
/>
|
||||
</BtField>
|
||||
<BtField label="거래 규모 유형" desc="자본 비율(복리) 또는 매 거래 고정 금액 중 선택합니다.">
|
||||
<select className="stg-select stg-select--wide"
|
||||
value={cfg.tradeSizeType}
|
||||
onChange={e => field('tradeSizeType', e.target.value as 'CAPITAL_PCT' | 'FIXED_AMOUNT')}>
|
||||
<option value="CAPITAL_PCT">자본 비율 (%)</option>
|
||||
<option value="FIXED_AMOUNT">고정 금액 (원)</option>
|
||||
</select>
|
||||
</BtField>
|
||||
<BtField
|
||||
label={cfg.tradeSizeType === 'CAPITAL_PCT' ? '사용 자본 비율 (%)' : '거래당 금액 (원)'}
|
||||
desc={cfg.tradeSizeType === 'CAPITAL_PCT'
|
||||
? '매 거래에 사용할 자본 비율. 100 = 전체 자본.'
|
||||
: '매 거래마다 사용할 원화 금액.'}
|
||||
>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number" min={1} step={cfg.tradeSizeType === 'CAPITAL_PCT' ? 1 : 100000}
|
||||
value={cfg.tradeSizeValue}
|
||||
onChange={e => field('tradeSizeValue', Number(e.target.value))}
|
||||
/>
|
||||
</BtField>
|
||||
</BtGrid>
|
||||
</BtSection>
|
||||
|
||||
<BtSection title="비용 모델">
|
||||
<BtGrid>
|
||||
<BtField label="수수료 유형" desc="LINEAR: 거래금액 × 수수료율. ZERO: 수수료 없음.">
|
||||
<select className="stg-select stg-select--wide"
|
||||
value={cfg.commissionType}
|
||||
onChange={e => field('commissionType', e.target.value as 'LINEAR' | 'ZERO')}>
|
||||
<option value="LINEAR">LINEAR (비율 수수료)</option>
|
||||
<option value="ZERO">ZERO (수수료 없음)</option>
|
||||
</select>
|
||||
</BtField>
|
||||
{cfg.commissionType === 'LINEAR' ? (
|
||||
<BtField label="수수료율 (%)" desc="매수·매도 각 1회당 수수료율. 업비트 현물 약 0.05%.">
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number" min={0} max={5} step={0.01}
|
||||
value={(cfg.commissionRate ?? 0) * 100}
|
||||
onChange={e => field('commissionRate', Number(e.target.value) / 100)}
|
||||
/>
|
||||
</BtField>
|
||||
) : (
|
||||
<div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />
|
||||
)}
|
||||
<BtField
|
||||
label="슬리피지 (%)"
|
||||
desc="의도 가격과 실제 체결 가격 차이. 매수는 높게, 매도는 낮게 체결되는 효과."
|
||||
>
|
||||
<input
|
||||
className="stg-input stg-input--wide"
|
||||
type="number" min={0} max={5} step={0.01}
|
||||
value={(cfg.slippageRate ?? 0) * 100}
|
||||
onChange={e => field('slippageRate', Number(e.target.value) / 100)}
|
||||
/>
|
||||
</BtField>
|
||||
</BtGrid>
|
||||
</BtSection>
|
||||
|
||||
<BtSection title="거래 가격 기준">
|
||||
<BtGrid>
|
||||
<BtField label="진입 가격 기준" desc="매수 신호 시 진입 가격. 종가 즉시 진입 또는 다음 봉 시가 진입.">
|
||||
<select className="stg-select stg-select--wide"
|
||||
value={cfg.entryPriceType}
|
||||
onChange={e => field('entryPriceType', e.target.value as 'CLOSE' | 'NEXT_OPEN')}>
|
||||
<option value="CLOSE">종가 (Close)</option>
|
||||
<option value="NEXT_OPEN">다음봉 시가 (Next Open)</option>
|
||||
</select>
|
||||
</BtField>
|
||||
<BtField label="청산 가격 기준" desc="매도 신호 시 청산 가격. 종가 즉시 청산 또는 다음 봉 시가 청산.">
|
||||
<select className="stg-select stg-select--wide"
|
||||
value={cfg.exitPriceType}
|
||||
onChange={e => field('exitPriceType', e.target.value as 'CLOSE' | 'NEXT_OPEN')}>
|
||||
<option value="CLOSE">종가 (Close)</option>
|
||||
<option value="NEXT_OPEN">다음봉 시가 (Next Open)</option>
|
||||
</select>
|
||||
</BtField>
|
||||
<BtField label="포지션 방향" desc="LONG(매수만), SHORT(공매도), BOTH(양방향) 중 선택.">
|
||||
<select className="stg-select stg-select--wide"
|
||||
value={cfg.positionDirection}
|
||||
onChange={e => field('positionDirection', e.target.value as 'LONG' | 'SHORT' | 'BOTH')}>
|
||||
<option value="LONG">LONG (매수만)</option>
|
||||
<option value="SHORT">SHORT (공매도)</option>
|
||||
<option value="BOTH">BOTH (양방향)</option>
|
||||
</select>
|
||||
</BtField>
|
||||
</BtGrid>
|
||||
</BtSection>
|
||||
|
||||
<BtSection title="리스크 관리">
|
||||
<BtGrid>
|
||||
<BtField label="손절 활성화" desc="진입가 대비 N% 하락 시 자동 청산 (StopLossRule).">
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={!!cfg.stopLossEnabled}
|
||||
onChange={e => field('stopLossEnabled', e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</BtField>
|
||||
{cfg.stopLossEnabled ? (
|
||||
<BtField label="손절 비율 (%)" desc="진입가 대비 하락 허용 비율.">
|
||||
<input className="stg-input stg-input--wide" type="number" min={0.1} max={50} step={0.1}
|
||||
value={cfg.stopLossPct ?? 2}
|
||||
onChange={e => field('stopLossPct', Number(e.target.value))} />
|
||||
</BtField>
|
||||
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
|
||||
|
||||
<BtField label="익절 활성화" desc="진입가 대비 N% 상승 시 자동 청산 (StopGainRule).">
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={!!cfg.takeProfitEnabled}
|
||||
onChange={e => field('takeProfitEnabled', e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</BtField>
|
||||
{cfg.takeProfitEnabled ? (
|
||||
<BtField label="익절 비율 (%)" desc="진입가 대비 상승 목표 비율.">
|
||||
<input className="stg-input stg-input--wide" type="number" min={0.1} max={200} step={0.1}
|
||||
value={cfg.takeProfitPct ?? 5}
|
||||
onChange={e => field('takeProfitPct', Number(e.target.value))} />
|
||||
</BtField>
|
||||
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
|
||||
|
||||
<BtField label="트레일링 스탑 활성화" desc="최고가 대비 N% 하락 시 자동 청산.">
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={!!cfg.trailingStopEnabled}
|
||||
onChange={e => field('trailingStopEnabled', e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</BtField>
|
||||
{cfg.trailingStopEnabled ? (
|
||||
<BtField label="트레일링 비율 (%)" desc="최고가 대비 하락 허용 비율.">
|
||||
<input className="stg-input stg-input--wide" type="number" min={0.1} max={50} step={0.1}
|
||||
value={cfg.trailingStopPct ?? 2}
|
||||
onChange={e => field('trailingStopPct', Number(e.target.value))} />
|
||||
</BtField>
|
||||
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
|
||||
</BtGrid>
|
||||
</BtSection>
|
||||
|
||||
<BtSection title="재진입 · 분할 청산">
|
||||
<BtGrid>
|
||||
<BtField label="재진입 대기 봉 수" desc="매도 후 다음 매수까지 최소 대기 캔들 수. 0 = 즉시 재진입.">
|
||||
<input className="stg-input stg-input--wide" type="number" min={0} max={100} step={1}
|
||||
value={cfg.reentryWaitBars ?? 0}
|
||||
onChange={e => field('reentryWaitBars', Number(e.target.value))} />
|
||||
</BtField>
|
||||
<BtField label="최대 동시 포지션" desc="동시 보유 가능한 최대 포지션 수 (현재 단일 포지션 지원).">
|
||||
<input className="stg-input stg-input--wide" type="number" min={1} max={10} step={1}
|
||||
value={cfg.maxOpenTrades ?? 1}
|
||||
onChange={e => field('maxOpenTrades', Number(e.target.value))} />
|
||||
</BtField>
|
||||
<BtField label="분할 청산 활성화" desc="첫 매도 신호 시 포지션의 일부만 청산합니다.">
|
||||
<label className="stg-toggle">
|
||||
<input type="checkbox" checked={!!cfg.partialExitEnabled}
|
||||
onChange={e => field('partialExitEnabled', e.target.checked)} />
|
||||
<span className="stg-toggle-slider" />
|
||||
</label>
|
||||
</BtField>
|
||||
{cfg.partialExitEnabled ? (
|
||||
<BtField label="1차 청산 비율 (%)" desc="첫 매도 시 청산할 포지션 비율.">
|
||||
<input className="stg-input stg-input--wide" type="number" min={10} max={90} step={5}
|
||||
value={cfg.partialExitPct ?? 50}
|
||||
onChange={e => field('partialExitPct', Number(e.target.value))} />
|
||||
</BtField>
|
||||
) : <div className="stg-bt-field stg-bt-field--placeholder" aria-hidden />}
|
||||
</BtGrid>
|
||||
</BtSection>
|
||||
|
||||
<BtSection title="포지션 종속성 모드">
|
||||
<BtField
|
||||
full
|
||||
label="매매 시그널 발생 조건"
|
||||
desc="매도 시그널이 이전 매수(포지션) 없이도 발생할지 선택합니다."
|
||||
>
|
||||
<div className="stg-bt-radio-group">
|
||||
<label className="stg-bt-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="bt-pos-mode"
|
||||
value="LONG_ONLY"
|
||||
checked={(cfg.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
|
||||
onChange={() => field('positionMode', 'LONG_ONLY')}
|
||||
/>
|
||||
<span className="stg-bt-radio-text">
|
||||
<strong>보유 자산 기준 (Long-Only)</strong>
|
||||
<span>매수 이력이 있을 때만 매도 허용 · Ta4j 표준 엄격 모드</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="stg-bt-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="bt-pos-mode"
|
||||
value="SIGNAL_ONLY"
|
||||
checked={cfg.positionMode === 'SIGNAL_ONLY'}
|
||||
onChange={() => field('positionMode', 'SIGNAL_ONLY')}
|
||||
/>
|
||||
<span className="stg-bt-radio-text">
|
||||
<strong>순수 지표 기준 (Signal-Only)</strong>
|
||||
<span>포지션 무관, 지표 규칙 충족 시 즉시 매도 · 락 우회 모드</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</BtField>
|
||||
{cfg.positionMode === 'SIGNAL_ONLY' && (
|
||||
<div className="stg-bt-hint stg-bt-hint--warn">
|
||||
Signal-Only 모드: 매도 시그널이 이전 매수 없이도 발생합니다
|
||||
</div>
|
||||
)}
|
||||
</BtSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacktestSettingsPanel;
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import type { Timeframe } from '../types';
|
||||
import TimezonePicker from './TimezonePicker';
|
||||
|
||||
interface BottomBarProps {
|
||||
timeframe: Timeframe;
|
||||
logScale: boolean;
|
||||
percentScale: boolean;
|
||||
showGrid: boolean;
|
||||
lastTime: number;
|
||||
displayTimezone: string;
|
||||
onTimezoneChange: (tz: string) => void;
|
||||
onTimeframe: (t: Timeframe) => void;
|
||||
onLogScale: () => void;
|
||||
onPercentScale: () => void;
|
||||
onAutoScale: () => void;
|
||||
onFitContent: () => void;
|
||||
onToggleGrid: () => void;
|
||||
}
|
||||
|
||||
const PERIODS: { label: string; tf: Timeframe }[] = [
|
||||
{ label: '1M', tf: '1M' },
|
||||
{ label: '1W', tf: '1W' },
|
||||
{ label: '1D', tf: '1D' },
|
||||
{ label: '4h', tf: '4h' },
|
||||
{ label: '1h', tf: '1h' },
|
||||
{ label: '30m',tf: '30m' },
|
||||
{ label: '5m', tf: '5m' },
|
||||
{ label: '3m', tf: '3m' },
|
||||
{ label: '1m', tf: '1m' },
|
||||
];
|
||||
|
||||
const IcCalendar = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<rect x="1" y="3" width="12" height="10" rx="1"/>
|
||||
<line x1="1" y1="6" x2="13" y2="6"/>
|
||||
<line x1="4" y1="1" x2="4" y2="4"/>
|
||||
<line x1="10" y1="1" x2="10" y2="4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcGrid = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
<rect x="1" y="1" width="12" height="12" rx="1"/>
|
||||
<line x1="5" y1="1" x2="5" y2="13"/>
|
||||
<line x1="9" y1="1" x2="9" y2="13"/>
|
||||
<line x1="1" y1="5" x2="13" y2="5"/>
|
||||
<line x1="1" y1="9" x2="13" y2="9"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const BottomBar: React.FC<BottomBarProps> = ({
|
||||
timeframe, logScale, percentScale, showGrid, lastTime,
|
||||
displayTimezone, onTimezoneChange,
|
||||
onTimeframe, onLogScale, onPercentScale, onAutoScale, onFitContent, onToggleGrid,
|
||||
}) => {
|
||||
return (
|
||||
<div className="tv-bottom-bar">
|
||||
<div className="tv-period-group">
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p.label}
|
||||
className={`tv-period-btn ${timeframe === p.tf ? 'active' : ''}`}
|
||||
onClick={() => onTimeframe(p.tf)}
|
||||
>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="tv-period-btn" title="화면 맞춤 (F)" onClick={onFitContent}>
|
||||
<IcCalendar />
|
||||
</button>
|
||||
|
||||
<div className="tv-bottom-spacer" />
|
||||
|
||||
<TimezonePicker
|
||||
variant="bar"
|
||||
value={displayTimezone}
|
||||
displayUnix={lastTime}
|
||||
onChange={onTimezoneChange}
|
||||
/>
|
||||
|
||||
<div className="tv-period-group tv-right-toggles">
|
||||
<button
|
||||
className={`tv-period-btn tv-toggle-btn ${showGrid ? 'active' : ''}`}
|
||||
title={showGrid ? '그리드 숨기기' : '그리드 표시'}
|
||||
onClick={onToggleGrid}
|
||||
><IcGrid /></button>
|
||||
<button
|
||||
className={`tv-period-btn tv-toggle-btn ${percentScale ? 'active' : ''}`}
|
||||
title="퍼센트(%) 스케일"
|
||||
onClick={onPercentScale}
|
||||
>%</button>
|
||||
<button
|
||||
className={`tv-period-btn tv-toggle-btn ${logScale ? 'active' : ''}`}
|
||||
onClick={onLogScale}
|
||||
title="로그 스케일 (L)"
|
||||
>log</button>
|
||||
<button
|
||||
className="tv-period-btn tv-toggle-btn"
|
||||
title="가격 스케일 자동 맞춤"
|
||||
onClick={onAutoScale}
|
||||
>auto</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomBar;
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 캔들 pane 좌측 하단 — 캔들 확대 보기 / 기본 보기 복원 버튼
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
|
||||
const IconExpand = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1,9 1,13 5,13"/>
|
||||
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
|
||||
<polyline points="13,5 13,1 9,1"/>
|
||||
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRestore = () => (
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="5,13 1,13 1,9"/>
|
||||
<line x1="1" y1="13" x2="5.5" y2="8.5"/>
|
||||
<polyline points="9,1 13,1 13,5"/>
|
||||
<line x1="13" y1="1" x2="8.5" y2="5.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface CandlePaneControlsProps {
|
||||
manager: ChartManager;
|
||||
containerEl: HTMLElement;
|
||||
candleOnly: boolean;
|
||||
onExpand: () => void;
|
||||
onRestore: () => void;
|
||||
}
|
||||
|
||||
const CandlePaneControls: React.FC<CandlePaneControlsProps> = ({
|
||||
manager,
|
||||
containerEl,
|
||||
candleOnly,
|
||||
onExpand,
|
||||
onRestore,
|
||||
}) => {
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
||||
|
||||
const updatePos = useCallback(() => {
|
||||
const main = manager.getPaneLayouts().find(l => l.paneIndex === 0);
|
||||
if (!main) { setPos(null); return; }
|
||||
const rect = containerEl.getBoundingClientRect();
|
||||
const paneBottomY = rect.top + main.topY + main.height;
|
||||
setPos({
|
||||
left: rect.left + 4,
|
||||
top: paneBottomY - 30,
|
||||
});
|
||||
}, [manager, containerEl]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePos();
|
||||
const ro = new ResizeObserver(() => updatePos());
|
||||
ro.observe(containerEl);
|
||||
const id = setInterval(updatePos, 400);
|
||||
return () => { ro.disconnect(); clearInterval(id); };
|
||||
}, [containerEl, updatePos, candleOnly]);
|
||||
|
||||
if (!pos) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="candle-pane-controls"
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 504 }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
{candleOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
className="pane-btn pane-btn-expand active"
|
||||
onClick={e => { e.stopPropagation(); onRestore(); }}
|
||||
title="기본 보기로 복원"
|
||||
>
|
||||
<IconRestore />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="pane-btn pane-btn-expand"
|
||||
onClick={e => { e.stopPropagation(); onExpand(); }}
|
||||
title="캔들 전체보기 (캔들 영역 오버레이 지표 유지, 하단 보조지표 숨김)"
|
||||
>
|
||||
<IconExpand />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CandlePaneControls;
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 차트 우클릭 컨텍스트 메뉴 (매수 / 매도)
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export interface ChartContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
marketLabel: string;
|
||||
priceLabel: string;
|
||||
onBuy: () => void;
|
||||
onSell: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ChartContextMenu: React.FC<ChartContextMenuProps> = ({
|
||||
x, y, marketLabel, priceLabel, onBuy, onSell, onClose,
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const menuW = 168;
|
||||
const menuH = 88;
|
||||
const left = Math.min(x, window.innerWidth - menuW - 8);
|
||||
const top = Math.min(y, window.innerHeight - menuH - 8);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
className="chart-ctx-menu"
|
||||
style={{ left, top }}
|
||||
role="menu"
|
||||
>
|
||||
<div className="chart-ctx-menu-hint">
|
||||
<span>{marketLabel}</span>
|
||||
<span className="chart-ctx-menu-price">{priceLabel}</span>
|
||||
</div>
|
||||
<button type="button" className="chart-ctx-menu-item chart-ctx-menu-item--buy" onClick={onBuy}>
|
||||
매수
|
||||
</button>
|
||||
<button type="button" className="chart-ctx-menu-item chart-ctx-menu-item--sell" onClick={onSell}>
|
||||
매도
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartContextMenu;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* ChartHoverToolbar
|
||||
* 차트 영역에 마우스 오버 시 하단 중앙에 표시되는 플로팅 툴바.
|
||||
* - 마우스가 차트 영역을 벗어나면 사라진다.
|
||||
* - 버튼: 줌아웃(−), 줌인(+), 전체보기(⌐┘), 왼쪽(‹), 오른쪽(›)
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
interface ChartHoverToolbarProps {
|
||||
onZoomOut: () => void;
|
||||
onZoomIn: () => void;
|
||||
onFit: () => void;
|
||||
onScrollLeft: () => void;
|
||||
onScrollRight: () => void;
|
||||
/** 멀티차트 모드에서 이 슬롯을 단일 전체화면으로 확장. 없으면 기본 fitContent() 사용 */
|
||||
onFullView?: () => void;
|
||||
}
|
||||
|
||||
const ChartHoverToolbar: React.FC<ChartHoverToolbarProps> = ({
|
||||
onZoomOut, onZoomIn, onFit, onScrollLeft, onScrollRight, onFullView,
|
||||
}) => (
|
||||
<div className="chart-hover-toolbar" onMouseDown={e => e.stopPropagation()}>
|
||||
<button className="cht-btn" onClick={onZoomOut} title="축소 (−)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="2" y1="7" x2="12" y2="7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="cht-btn" onClick={onZoomIn} title="확대 (+)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="7" y1="2" x2="7" y2="12"/>
|
||||
<line x1="2" y1="7" x2="12" y2="7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 멀티차트: 단일 전체화면 전환 / 단일차트: 전체 데이터 핏 */}
|
||||
<button className="cht-btn" onClick={onFullView ?? onFit} title={onFullView ? '단일 차트로 전환' : '전체 보기'}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="5,1 1,1 1,5"/>
|
||||
<polyline points="9,13 13,13 13,9"/>
|
||||
<line x1="1" y1="1" x2="6" y2="6"/>
|
||||
<line x1="13" y1="13" x2="8" y2="8"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="cht-btn" onClick={onScrollLeft} title="왼쪽으로 이동 (과거)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9,2 4,7 9,12"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button className="cht-btn" onClick={onScrollRight} title="오른쪽으로 이동 (최근)">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="5,2 10,7 5,12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ChartHoverToolbar;
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 캔들차트 상단 OHLCV·지표 범례 (tv-legend)
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { WsStatus } from '../hooks/useUpbitData';
|
||||
import type { ChartMode, IndicatorConfig, LegendData } from '../types';
|
||||
import type { ChartLegendVisibility } from '../types/chartLegend';
|
||||
import { formatPrice } from '../utils/dataGenerator';
|
||||
import { getIndicatorDef, getIndicatorChartTitle } from '../utils/indicatorRegistry';
|
||||
import { formatBbLegendLabel } from '../utils/bollingerConfig';
|
||||
|
||||
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
|
||||
if (!isUpbit) return null;
|
||||
const map: Record<WsStatus, { dot: string; label: string }> = {
|
||||
connecting: { dot: 'dot-yellow', label: '연결 중...' },
|
||||
connected: { dot: 'dot-lime', label: '실시간' },
|
||||
disconnected: { dot: 'dot-red', label: '재연결 중' },
|
||||
error: { dot: 'dot-red', label: '오류' },
|
||||
};
|
||||
const { dot, label } = map[status];
|
||||
return <span className={`ws-badge ${dot}`}><span className="ws-dot" />{label}</span>;
|
||||
};
|
||||
|
||||
export interface ChartLegendBarProps {
|
||||
visibility: ChartLegendVisibility;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
useUpbit: boolean;
|
||||
wsStatus: WsStatus;
|
||||
mode: ChartMode;
|
||||
currentBar: LegendData | null;
|
||||
isUp: boolean;
|
||||
dailyChange: number;
|
||||
dailyChangePct: number;
|
||||
indicators: IndicatorConfig[];
|
||||
legend: LegendData | null;
|
||||
}
|
||||
|
||||
export const ChartLegendBar: React.FC<ChartLegendBarProps> = ({
|
||||
visibility: v,
|
||||
symbol,
|
||||
timeframe,
|
||||
useUpbit,
|
||||
wsStatus,
|
||||
mode,
|
||||
currentBar,
|
||||
isUp,
|
||||
dailyChange,
|
||||
dailyChangePct,
|
||||
indicators,
|
||||
legend,
|
||||
}) => {
|
||||
const showOhlc = currentBar && (v.open || v.high || v.low || v.close || v.change);
|
||||
const showAny =
|
||||
v.symbol || v.timeframe || (useUpbit && v.liveStatus) || (mode === 'trading' && v.tradingBadge)
|
||||
|| showOhlc || (v.indicators && indicators.length > 0);
|
||||
|
||||
if (!showAny) return null;
|
||||
|
||||
return (
|
||||
<div className="tv-legend">
|
||||
{v.symbol && (
|
||||
<span className="tv-legend-sym">
|
||||
<span className="tv-legend-dot" style={{ background: isUp ? 'var(--up)' : 'var(--down)' }} />
|
||||
{symbol}
|
||||
</span>
|
||||
)}
|
||||
{v.timeframe && <span className="tv-legend-tf">· {timeframe}</span>}
|
||||
{useUpbit && v.liveStatus && <WsBadge status={wsStatus} isUpbit={true} />}
|
||||
{mode === 'trading' && v.tradingBadge && <span className="tv-legend-badge trading">✏</span>}
|
||||
{showOhlc && (
|
||||
<span className="tv-legend-ohlcv">
|
||||
{v.open && (
|
||||
<span>O <b className={isUp ? 'up' : 'down'}>{formatPrice(currentBar!.open)}</b></span>
|
||||
)}
|
||||
{v.high && (
|
||||
<span>H <b className="up">{formatPrice(currentBar!.high)}</b></span>
|
||||
)}
|
||||
{v.low && (
|
||||
<span>L <b className="down">{formatPrice(currentBar!.low)}</b></span>
|
||||
)}
|
||||
{v.close && (
|
||||
<span>C <b className={isUp ? 'up' : 'down'}>{formatPrice(currentBar!.close)}</b></span>
|
||||
)}
|
||||
{v.change && (
|
||||
<span className={`tv-legend-chg ${isUp ? 'up' : 'down'}`}>
|
||||
{isUp ? '▲' : '▼'} {formatPrice(Math.abs(dailyChange))} ({Math.abs(dailyChangePct).toFixed(2)}%)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{v.indicators && indicators.map(ind => {
|
||||
const def = getIndicatorDef(ind.type);
|
||||
const vals = legend?.indicatorValues?.[ind.type];
|
||||
const plots = ind.plots ?? def?.plots ?? [];
|
||||
const label = ind.type === 'BollingerBands'
|
||||
? formatBbLegendLabel(ind.params ?? {})
|
||||
: getIndicatorChartTitle(ind.type);
|
||||
const fmtVal = (n: number) =>
|
||||
Math.abs(n) >= 1000
|
||||
? n.toLocaleString('ko-KR', { maximumFractionDigits: 1 })
|
||||
: n.toFixed(2);
|
||||
return def ? (
|
||||
<span key={ind.id} className="tv-legend-ind">
|
||||
<span className="tv-legend-ind-name">{label}</span>
|
||||
{v.indicatorValues && vals && vals.length > 0 && (
|
||||
<span className="tv-legend-ind-vals">
|
||||
{vals.map((n, i) =>
|
||||
Number.isFinite(n) ? (
|
||||
<b
|
||||
key={i}
|
||||
style={{ color: (plots[i]?.color ?? def.plots?.[i]?.color ?? '#42A5F5').slice(0, 7) }}
|
||||
>
|
||||
{fmtVal(n)}
|
||||
</b>
|
||||
) : null,
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartLegendBar;
|
||||
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* ChartMagnifier
|
||||
*
|
||||
* 차트 위에 떠 있는 돋보기(루페) 오버레이.
|
||||
* - 헤더 드래그로 이동
|
||||
* - 8방향 핸들로 리사이즈
|
||||
* - 내부 canvas 에 차트 픽셀을 확대 복사 (rAF 루프)
|
||||
* - X/Y 동시 줌 배율 조절
|
||||
* - Y축 전용 줌(Y-Zoom): 수직 방향만 추가 확대 → 미세한 Y값 변화도 파악 가능
|
||||
* - LIVE 지시등: 캔버스 픽셀 변화 감지 시 점멸
|
||||
*/
|
||||
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
||||
|
||||
interface ChartMagnifierProps {
|
||||
/** LWC canvas 들이 들어있는 chart-container div */
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
/** position:relative 부모 (tv-chart-wrap) — 위치 기준점 */
|
||||
wrapperRef: React.RefObject<HTMLDivElement>;
|
||||
enabled: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const HEADER_H = 30;
|
||||
const MIN_W = 180;
|
||||
const MIN_H = 120 + HEADER_H;
|
||||
const INIT_W = 320;
|
||||
const INIT_H = 240 + HEADER_H;
|
||||
|
||||
const XY_STEPS: number[] = [1.5, 2, 2.5, 3, 4, 5, 6, 8];
|
||||
const Y_STEPS: number[] = [1, 1.5, 2, 3, 5, 8, 12];
|
||||
|
||||
type ResizeDir = 'nw'|'n'|'ne'|'e'|'se'|'s'|'sw'|'w';
|
||||
|
||||
const HANDLES: { id: ResizeDir; cursor: string; style: React.CSSProperties }[] = [
|
||||
{ id:'nw', cursor:'nw-resize', style:{ top:-5, left:-5 } },
|
||||
{ id:'n', cursor:'n-resize', style:{ top:-5, left:'50%', transform:'translateX(-50%)' } },
|
||||
{ id:'ne', cursor:'ne-resize', style:{ top:-5, right:-5 } },
|
||||
{ id:'e', cursor:'e-resize', style:{ top:'50%', right:-5, transform:'translateY(-50%)' } },
|
||||
{ id:'se', cursor:'se-resize', style:{ bottom:-5, right:-5 } },
|
||||
{ id:'s', cursor:'s-resize', style:{ bottom:-5, left:'50%',transform:'translateX(-50%)' } },
|
||||
{ id:'sw', cursor:'sw-resize', style:{ bottom:-5, left:-5 } },
|
||||
{ id:'w', cursor:'w-resize', style:{ top:'50%', left:-5, transform:'translateY(-50%)' } },
|
||||
];
|
||||
|
||||
const ChartMagnifier: React.FC<ChartMagnifierProps> = ({
|
||||
containerRef, wrapperRef, enabled, onClose,
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const tempCvRef = useRef<HTMLCanvasElement | null>(null); // Y-Zoom 용 임시 캔버스
|
||||
const rafRef = useRef<number>(0);
|
||||
|
||||
// ── position / size / xy-zoom / y-zoom ────────────────────────────────────
|
||||
// position: fixed 기준 — 뷰포트 좌표 (wrapper 밖으로도 이동 가능)
|
||||
const initPos = useCallback(() => {
|
||||
const wr = wrapperRef.current?.getBoundingClientRect();
|
||||
return wr ? { x: wr.left + 40, y: wr.top + 40 } : { x: 80, y: 80 };
|
||||
}, [wrapperRef]);
|
||||
|
||||
const [pos, setPos] = useState(() => ({ x: 80, y: 80 }));
|
||||
const [size, setSize] = useState({ w: INIT_W, h: INIT_H });
|
||||
const [xyZoom, setXyZoom] = useState(2.5);
|
||||
const [yZoom, setYZoom] = useState(1);
|
||||
/** LIVE 지시등: true = 직전 프레임 대비 픽셀 변화 있음 */
|
||||
const [isLive, setIsLive] = useState(false);
|
||||
|
||||
const posRef = useRef({ x: 80, y: 80 });
|
||||
const sizeRef = useRef({ w: INIT_W, h: INIT_H });
|
||||
const xyZoomRef = useRef(2.5);
|
||||
const yZoomRef = useRef(1);
|
||||
|
||||
// 픽셀 변화 감지용: 이전 프레임의 중앙 수직 라인 픽셀 데이터
|
||||
const prevRowRef = useRef<Uint8ClampedArray | null>(null);
|
||||
const liveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// ── rAF: 차트 캔버스 픽셀을 확대하여 magnifier canvas 에 복사 ──────────────
|
||||
const renderFrame = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!canvas || !container || !wrapper) return;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) return;
|
||||
|
||||
const { x, y } = posRef.current;
|
||||
const { w, h } = sizeRef.current;
|
||||
const zm = xyZoomRef.current;
|
||||
const yz = yZoomRef.current;
|
||||
const canvasH = h - HEADER_H;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
// canvas 해상도 유지
|
||||
const tW = Math.round(w * dpr);
|
||||
const tH = Math.round(canvasH * dpr);
|
||||
if (canvas.width !== tW || canvas.height !== tH) {
|
||||
canvas.width = tW;
|
||||
canvas.height = tH;
|
||||
canvas.style.width = w + 'px';
|
||||
canvas.style.height = canvasH + 'px';
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// 샘플링 중심 — position:fixed 이므로 pos가 이미 뷰포트 절대 좌표
|
||||
const centerX = x + w / 2;
|
||||
const centerY = y + HEADER_H + canvasH / 2;
|
||||
|
||||
// 원본 영역 (xy-zoom 적용)
|
||||
const srcW = w / zm;
|
||||
const srcH = canvasH / zm;
|
||||
const srcL = centerX - srcW / 2;
|
||||
const srcT = centerY - srcH / 2;
|
||||
|
||||
// position:fixed → pos 자체가 뷰포트 좌표이므로 wrapper 오프셋 불필요
|
||||
const ssrcL = srcL;
|
||||
const ssrcT = srcT;
|
||||
|
||||
// LWC 캔버스 레이어 순서대로 복사
|
||||
const layers = Array.from(container.querySelectorAll<HTMLCanvasElement>('canvas'));
|
||||
for (const lc of layers) {
|
||||
const cRect = lc.getBoundingClientRect();
|
||||
const csX = (ssrcL - cRect.left) * dpr;
|
||||
const csY = (ssrcT - cRect.top ) * dpr;
|
||||
const csW = srcW * dpr;
|
||||
const csH = srcH * dpr;
|
||||
const x0 = Math.max(0, csX);
|
||||
const y0 = Math.max(0, csY);
|
||||
const x1 = Math.min(lc.width, csX + csW);
|
||||
const y1 = Math.min(lc.height, csY + csH);
|
||||
if (x1 <= x0 || y1 <= y0) continue;
|
||||
const dstX = ((x0 - csX) / csW) * canvas.width;
|
||||
const dstY = ((y0 - csY) / csH) * canvas.height;
|
||||
const dstW = ((x1 - x0) / csW) * canvas.width;
|
||||
const dstH = ((y1 - y0) / csH) * canvas.height;
|
||||
try { ctx.drawImage(lc, x0, y0, x1-x0, y1-y0, dstX, dstY, dstW, dstH); }
|
||||
catch { /* cross-origin 무시 */ }
|
||||
}
|
||||
|
||||
// ── Y-Zoom: 수직 방향 추가 확대 ───────────────────────────────────────────
|
||||
// Y축의 미세한 움직임을 파악할 수 있도록 중앙 기준으로 수직만 재확대
|
||||
if (yz > 1) {
|
||||
// 임시 캔버스 초기화 (한 번만 생성)
|
||||
let tmp = tempCvRef.current;
|
||||
if (!tmp || tmp.width !== canvas.width || tmp.height !== canvas.height) {
|
||||
tmp = document.createElement('canvas');
|
||||
tmp.width = canvas.width;
|
||||
tmp.height = canvas.height;
|
||||
tempCvRef.current = tmp;
|
||||
}
|
||||
const tCtx = tmp.getContext('2d')!;
|
||||
tCtx.clearRect(0, 0, tmp.width, tmp.height);
|
||||
tCtx.drawImage(canvas, 0, 0);
|
||||
|
||||
// 중앙 ±(1/yz) 영역만 잘라 전체 높이로 늘림
|
||||
const srcYH = Math.round(canvas.height / yz);
|
||||
const srcYT = Math.round((canvas.height - srcYH) / 2);
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(tmp, 0, srcYT, canvas.width, srcYH, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Y-Zoom 배율 워터마크
|
||||
ctx.fillStyle = 'rgba(255,200,50,0.6)';
|
||||
ctx.font = `bold ${Math.round(10 * dpr)}px monospace`;
|
||||
ctx.fillText(`Y×${yz}`, 4 * dpr, (canvasH - 4) * dpr);
|
||||
}
|
||||
|
||||
// ── 테두리 ─────────────────────────────────────────────────────────────────
|
||||
ctx.strokeStyle = 'rgba(255, 200, 50, 0.35)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, canvas.width - 1, canvas.height - 1);
|
||||
|
||||
// ── LIVE 감지: 중앙 세로 라인 픽셀 샘플링으로 변화 감지 ─────────────────
|
||||
try {
|
||||
const midX = Math.floor(canvas.width / 2);
|
||||
const sample = ctx.getImageData(midX, 0, 1, canvas.height).data;
|
||||
const prev = prevRowRef.current;
|
||||
if (prev && prev.length === sample.length) {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sample.length; i += 4) {
|
||||
diff += Math.abs(sample[i] - prev[i]);
|
||||
diff += Math.abs(sample[i+1] - prev[i+1]);
|
||||
diff += Math.abs(sample[i+2] - prev[i+2]);
|
||||
}
|
||||
if (diff > 30) { // 임계값: 미세 잡음 무시
|
||||
setIsLive(true);
|
||||
if (liveTimerRef.current) clearTimeout(liveTimerRef.current);
|
||||
liveTimerRef.current = setTimeout(() => setIsLive(false), 600);
|
||||
}
|
||||
}
|
||||
prevRowRef.current = new Uint8ClampedArray(sample);
|
||||
} catch { /* getImageData 실패 무시 */ }
|
||||
}, [containerRef, wrapperRef]);
|
||||
|
||||
// enabled 시 wrapper 위치 기준으로 초기 위치 설정
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const p = initPos();
|
||||
posRef.current = p;
|
||||
setPos(p);
|
||||
}, [enabled, initPos]);
|
||||
|
||||
// rAF 루프
|
||||
useEffect(() => {
|
||||
if (!enabled) { cancelAnimationFrame(rafRef.current); return; }
|
||||
const loop = () => { renderFrame(); rafRef.current = requestAnimationFrame(loop); };
|
||||
rafRef.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [enabled, renderFrame]);
|
||||
|
||||
// ── 드래그 (헤더) ─────────────────────────────────────────────────────────
|
||||
const dragStart = useRef<{ mx: number; my: number; px: number; py: number } | null>(null);
|
||||
|
||||
const onHeaderDown = useCallback((e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
dragStart.current = { mx: e.clientX, my: e.clientY, px: posRef.current.x, py: posRef.current.y };
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const onHeaderMove = useCallback((e: React.PointerEvent) => {
|
||||
if (!dragStart.current) return;
|
||||
const dx = e.clientX - dragStart.current.mx;
|
||||
const dy = e.clientY - dragStart.current.my;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const { w, h } = sizeRef.current;
|
||||
// 헤더가 항상 화면 안에 있도록 최소 마진만 유지 (위아래 자유 이동)
|
||||
const np = {
|
||||
x: Math.max(-(w - 60), Math.min(vw - 60, dragStart.current.px + dx)),
|
||||
y: Math.max(-(h - HEADER_H), Math.min(vh - HEADER_H, dragStart.current.py + dy)),
|
||||
};
|
||||
posRef.current = np;
|
||||
setPos(np);
|
||||
}, []);
|
||||
|
||||
const onHeaderUp = useCallback(() => { dragStart.current = null; }, []);
|
||||
|
||||
// ── 리사이즈 핸들 ──────────────────────────────────────────────────────────
|
||||
const resizeStart = useRef<{
|
||||
dir: ResizeDir; mx: number; my: number;
|
||||
px: number; py: number; pw: number; ph: number;
|
||||
} | null>(null);
|
||||
|
||||
const onHandleDown = useCallback((e: React.PointerEvent, dir: ResizeDir) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
resizeStart.current = {
|
||||
dir, mx: e.clientX, my: e.clientY,
|
||||
px: posRef.current.x, py: posRef.current.y,
|
||||
pw: sizeRef.current.w, ph: sizeRef.current.h,
|
||||
};
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
}, []);
|
||||
|
||||
const onHandleMove = useCallback((e: React.PointerEvent) => {
|
||||
const rs = resizeStart.current; if (!rs) return;
|
||||
const { dir, mx, my, px, py, pw, ph } = rs;
|
||||
const dx = e.clientX - mx; const dy = e.clientY - my;
|
||||
let nx = px, ny = py, nw = pw, nh = ph;
|
||||
if (dir.includes('e')) { nw = Math.max(MIN_W, pw + dx); }
|
||||
if (dir.includes('w')) { nw = Math.max(MIN_W, pw - dx); nx = px + pw - nw; }
|
||||
if (dir.includes('s')) { nh = Math.max(MIN_H, ph + dy); }
|
||||
if (dir.includes('n')) { nh = Math.max(MIN_H, ph - dy); ny = py + ph - nh; }
|
||||
// 리사이즈 시 뷰포트 경계 안에서만 클램프 (헤더 마진 확보)
|
||||
nx = Math.max(-(nw - 60), Math.min(window.innerWidth - 60, nx));
|
||||
ny = Math.max(-(nh - HEADER_H), Math.min(window.innerHeight - HEADER_H, ny));
|
||||
posRef.current = { x: nx, y: ny };
|
||||
sizeRef.current = { w: nw, h: nh };
|
||||
setPos ({ x: nx, y: ny });
|
||||
setSize({ w: nw, h: nh });
|
||||
}, [wrapperRef]);
|
||||
|
||||
const onHandleUp = useCallback(() => { resizeStart.current = null; }, []);
|
||||
|
||||
// ── XY-Zoom 조절 ──────────────────────────────────────────────────────────
|
||||
const adjustXyZoom = useCallback((delta: number) => {
|
||||
const cur = xyZoomRef.current;
|
||||
const idx = XY_STEPS.findIndex(s => s >= cur - 0.01);
|
||||
const next = XY_STEPS[Math.max(0, Math.min(XY_STEPS.length - 1, idx + delta))];
|
||||
xyZoomRef.current = next; setXyZoom(next);
|
||||
}, []);
|
||||
|
||||
// ── Y-Zoom 조절 ───────────────────────────────────────────────────────────
|
||||
const adjustYZoom = useCallback((delta: number) => {
|
||||
const cur = yZoomRef.current;
|
||||
const idx = Y_STEPS.findIndex(s => s >= cur - 0.01);
|
||||
const next = Y_STEPS[Math.max(0, Math.min(Y_STEPS.length - 1, idx + delta))];
|
||||
yZoomRef.current = next; setYZoom(next);
|
||||
}, []);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="chart-magnifier"
|
||||
style={{ left: pos.x, top: pos.y, width: size.w, height: size.h }}
|
||||
onPointerDown={onHeaderDown}
|
||||
onPointerMove={onHeaderMove}
|
||||
onPointerUp={onHeaderUp}
|
||||
>
|
||||
{/* ── 헤더 ──────────────────────────────────────────────── */}
|
||||
<div
|
||||
className="chart-magnifier-header"
|
||||
>
|
||||
{/* 아이콘 + 제목 */}
|
||||
<svg className="chart-magnifier-icon" width="13" height="13" viewBox="0 0 13 13"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<circle cx="5.5" cy="5.5" r="4"/>
|
||||
<line x1="8.8" y1="8.8" x2="12.5" y2="12.5"/>
|
||||
<line x1="5.5" y1="3.5" x2="5.5" y2="7.5"/>
|
||||
<line x1="3.5" y1="5.5" x2="7.5" y2="5.5"/>
|
||||
</svg>
|
||||
<span className="chart-magnifier-title">확대경</span>
|
||||
|
||||
{/* LIVE 지시등 */}
|
||||
<span
|
||||
className={`chart-magnifier-live${isLive ? ' live-on' : ''}`}
|
||||
title="캔버스 실시간 갱신 감지"
|
||||
>LIVE</span>
|
||||
|
||||
{/* XY Zoom 컨트롤 */}
|
||||
<div className="chart-magnifier-zoom" title="X·Y 동시 확대">
|
||||
<button className="chart-magnifier-zoom-btn" onPointerDown={e => e.stopPropagation()}
|
||||
onClick={() => adjustXyZoom(-1)}>−</button>
|
||||
<span className="chart-magnifier-zoom-val">{xyZoom}×</span>
|
||||
<button className="chart-magnifier-zoom-btn" onPointerDown={e => e.stopPropagation()}
|
||||
onClick={() => adjustXyZoom(1)}>+</button>
|
||||
</div>
|
||||
|
||||
{/* Y-Zoom 컨트롤 */}
|
||||
<div className="chart-magnifier-yzoom" title="Y축 전용 확대 (미세 변화 감지용)">
|
||||
<button className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
|
||||
onPointerDown={e => e.stopPropagation()} onClick={() => adjustYZoom(-1)}>−</button>
|
||||
<span className="chart-magnifier-zoom-val">
|
||||
<span style={{ fontSize: 9, opacity: 0.7 }}>Y</span>{yZoom}×
|
||||
</span>
|
||||
<button className="chart-magnifier-zoom-btn chart-magnifier-yzoom-btn"
|
||||
onPointerDown={e => e.stopPropagation()} onClick={() => adjustYZoom(1)}>+</button>
|
||||
</div>
|
||||
|
||||
{/* 닫기 */}
|
||||
<button className="chart-magnifier-close" onPointerDown={e => e.stopPropagation()}
|
||||
onClick={onClose} title="닫기">×</button>
|
||||
</div>
|
||||
|
||||
{/* ── 확대 canvas ───────────────────────────────────────── */}
|
||||
<canvas ref={canvasRef} className="chart-magnifier-canvas" />
|
||||
|
||||
{/* ── 리사이즈 핸들 (8방향) ─────────────────────────────── */}
|
||||
{HANDLES.map(h => (
|
||||
<div key={h.id} className="chart-magnifier-handle"
|
||||
style={{ ...h.style, cursor: h.cursor, position: 'absolute', width: 10, height: 10 }}
|
||||
onPointerDown={e => onHandleDown(e, h.id)}
|
||||
onPointerMove={onHandleMove}
|
||||
onPointerUp={onHandleUp}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartMagnifier;
|
||||
@@ -0,0 +1,783 @@
|
||||
/**
|
||||
* ChartSlot
|
||||
* 레이아웃 내 개별 차트 슬롯. 자체 심볼/타임프레임/데이터를 관리한다.
|
||||
*/
|
||||
import React, {
|
||||
useState, useEffect, useCallback, useRef, useMemo,
|
||||
useImperativeHandle, forwardRef,
|
||||
} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import TradingChart from './TradingChart';
|
||||
import IndicatorContextToolbar from './IndicatorContextToolbar';
|
||||
import IndicatorSettingsModal from './IndicatorSettingsModal';
|
||||
import MainChartSettingsModal from './MainChartSettingsModal';
|
||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||
import { generateBars, formatPrice } from '../utils/dataGenerator';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { DEFAULT_MAIN_CHART_STYLE } from '../utils/storage';
|
||||
import { enrichIndicatorConfig, getIndicatorDef } from '../utils/indicatorRegistry';
|
||||
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from '../utils/smaConfig';
|
||||
import { normalizeIchimokuConfig } from '../utils/ichimokuConfig';
|
||||
import { isUpbitMarket } from '../utils/upbitApi';
|
||||
import { useChartRealtimeData, type WsStatus } from '../hooks/useChartRealtimeData';
|
||||
import { invalidateMarketCache } from '../utils/requestCache';
|
||||
import { useHistoryLoader, LOAD_MORE_TRIGGER } from '../hooks/useHistoryLoader';
|
||||
import { useIndicatorSettings } from '../hooks/useIndicatorSettings';
|
||||
import {
|
||||
duplicateIndicatorInList,
|
||||
mergeIndicators,
|
||||
reorderIndicatorGroup,
|
||||
splitIndicatorPane,
|
||||
} from '../utils/indicatorPaneMerge';
|
||||
import { saveSlot } from '../utils/backendApi';
|
||||
import type {
|
||||
OHLCVBar, ChartType, Theme, Timeframe,
|
||||
IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle,
|
||||
} from '../types';
|
||||
import type { ChartManager } from '../utils/ChartManager';
|
||||
|
||||
// ── 타임프레임 옵션 ────────────────────────────────────────────────────────
|
||||
const TF_OPTIONS: Timeframe[] = ['1m','3m','5m','15m','30m','1h','4h','1D','1W','1M'];
|
||||
|
||||
// ── Ws 배지 ───────────────────────────────────────────────────────────────
|
||||
const WsBadge: React.FC<{ status: WsStatus; isUpbit: boolean }> = ({ status, isUpbit }) => {
|
||||
if (!isUpbit) return null;
|
||||
const map: Record<WsStatus, { dot: string; label: string }> = {
|
||||
connecting: { dot: 'dot-yellow', label: '연결 중...' },
|
||||
connected: { dot: 'dot-lime', label: '실시간' },
|
||||
disconnected: { dot: 'dot-red', label: '재연결' },
|
||||
error: { dot: 'dot-red', label: '오류' },
|
||||
};
|
||||
const { dot, label } = map[status];
|
||||
return (
|
||||
<span className={`ws-badge ${dot}`} style={{ fontSize: '10px' }}>
|
||||
<span className="ws-dot" />{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
let _slotIndCounter = 0;
|
||||
function newIndId() { return `sind_${++_slotIndCounter}_${Date.now()}`; }
|
||||
|
||||
// ── Ref 핸들 (외부에서 슬롯 조작용) ─────────────────────────────────────────
|
||||
export interface ChartSlotHandle {
|
||||
/** 지표 추가 */
|
||||
addIndicator: (type: string, fromConfig?: IndicatorConfig) => void;
|
||||
/** 지표 제거 (type 기준) */
|
||||
removeIndicatorByType: (type: string) => void;
|
||||
/** 보조지표 pane 복사 */
|
||||
duplicateIndicator: (sourceId: string) => void;
|
||||
/** 현재 활성 심볼 */
|
||||
getSymbol: () => string;
|
||||
/** 현재 활성 타임프레임 */
|
||||
getTimeframe: () => Timeframe;
|
||||
/** 현재 활성 지표 목록 */
|
||||
getIndicators: () => IndicatorConfig[];
|
||||
/** 심볼 변경 */
|
||||
setSymbol: (s: string) => void;
|
||||
/** 타임프레임 변경 */
|
||||
setTimeframe: (tf: Timeframe) => void;
|
||||
/** 지표 목록 교체 (모드 전환 시 이전 설정 복원용) */
|
||||
setIndicators: (inds: IndicatorConfig[]) => void;
|
||||
/** 크로스헤어 자석모드 설정 */
|
||||
setCrosshairMagnet: (enabled: boolean) => void;
|
||||
/** 가격축 라벨·설명 on/off */
|
||||
setSeriesPriceLabelsEnabled: (enabled: boolean) => void;
|
||||
/** 거래량 pane on/off */
|
||||
setVolumeVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
// ── Props ─────────────────────────────────────────────────────────────────
|
||||
export interface ChartSlotProps {
|
||||
slotIndex: number;
|
||||
theme: Theme;
|
||||
/** 슬롯이 활성(포커스) 상태인지 */
|
||||
active: boolean;
|
||||
onActivate: () => void;
|
||||
/** 외부에서 강제 심볼 동기화 */
|
||||
forcedSymbol?: string;
|
||||
/** 외부에서 강제 타임프레임 동기화 */
|
||||
forcedTf?: Timeframe;
|
||||
/** 마운트 시 1회만 적용되는 초기 심볼 (단일↔다중 모드 전환 시 설정 이전용) */
|
||||
initialSymbol?: string;
|
||||
/** 마운트 시 1회만 적용되는 초기 타임프레임 */
|
||||
initialTimeframe?: Timeframe;
|
||||
/** 마운트 시 1회만 적용되는 초기 지표 목록 */
|
||||
initialIndicators?: IndicatorConfig[];
|
||||
/** Crosshair sync: 동기화 시각 (unix seconds) */
|
||||
syncTime?: number;
|
||||
onCrosshairTime?: (t: number) => void;
|
||||
/** Time sync: 동기화 가시 시간 범위 */
|
||||
syncVisibleRange?: { from: number; to: number } | null;
|
||||
onVisibleRangeChange?: (r: { from: number; to: number }) => void;
|
||||
/** Date range sync: 동기화 가시 논리 범위 */
|
||||
syncLogicalRange?: { from: number; to: number } | null;
|
||||
onLogicalRangeChange?: (r: { from: number; to: number }) => void;
|
||||
/** 돋보기 활성 여부 (App 에서 전달) */
|
||||
magnifierEnabled?: boolean;
|
||||
/** 돋보기 닫기 콜백 */
|
||||
onMagnifierClose?: () => void;
|
||||
/** 멀티차트 전체 보기: 이 슬롯을 단일 차트로 확장 */
|
||||
onFullView?: (symbol: string) => void;
|
||||
/**
|
||||
* 슬롯 내 지표 목록이 바뀔 때마다 호출.
|
||||
* App 에서 활성 슬롯의 지표 목록을 IndicatorModal 에 반영하기 위해 사용.
|
||||
*/
|
||||
onIndicatorsChange?: (indicators: IndicatorConfig[]) => void;
|
||||
/**
|
||||
* 슬롯 내 심볼이 바뀔 때마다 호출.
|
||||
* App 에서 activeSlotSymbol(호가창 심볼)을 동기화하기 위해 사용.
|
||||
*/
|
||||
onSymbolChange?: (symbol: string) => void;
|
||||
/** 자석모드 상태 (크로스헤어 스냅) */
|
||||
magnetMode?: 'off' | 'weak' | 'strong';
|
||||
/** 차트 우클릭 → 매수·매도 패널 자동 입력 */
|
||||
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }, slotIndex: number) => void;
|
||||
/** 보조지표 우측 가격축 라벨·설명 표시 */
|
||||
chartSeriesPriceLabels?: boolean;
|
||||
/** 차트 하단 거래량 바 표시 */
|
||||
chartVolumeVisible?: boolean;
|
||||
chartRealtimeSource?: 'BACKEND_STOMP' | 'UPBIT_DIRECT';
|
||||
displayTimezone?: string;
|
||||
}
|
||||
|
||||
const ChartSlot = forwardRef<ChartSlotHandle, ChartSlotProps>(function ChartSlot({
|
||||
slotIndex, theme, active, onActivate,
|
||||
forcedSymbol, forcedTf,
|
||||
initialSymbol, initialTimeframe, initialIndicators,
|
||||
syncTime, onCrosshairTime,
|
||||
syncVisibleRange, onVisibleRangeChange,
|
||||
syncLogicalRange, onLogicalRangeChange,
|
||||
onFullView,
|
||||
magnifierEnabled = false,
|
||||
onMagnifierClose,
|
||||
onIndicatorsChange,
|
||||
onSymbolChange,
|
||||
magnetMode = 'off',
|
||||
onTradeOrderRequest,
|
||||
chartSeriesPriceLabels = true,
|
||||
chartVolumeVisible = true,
|
||||
chartRealtimeSource = 'BACKEND_STOMP',
|
||||
displayTimezone,
|
||||
}, ref) {
|
||||
// ── 전역 지표 파라미터 + 시각 설정 (DB 동기화) ────────────────────────
|
||||
const { getParams, saveParams, getVisualConfig, saveVisual } = useIndicatorSettings();
|
||||
|
||||
// ── 자체 상태 ─────────────────────────────────────────────────────────
|
||||
// initial* 값이 있으면 우선 적용 (단일↔다중 모드 전환 시 설정 이전용)
|
||||
const [symbol, setSymbol] = useState(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
|
||||
const [timeframe, setTimeframe] = useState<Timeframe>(initialTimeframe ?? forcedTf ?? '1D');
|
||||
const [chartType] = useState<ChartType>('candlestick');
|
||||
const [indicators, setIndicators] = useState<IndicatorConfig[]>(initialIndicators ?? []);
|
||||
const [drawings] = useState<Drawing[]>([]);
|
||||
const [legend, setLegend] = useState<LegendData | null>(null);
|
||||
const [ctxToolbar, setCtxToolbar] = useState<{ entryId: string; screenX: number; screenY: number } | null>(null);
|
||||
const [settingsModalId, setSettingsModalId] = useState<string | null>(null);
|
||||
const [showMainChartModal, setShowMainChartModal] = useState(false);
|
||||
const [mainChartStyle, setMainChartStyle] = useState<MainChartStyle>(DEFAULT_MAIN_CHART_STYLE);
|
||||
const [showMarket, setShowMarket] = useState(false);
|
||||
// 슬롯 컨테이너 rect: MarketSearchPanel 위치 계산에 사용
|
||||
const [slotAnchorRect, setSlotAnchorRect] = useState<DOMRect | null>(null);
|
||||
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
// initialSymbol 도 초기값으로 포함: requestAnimationFrame 이전에 올바른 값 보장
|
||||
const symbolRef = useRef(initialSymbol ?? forcedSymbol ?? 'KRW-BTC');
|
||||
const timeframeRef = useRef<Timeframe>(forcedTf ?? '1D');
|
||||
|
||||
// symbol / timeframe 변경 시 ref 동기화 (외부 접근용)
|
||||
useEffect(() => { symbolRef.current = symbol; }, [symbol]);
|
||||
useEffect(() => { timeframeRef.current = timeframe; }, [timeframe]);
|
||||
|
||||
// 심볼 변경 시 부모(App)에 알림 → 호가창 activeSlotSymbol 동기화
|
||||
const onSymbolChangeRef = useRef(onSymbolChange);
|
||||
onSymbolChangeRef.current = onSymbolChange;
|
||||
useEffect(() => {
|
||||
onSymbolChangeRef.current?.(symbol);
|
||||
}, [symbol]);
|
||||
|
||||
// 지표 목록 변경 시 부모(App)에 알림 → IndicatorModal 의 activeIndicators 갱신
|
||||
const onIndicatorsChangeRef = useRef(onIndicatorsChange);
|
||||
onIndicatorsChangeRef.current = onIndicatorsChange;
|
||||
useEffect(() => {
|
||||
onIndicatorsChangeRef.current?.(indicators);
|
||||
}, [indicators]);
|
||||
|
||||
// indicators 를 ref 로도 유지 (useImperativeHandle 의 최신 값 접근용)
|
||||
const indicatorsRef = useRef<IndicatorConfig[]>(indicators);
|
||||
useEffect(() => { indicatorsRef.current = indicators; }, [indicators]);
|
||||
|
||||
// ── 외부 핸들 노출 ─────────────────────────────────────────────────────
|
||||
useImperativeHandle(ref, () => ({
|
||||
addIndicator: (type: string, fromConfig?: IndicatorConfig) => {
|
||||
setIndicators(prev => {
|
||||
if (prev.some(i => i.type === type)) return prev;
|
||||
const def = getIndicatorDef(type);
|
||||
if (!def) return prev;
|
||||
let cfg: IndicatorConfig;
|
||||
if (fromConfig) {
|
||||
cfg = { ...fromConfig, id: newIndId(), type: def.type };
|
||||
} else {
|
||||
const params = getParams(type, def.defaultParams);
|
||||
const { plots, hlines, cloudColors } = getVisualConfig(type, def.plots, def.hlines);
|
||||
cfg = {
|
||||
id: newIndId(),
|
||||
type: def.type,
|
||||
params,
|
||||
plots,
|
||||
hlines,
|
||||
...(cloudColors ? { cloudColors } : {}),
|
||||
};
|
||||
}
|
||||
if (type === 'SMA') {
|
||||
cfg = normalizeSmaConfig({
|
||||
...cfg,
|
||||
plotVisibility: cfg.plotVisibility ?? createDefaultSmaPlotVisibility(),
|
||||
});
|
||||
} else if (type === 'IchimokuCloud') {
|
||||
cfg = normalizeIchimokuConfig(cfg);
|
||||
}
|
||||
return [...prev, cfg];
|
||||
});
|
||||
},
|
||||
removeIndicatorByType: (type: string) => {
|
||||
setIndicators(prev => prev.filter(i => i.type !== type));
|
||||
},
|
||||
duplicateIndicator: (sourceId: string) => {
|
||||
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
|
||||
},
|
||||
getSymbol: () => symbolRef.current,
|
||||
getTimeframe: () => timeframeRef.current,
|
||||
getIndicators: () => indicatorsRef.current,
|
||||
setSymbol: (s: string) => {
|
||||
// 새 종목의 캐시를 미리 무효화해 신선한 데이터를 요청하도록 함
|
||||
if (isUpbitMarket(s)) invalidateMarketCache(s);
|
||||
setSymbol(s);
|
||||
setLegend(null);
|
||||
},
|
||||
setTimeframe: (tf: Timeframe) => { setTimeframe(tf); },
|
||||
setIndicators: (inds: IndicatorConfig[]) => { setIndicators(inds); },
|
||||
setCrosshairMagnet: (enabled: boolean) => {
|
||||
managerRef.current?.setCrosshairMagnet(enabled);
|
||||
},
|
||||
setSeriesPriceLabelsEnabled: (enabled: boolean) => {
|
||||
managerRef.current?.setSeriesPriceLabelsEnabled(enabled);
|
||||
},
|
||||
setVolumeVisible: (visible: boolean) => {
|
||||
managerRef.current?.setVolumeVisible(visible);
|
||||
},
|
||||
}), []);
|
||||
|
||||
// 자석모드 변경 → ChartManager 크로스헤어 모드 즉시 반영
|
||||
useEffect(() => {
|
||||
managerRef.current?.setCrosshairMagnet(magnetMode !== 'off');
|
||||
}, [magnetMode]);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current?.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||
}, [chartSeriesPriceLabels]);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current?.setVolumeVisible(chartVolumeVisible);
|
||||
}, [chartVolumeVisible]);
|
||||
|
||||
// 외부 심볼 동기화
|
||||
useEffect(() => {
|
||||
if (forcedSymbol && forcedSymbol !== symbol) setSymbol(forcedSymbol);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [forcedSymbol]);
|
||||
|
||||
// 외부 타임프레임 동기화
|
||||
useEffect(() => {
|
||||
if (forcedTf && forcedTf !== timeframe) setTimeframe(forcedTf);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [forcedTf]);
|
||||
|
||||
// mainChartStyle → ChartManager
|
||||
useEffect(() => {
|
||||
managerRef.current?.updateMainSeriesStyle(mainChartStyle);
|
||||
}, [mainChartStyle]);
|
||||
|
||||
// ── 슬롯 상태 변경 시 DB 자동 저장 (디바운스 2s) ─────────────────────────
|
||||
// 멀티슬롯 모드에서 각 슬롯 독립 저장 보장 (App.tsx workspaceState는 슬롯 0만 포함)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveSlot(slotIndex, {
|
||||
slotIndex,
|
||||
symbol,
|
||||
timeframe,
|
||||
chartType: 'candlestick',
|
||||
theme,
|
||||
mode: 'normal',
|
||||
logScale: false,
|
||||
drawingsLocked: false,
|
||||
drawingsVisible: true,
|
||||
indicators,
|
||||
drawings: [],
|
||||
paneLayout: null,
|
||||
mainChartStyle,
|
||||
}).catch(err => console.error('[ChartSlot] saveSlot failed', err));
|
||||
}, 2000);
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slotIndex, symbol, timeframe, theme, indicators, mainChartStyle]);
|
||||
|
||||
// ── 동기화 콜백 refs (항상 최신 값을 참조하도록) ─────────────────────────
|
||||
const onVisibleRangeChangeRef = useRef(onVisibleRangeChange);
|
||||
const onLogicalRangeChangeRef = useRef(onLogicalRangeChange);
|
||||
onVisibleRangeChangeRef.current = onVisibleRangeChange;
|
||||
onLogicalRangeChangeRef.current = onLogicalRangeChange;
|
||||
|
||||
// 외부에서 sync 값을 적용하는 중임을 표시 (feedback loop 방지)
|
||||
const isSyncingTimeRef = useRef(false);
|
||||
const isSyncingLogicalRef = useRef(false);
|
||||
|
||||
// 데이터 로드 전 수신한 sync range 를 저장해두는 pending ref
|
||||
// → onDataLoaded 콜백에서 재적용 (멀티차트 첫 마운트 시 타이밍 문제 해결)
|
||||
const pendingSyncVisibleRef = useRef<{ from: number; to: number } | null>(null);
|
||||
const pendingSyncLogicalRef = useRef<{ from: number; to: number } | null>(null);
|
||||
|
||||
// ── Crosshair sync: syncTime → setCrosshairAtTime ────────────────────
|
||||
useEffect(() => {
|
||||
if (!syncTime) { managerRef.current?.clearCrosshairPosition(); return; }
|
||||
managerRef.current?.setCrosshairAtTime(syncTime);
|
||||
}, [syncTime]);
|
||||
|
||||
// ── Time sync: syncVisibleRange 외부 적용 ────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!syncVisibleRange) return;
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || mgr.getRawBarsLength() === 0) {
|
||||
// 데이터 로드 전 → pending 저장 후 onDataLoaded 에서 재적용
|
||||
pendingSyncVisibleRef.current = syncVisibleRange;
|
||||
return;
|
||||
}
|
||||
isSyncingTimeRef.current = true;
|
||||
mgr.applyVisibleTimeRange(syncVisibleRange.from, syncVisibleRange.to);
|
||||
requestAnimationFrame(() => { isSyncingTimeRef.current = false; });
|
||||
}, [syncVisibleRange]);
|
||||
|
||||
// ── Date range sync: syncLogicalRange 외부 적용 ──────────────────────
|
||||
useEffect(() => {
|
||||
if (!syncLogicalRange) return;
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || mgr.getRawBarsLength() === 0) {
|
||||
pendingSyncLogicalRef.current = syncLogicalRange;
|
||||
return;
|
||||
}
|
||||
isSyncingLogicalRef.current = true;
|
||||
mgr.applyVisibleLogicalRange(syncLogicalRange.from, syncLogicalRange.to);
|
||||
requestAnimationFrame(() => { isSyncingLogicalRef.current = false; });
|
||||
}, [syncLogicalRange]);
|
||||
|
||||
// 멀티 레이아웃 새로고침 시 차트 빈 화면 방지:
|
||||
// CSS Grid 높이 확정 지연에 대비해 여러 시점에 pane 높이를 재적용한다.
|
||||
// TradingChart 자체도 applyPaneLayout 자동 재시도를 하지만, 슬롯 레벨에서도 보강.
|
||||
// ※ TradingChart를 재마운트(key 변경)하면 managerRef가 초기화되어 MarketSearchPanel 등
|
||||
// 다른 상태에 영향을 줄 수 있으므로, 재마운트 대신 resetPaneHeights 재시도만 수행한다.
|
||||
const slotContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
// 데이터 로딩 완료 후 차트가 여전히 비어 있으면 1회만 재마운트 (data + blank guard)
|
||||
const [reloadTick, setReloadTick] = useState(0);
|
||||
const reloadTriggeredRef = useRef(false); // 재마운트는 최대 1회
|
||||
useEffect(() => {
|
||||
const checks = [200, 500, 1000, 2000].map(delay =>
|
||||
setTimeout(() => {
|
||||
const el = slotContainerRef.current;
|
||||
const mgr = managerRef.current;
|
||||
if (!el || !mgr) return;
|
||||
const h = el.clientHeight;
|
||||
if (h <= 0) return;
|
||||
if (mgr.hasMainSeries()) {
|
||||
// 차트 데이터 있음 → pane 높이만 재적용
|
||||
mgr.resetPaneHeights(h);
|
||||
} else if (!reloadTriggeredRef.current) {
|
||||
// 차트 비어 있고 아직 재시도 안 했음 → 1회만 재마운트
|
||||
// (bars 로딩 지연일 수 있으므로, 늦은 타이머에서만 트리거)
|
||||
if (delay >= 1000) {
|
||||
reloadTriggeredRef.current = true;
|
||||
setReloadTick(t => t + 1);
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
);
|
||||
return () => checks.forEach(clearTimeout);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ── 데이터 ────────────────────────────────────────────────────────────
|
||||
const useUpbit = isUpbitMarket(symbol);
|
||||
|
||||
// 과거 데이터 추가 로드 (차트를 왼쪽으로 스크롤할 때)
|
||||
const { isLoadingMore, loadMoreRef } = useHistoryLoader({
|
||||
symbol, timeframe, isUpbit: useUpbit, managerRef,
|
||||
});
|
||||
|
||||
const [simBars, setSimBars] = useState<OHLCVBar[]>(() =>
|
||||
!isUpbitMarket(symbol) ? generateBars(symbol, timeframe) : []
|
||||
);
|
||||
useEffect(() => {
|
||||
if (useUpbit) return;
|
||||
setSimBars(generateBars(symbol, timeframe));
|
||||
setLegend(null);
|
||||
}, [symbol, timeframe, useUpbit]);
|
||||
|
||||
const pendingRealtimeBarRef = useRef<OHLCVBar | null>(null);
|
||||
|
||||
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
|
||||
if (!managerRef.current) {
|
||||
pendingRealtimeBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
managerRef.current.updateBar(bar);
|
||||
}, []);
|
||||
const handleNewCandle = useCallback((bar: OHLCVBar) => {
|
||||
if (!managerRef.current) {
|
||||
pendingRealtimeBarRef.current = bar;
|
||||
return;
|
||||
}
|
||||
managerRef.current.appendBar(bar);
|
||||
}, []);
|
||||
|
||||
const { bars: upbitBars, latestBar, wsStatus, isLoading } = useChartRealtimeData(
|
||||
symbol, timeframe,
|
||||
useMemo(() => ({
|
||||
onTickUpdate: handleTickUpdate,
|
||||
onNewCandle: handleNewCandle,
|
||||
enabled: useUpbit,
|
||||
source: chartRealtimeSource,
|
||||
}), [handleTickUpdate, handleNewCandle, useUpbit, chartRealtimeSource]),
|
||||
);
|
||||
|
||||
const bars = useUpbit ? upbitBars : simBars;
|
||||
|
||||
const lastKnownBar = useUpbit
|
||||
? (latestBar ?? (bars.length > 0 ? bars[bars.length - 1] : null))
|
||||
: (bars.length > 0 ? bars[bars.length - 1] : null);
|
||||
const currentBar = legend ?? lastKnownBar;
|
||||
const isUp = currentBar ? currentBar.close >= currentBar.open : true;
|
||||
const prevClose = bars.length > 1 ? bars[bars.length - 2].close : currentBar?.open ?? 0;
|
||||
const dailyChange = currentBar ? currentBar.close - prevClose : 0;
|
||||
const dailyChangePct = prevClose > 0 ? dailyChange / prevClose * 100 : 0;
|
||||
|
||||
// ── 인디케이터 핸들러 ─────────────────────────────────────────────────
|
||||
const handleIndicatorSave = useCallback((updated: IndicatorConfig) => {
|
||||
setIndicators(prev => prev.map(i => i.id === updated.id ? updated : i));
|
||||
setSettingsModalId(null);
|
||||
setCtxToolbar(null);
|
||||
// 파라미터 변경 DB 저장 → 백엔드 Ta4j 계산에 반영
|
||||
saveParams(updated.type, updated.params);
|
||||
// 시각 설정(색상·선굵기·수평선) 변경 DB 저장 → 새 지표 추가 시 기본값으로 사용
|
||||
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors);
|
||||
}, [saveParams, saveVisual]);
|
||||
|
||||
const handleToggleHidden = useCallback((id: string) => {
|
||||
setIndicators(prev => prev.map(i => i.id === id ? { ...i, hidden: !i.hidden } : i));
|
||||
}, []);
|
||||
|
||||
const handleRemoveById = useCallback((id: string) => {
|
||||
setIndicators(prev => prev.filter(i => i.id !== id));
|
||||
setCtxToolbar(null);
|
||||
setSettingsModalId(null);
|
||||
}, []);
|
||||
|
||||
const handleReorderIndicators = useCallback((fromId: string, insertBeforeId: string | null) => {
|
||||
setIndicators(prev => reorderIndicatorGroup(fromId, insertBeforeId, prev));
|
||||
}, []);
|
||||
|
||||
const handleMergeIndicators = useCallback((fromId: string, intoId: string) => {
|
||||
setIndicators(prev => mergeIndicators(fromId, intoId, prev));
|
||||
}, []);
|
||||
|
||||
const handleSplitIndicatorPane = useCallback((hostId: string) => {
|
||||
setIndicators(prev => splitIndicatorPane(hostId, prev));
|
||||
}, []);
|
||||
|
||||
const [focusedIndicatorId, setFocusedIndicatorId] = useState<string | null>(null);
|
||||
|
||||
const handleExpandIndicator = useCallback((id: string) => {
|
||||
setFocusedIndicatorId(id);
|
||||
}, []);
|
||||
|
||||
const handleRestoreIndicators = useCallback(() => {
|
||||
setFocusedIndicatorId(null);
|
||||
}, []);
|
||||
|
||||
const handleDuplicateIndicator = useCallback((sourceId: string) => {
|
||||
setIndicators(prev => duplicateIndicatorInList(sourceId, prev, newIndId) ?? prev);
|
||||
}, []);
|
||||
|
||||
const handleRemoveByIdSlot = useCallback((id: string) => {
|
||||
setIndicators(prev => prev.filter(i => i.id !== id));
|
||||
setFocusedIndicatorId(prev => prev === id ? null : prev);
|
||||
setCtxToolbar(null);
|
||||
setSettingsModalId(null);
|
||||
}, []);
|
||||
|
||||
/** 단독 전체화면에서 보여줄 지표 목록 (overlay + 선택된 1개) + 타임프레임 필터 */
|
||||
const effectiveIndicators = useMemo(() => {
|
||||
// 타임프레임 필터: timeframeVisibility[currentTf] === false 이면 제외
|
||||
const tfFiltered = indicators.filter(ind =>
|
||||
ind.timeframeVisibility?.[timeframe] !== false
|
||||
);
|
||||
if (!focusedIndicatorId) return tfFiltered;
|
||||
return tfFiltered.filter(ind => {
|
||||
const def = getIndicatorDef(ind.type);
|
||||
return def?.overlay || def?.returnsMarkers || ind.id === focusedIndicatorId;
|
||||
});
|
||||
}, [focusedIndicatorId, indicators, timeframe]);
|
||||
|
||||
// ── 심볼 선택 ─────────────────────────────────────────────────────────
|
||||
const handleSymbolSelect = useCallback((s: string) => {
|
||||
if (isUpbitMarket(s)) invalidateMarketCache(s);
|
||||
setSymbol(s);
|
||||
setLegend(null);
|
||||
setShowMarket(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`chart-slot${active ? ' active' : ''}`}
|
||||
onClick={onActivate}
|
||||
>
|
||||
{/* ── 슬롯 미니 헤더 ────────────────────────────────────────────────── */}
|
||||
<div className="slot-header" onClick={e => e.stopPropagation()}>
|
||||
{/* 심볼 버튼: 클릭 시 슬롯 영역 rect를 캡처 후 MarketSearchPanel Portal 오픈 */}
|
||||
<button
|
||||
className={`slot-sym-btn${showMarket ? ' open' : ''}`}
|
||||
title="종목 변경"
|
||||
onClick={() => {
|
||||
if (!showMarket) {
|
||||
// 클릭 시점에 슬롯 전체(.chart-slot) 컨테이너의 rect를 캡처
|
||||
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 }}>
|
||||
<circle cx="6" cy="6" r="4"/><line x1="9.5" y1="9.5" x2="13" y2="13"/>
|
||||
</svg>
|
||||
{getKoreanName(symbol) !== symbol
|
||||
? <><span className="slot-sym-kr">{getKoreanName(symbol)}</span><span className="slot-sym-code">{symbol}</span></>
|
||||
: <span className="slot-sym-kr">{symbol}</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* MarketSearchPanel: body 포털로 렌더 + anchorRect로 해당 슬롯 영역 안에 표시 */}
|
||||
{showMarket && ReactDOM.createPortal(
|
||||
<MarketSearchPanel
|
||||
currentMarket={symbol}
|
||||
onSelect={s => { handleSymbolSelect(s); }}
|
||||
onClose={() => setShowMarket(false)}
|
||||
anchorRect={slotAnchorRect}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{/* 타임프레임 선택 */}
|
||||
<div className="slot-tf-row">
|
||||
{TF_OPTIONS.map(tf => (
|
||||
<button
|
||||
key={tf}
|
||||
className={`slot-tf-btn${tf === timeframe ? ' active' : ''}`}
|
||||
onClick={() => setTimeframe(tf)}
|
||||
>
|
||||
{tf}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 현재가 */}
|
||||
{currentBar && (
|
||||
<span className="slot-price" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||
{formatPrice(currentBar.close)}
|
||||
<span className="slot-change" style={{ color: isUp ? 'var(--up)' : 'var(--down)' }}>
|
||||
{isUp ? '▲' : '▼'} {Math.abs(dailyChangePct).toFixed(2)}%
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{useUpbit && <WsBadge status={wsStatus} isUpbit={true} />}
|
||||
{isLoading && <span className="slot-loading">로딩...</span>}
|
||||
</div>
|
||||
|
||||
{/* ── TradingChart ─────────────────────────────────────────────────── */}
|
||||
<div
|
||||
ref={slotContainerRef}
|
||||
className="slot-chart-wrap"
|
||||
style={{ flex: 1, minHeight: 0, position: 'relative', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}
|
||||
>
|
||||
{useUpbit && isLoading && (
|
||||
<div className="chart-loading" style={{ position: 'absolute', zIndex: 10 }}>
|
||||
<div className="loading-spinner" />
|
||||
</div>
|
||||
)}
|
||||
{isLoadingMore && (
|
||||
<div className="chart-history-loading">
|
||||
<div className="loading-spinner" style={{ width: 14, height: 14 }} />
|
||||
<span>과거 데이터 로딩...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradingChart
|
||||
key={reloadTick}
|
||||
bars={bars}
|
||||
market={symbol}
|
||||
timeframe={timeframe}
|
||||
displayTimezone={displayTimezone}
|
||||
chartType={chartType}
|
||||
theme={theme}
|
||||
mode="chart"
|
||||
indicators={effectiveIndicators}
|
||||
drawingTool="cursor"
|
||||
drawings={drawings}
|
||||
logScale={false}
|
||||
drawingsLocked={false}
|
||||
drawingsVisible={true}
|
||||
onCrosshair={data => {
|
||||
setLegend(data);
|
||||
if (data && onCrosshairTime) onCrosshairTime(data.time ?? 0);
|
||||
}}
|
||||
onManagerReady={mgr => {
|
||||
managerRef.current = mgr;
|
||||
const pending = pendingRealtimeBarRef.current;
|
||||
if (pending) {
|
||||
pendingRealtimeBarRef.current = null;
|
||||
const last = bars.length > 0 ? bars[bars.length - 1] : null;
|
||||
if (last && pending.time === last.time) mgr.updateBar(pending);
|
||||
else if (!last || pending.time > last.time) void mgr.appendBar(pending);
|
||||
else mgr.updateBar({ ...pending, time: last.time });
|
||||
}
|
||||
mgr.setSeriesPriceLabelsEnabled(chartSeriesPriceLabels);
|
||||
mgr.setVolumeVisible(chartVolumeVisible);
|
||||
// Time sync: 가시 시간 범위 변화 구독
|
||||
mgr.subscribeVisibleTimeRange(r => {
|
||||
if (!r || isSyncingTimeRef.current) return;
|
||||
onVisibleRangeChangeRef.current?.(r);
|
||||
});
|
||||
// Date range sync: 가시 논리 범위 변화 구독
|
||||
// + 왼쪽 스크롤 감지 → 과거 데이터 추가 로드
|
||||
mgr.subscribeVisibleLogicalRange(r => {
|
||||
if (!r) return;
|
||||
if (!isSyncingLogicalRef.current) {
|
||||
onLogicalRangeChangeRef.current?.(r);
|
||||
}
|
||||
// 가시 범위 왼쪽 끝이 LOAD_MORE_TRIGGER 이내이면 추가 로드
|
||||
if (r.from < LOAD_MORE_TRIGGER) {
|
||||
loadMoreRef.current();
|
||||
}
|
||||
});
|
||||
}}
|
||||
onDataLoaded={() => {
|
||||
// reloadAll(setData + setInitialVisibleRange) 완료 후
|
||||
// 첫 마운트 시 pending 상태였던 sync range 를 재적용한다
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr) return;
|
||||
if (pendingSyncVisibleRef.current) {
|
||||
isSyncingTimeRef.current = true;
|
||||
mgr.applyVisibleTimeRange(
|
||||
pendingSyncVisibleRef.current.from,
|
||||
pendingSyncVisibleRef.current.to,
|
||||
);
|
||||
requestAnimationFrame(() => { isSyncingTimeRef.current = false; });
|
||||
pendingSyncVisibleRef.current = null;
|
||||
}
|
||||
if (pendingSyncLogicalRef.current) {
|
||||
isSyncingLogicalRef.current = true;
|
||||
mgr.applyVisibleLogicalRange(
|
||||
pendingSyncLogicalRef.current.from,
|
||||
pendingSyncLogicalRef.current.to,
|
||||
);
|
||||
requestAnimationFrame(() => { isSyncingLogicalRef.current = false; });
|
||||
pendingSyncLogicalRef.current = null;
|
||||
}
|
||||
}}
|
||||
magnifierEnabled={magnifierEnabled}
|
||||
onMagnifierClose={onMagnifierClose}
|
||||
onAddDrawing={() => {}}
|
||||
onSeriesClick={(entryId, point) => {
|
||||
if (!entryId) { setCtxToolbar(null); return; }
|
||||
setCtxToolbar({ entryId, screenX: point.x, screenY: point.y });
|
||||
}}
|
||||
onSeriesDoubleClick={entryId => {
|
||||
setCtxToolbar(null);
|
||||
if (!entryId) return;
|
||||
if (entryId === '__main__') setShowMainChartModal(true);
|
||||
else setSettingsModalId(entryId);
|
||||
}}
|
||||
onHoverPaneLegend={() => {}}
|
||||
onLeavePaneLegend={() => {}}
|
||||
onTradeOrderRequest={req => onTradeOrderRequest?.(req, slotIndex)}
|
||||
onReorderIndicators={handleReorderIndicators}
|
||||
onMergeIndicators={handleMergeIndicators}
|
||||
onSplitIndicatorPane={handleSplitIndicatorPane}
|
||||
onExpandIndicator={handleExpandIndicator}
|
||||
onRemoveIndicator={handleRemoveByIdSlot}
|
||||
onDuplicateIndicator={handleDuplicateIndicator}
|
||||
focusedIndicatorId={focusedIndicatorId}
|
||||
onRestoreIndicators={handleRestoreIndicators}
|
||||
onFullView={onFullView ? () => onFullView(symbolRef.current) : undefined}
|
||||
/>
|
||||
|
||||
{/* 컨텍스트 툴바 */}
|
||||
{ctxToolbar && (() => {
|
||||
const cfg = ctxToolbar.entryId !== '__main__'
|
||||
? indicators.find(i => i.id === ctxToolbar.entryId) ?? null
|
||||
: null;
|
||||
return (
|
||||
<IndicatorContextToolbar
|
||||
indicatorId={ctxToolbar.entryId}
|
||||
config={cfg}
|
||||
chartType={chartType}
|
||||
screenX={ctxToolbar.screenX}
|
||||
screenY={ctxToolbar.screenY}
|
||||
onMouseEnterToolbar={() => {}}
|
||||
onMouseLeaveToolbar={() => {}}
|
||||
onSettings={() => {
|
||||
const id = ctxToolbar.entryId;
|
||||
setCtxToolbar(null);
|
||||
if (id === '__main__') setShowMainChartModal(true);
|
||||
else setSettingsModalId(id);
|
||||
}}
|
||||
onToggleHidden={() => handleToggleHidden(ctxToolbar.entryId)}
|
||||
onRemove={() => handleRemoveById(ctxToolbar.entryId)}
|
||||
onClose={() => setCtxToolbar(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 메인 차트 설정 모달 */}
|
||||
{showMainChartModal && (
|
||||
<MainChartSettingsModal
|
||||
style={mainChartStyle}
|
||||
onSave={style => { setMainChartStyle(style); setShowMainChartModal(false); }}
|
||||
onCancel={() => setShowMainChartModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 지표 설정 모달 */}
|
||||
{settingsModalId && settingsModalId !== '__main__' && (() => {
|
||||
const cfg = indicators.find(i => i.id === settingsModalId);
|
||||
if (!cfg) return null;
|
||||
const def = getIndicatorDef(cfg.type);
|
||||
const mergedCfg = enrichIndicatorConfig({
|
||||
...cfg,
|
||||
params: getParams(cfg.type, def?.defaultParams),
|
||||
hlines: cfg.hlines ?? def?.hlines,
|
||||
});
|
||||
return (
|
||||
<IndicatorSettingsModal
|
||||
config={mergedCfg}
|
||||
chartMarket={symbol}
|
||||
onSave={handleIndicatorSave}
|
||||
onCancel={() => setSettingsModalId(null)}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ChartSlot;
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 보조지표 설정 — 색상 + 투명도 (스와치 클릭 시 팔레트·고급 선택기)
|
||||
*/
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { parsePlotColor, formatPlotColor, plotColorCss } from '../utils/plotColorUtils';
|
||||
import { getOpacityPercentSpec } from '../utils/indicatorParamSpec';
|
||||
import NumericParamInput from './NumericParamInput';
|
||||
import ColorPickerPanel from './ColorPickerPanel';
|
||||
|
||||
export interface ColorInputProps {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}
|
||||
|
||||
const ColorInput: React.FC<ColorInputProps> = ({ value, onChange }) => {
|
||||
const { hex6, alpha } = parsePlotColor(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 });
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const popW = 280;
|
||||
let left = rect.left;
|
||||
if (left + popW > window.innerWidth - 12) {
|
||||
left = window.innerWidth - popW - 12;
|
||||
}
|
||||
setPos({ top: rect.bottom + 6, left: Math.max(8, left) });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
updatePosition();
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
const onScroll = () => updatePosition();
|
||||
window.addEventListener('keydown', onKey);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (anchorRef.current?.contains(t) || popoverRef.current?.contains(t)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
return () => document.removeEventListener('mousedown', onDoc);
|
||||
}, [open]);
|
||||
|
||||
const patchHex = (h: string) => onChange(formatPlotColor(h, alpha));
|
||||
const patchAlpha = (a: number) => onChange(formatPlotColor(hex6, a));
|
||||
|
||||
const popover = open && typeof document !== 'undefined' ? (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="ism-color-popover"
|
||||
style={{ top: pos.top, left: pos.left }}
|
||||
role="dialog"
|
||||
aria-label="색상 선택"
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="ism-color-popover-native">
|
||||
<label className="ism-color-native-label">
|
||||
<span className="ism-style-label">직접 선택</span>
|
||||
<input
|
||||
type="color"
|
||||
className="ism-color-picker"
|
||||
value={hex6}
|
||||
onChange={e => patchHex(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<ColorPickerPanel hex6={hex6} onHexChange={patchHex} />
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="ism-color-wrap" ref={anchorRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="ism-color-swatch-btn"
|
||||
title="색상 선택"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
setOpen(v => !v);
|
||||
if (!open) requestAnimationFrame(updatePosition);
|
||||
}}
|
||||
>
|
||||
<span className="ism-color-swatch-preview" style={{ background: plotColorCss(value) }} />
|
||||
</button>
|
||||
<input
|
||||
type="color"
|
||||
className="ism-color-picker ism-color-picker--compact"
|
||||
value={hex6}
|
||||
title="브라우저 색상 선택"
|
||||
onChange={e => patchHex(e.target.value)}
|
||||
/>
|
||||
<NumericParamInput
|
||||
className="ism-input ism-alpha-input"
|
||||
value={alpha}
|
||||
spec={getOpacityPercentSpec(alpha)}
|
||||
onChange={patchAlpha}
|
||||
/>
|
||||
<span className="ism-alpha-label">%</span>
|
||||
{popover ? createPortal(popover, document.body) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorInput;
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 색상 선택 패널 — 기존 스와치 팔레트 + 하단 고급 색상 팔레트
|
||||
*/
|
||||
import React from 'react';
|
||||
import { COLOR_PALETTE } from '../utils/plotColorUtils';
|
||||
import AdvancedColorPicker from './AdvancedColorPicker';
|
||||
|
||||
export interface ColorPickerPanelProps {
|
||||
hex6: string;
|
||||
onHexChange: (hex6: string) => void;
|
||||
/** false면 스와치 그리드만 숨김 (고급 팔레트만) */
|
||||
showQuickPalette?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ColorPickerPanel: React.FC<ColorPickerPanelProps> = ({
|
||||
hex6,
|
||||
onHexChange,
|
||||
showQuickPalette = true,
|
||||
className = '',
|
||||
}) => (
|
||||
<div className={`cpp-panel ${className}`.trim()}>
|
||||
{showQuickPalette && (
|
||||
<div className="cpp-quick-palette">
|
||||
<div className="plsp-palette cpp-palette-grid">
|
||||
{COLOR_PALETTE.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={`plsp-color-cell${hex6.toUpperCase() === c.toUpperCase() ? ' selected' : ''}`}
|
||||
style={{ background: c }}
|
||||
title={c}
|
||||
onClick={() => onHexChange(c)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="cpp-advanced-divider" aria-hidden />
|
||||
<AdvancedColorPicker hex6={hex6} onChange={onHexChange} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ColorPickerPanel;
|
||||
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 운영 현황 대시보드 — 멀티스레드 파이프라인·JVM·트래픽·모의/실거래
|
||||
*/
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import {
|
||||
loadDashboardSummary,
|
||||
type DashboardSummaryDto,
|
||||
type ProcessMonitorDto,
|
||||
type SystemMonitorDto,
|
||||
} from '../utils/backendApi';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
onGoChart?: (market: string) => void;
|
||||
}
|
||||
|
||||
const fmtKrw = (n: number) =>
|
||||
n >= 1e8 ? `${(n / 1e8).toFixed(2)}억` : n >= 1e4 ? `${(n / 1e4).toFixed(0)}만` : n.toLocaleString('ko-KR');
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
healthy: '정상',
|
||||
degraded: '주의',
|
||||
down: '중단',
|
||||
disabled: '비활성',
|
||||
};
|
||||
|
||||
function statusClass(s: string): string {
|
||||
if (s === 'healthy') return 'dash-proc--ok';
|
||||
if (s === 'degraded') return 'dash-proc--warn';
|
||||
if (s === 'down') return 'dash-proc--down';
|
||||
return 'dash-proc--off';
|
||||
}
|
||||
|
||||
function formatMetricValue(v: unknown): string {
|
||||
if (typeof v === 'boolean') return v ? 'Y' : 'N';
|
||||
if (typeof v === 'number') {
|
||||
if (Number.isInteger(v)) return v.toLocaleString('ko-KR');
|
||||
return v.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
}
|
||||
return String(v ?? '—');
|
||||
}
|
||||
|
||||
function formatAgo(ms: number): string {
|
||||
if (!ms) return '—';
|
||||
const sec = Math.floor((Date.now() - ms) / 1000);
|
||||
if (sec < 60) return `${sec}초 전`;
|
||||
if (sec < 3600) return `${Math.floor(sec / 60)}분 전`;
|
||||
return `${Math.floor(sec / 3600)}시간 전`;
|
||||
}
|
||||
|
||||
const ProcessCard: React.FC<{ proc: ProcessMonitorDto }> = ({ proc }) => (
|
||||
<article className={`dash-proc ${statusClass(proc.status)}`}>
|
||||
<header className="dash-proc-head">
|
||||
<span className="dash-proc-layer">{proc.layer}</span>
|
||||
<span className={`dash-proc-badge ${statusClass(proc.status)}`}>
|
||||
{STATUS_LABEL[proc.status] ?? proc.status}
|
||||
</span>
|
||||
</header>
|
||||
<h3 className="dash-proc-name">{proc.name}</h3>
|
||||
<ul className="dash-proc-metrics">
|
||||
{Object.entries(proc.metrics).slice(0, 6).map(([k, v]) => (
|
||||
<li key={k}>
|
||||
<span>{k}</span>
|
||||
<strong>{formatMetricValue(v)}</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
|
||||
const DashboardPage: React.FC<Props> = ({ theme, onGoChart }) => {
|
||||
const [data, setData] = useState<DashboardSummaryDto | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadDashboardSummary()
|
||||
.then(d => { setData(d); setError(null); })
|
||||
.catch(e => setError((e as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = window.setInterval(refresh, 5_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [refresh]);
|
||||
|
||||
const app = data?.app as Record<string, unknown> | undefined;
|
||||
const mon: SystemMonitorDto | undefined = data?.systemMonitor;
|
||||
|
||||
return (
|
||||
<div className={`dashboard-page theme-${theme}`}>
|
||||
<header className="dashboard-header">
|
||||
<h1>운영 대시보드</h1>
|
||||
<div className="dashboard-header-actions">
|
||||
{mon && (
|
||||
<span className="dashboard-live">
|
||||
파이프라인 {mon.pipelineEnabled ? 'ON' : 'OFF'}
|
||||
{mon.sampledAtMs ? ` · ${formatAgo(mon.sampledAtMs)} 갱신` : ''}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="stg-btn" onClick={refresh} disabled={loading}>
|
||||
{loading ? '갱신 중…' : '새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <p className="dashboard-error">{error}</p>}
|
||||
|
||||
{data && mon && (
|
||||
<div className="dashboard-grid">
|
||||
{/* JVM · 트래픽 */}
|
||||
<section className="dashboard-card dashboard-card--wide dash-monitor-top">
|
||||
<h2>JVM · 트래픽</h2>
|
||||
<div className="dash-jvm-row">
|
||||
<div className="dash-jvm-heap">
|
||||
<div className="dash-jvm-label">
|
||||
Heap {mon.jvm.heapUsedMb} / {mon.jvm.heapMaxMb} MB ({mon.jvm.heapPct}%)
|
||||
</div>
|
||||
<div className="dash-jvm-bar">
|
||||
<div
|
||||
className={`dash-jvm-fill${mon.jvm.heapPct > 85 ? ' dash-jvm-fill--warn' : ''}`}
|
||||
style={{ width: `${Math.min(100, mon.jvm.heapPct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="dashboard-kv dash-traffic-kv">
|
||||
<li><span>WS 메시지/s</span><strong>{mon.traffic.wsMessagesPerSec ?? 0}</strong></li>
|
||||
<li><span>틱 적재/s</span><strong>{mon.traffic.ticksEnqueuedPerSec ?? 0}</strong></li>
|
||||
<li><span>틱 처리/s</span><strong>{mon.traffic.ticksProcessedPerSec ?? 0}</strong></li>
|
||||
<li><span>주문 실행/s</span><strong>{mon.traffic.ordersExecutedPerSec ?? 0}</strong></li>
|
||||
<li><span>스레드</span><strong>{mon.jvm.threadCount}</strong></li>
|
||||
<li><span>Non-Heap</span><strong>{mon.jvm.nonHeapUsedMb} MB</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ta4j 메모리 */}
|
||||
<section className="dashboard-card">
|
||||
<h2>인메모리 캔들 (Ta4j)</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>종목</span><strong>{mon.memory.markets}</strong></li>
|
||||
<li><span>시리즈</span><strong>{mon.memory.series}</strong></li>
|
||||
<li><span>총 봉 수</span><strong>{mon.memory.totalBars.toLocaleString('ko-KR')}</strong></li>
|
||||
<li><span>시리즈 상한</span><strong>{mon.memory.maxBarsPerSeries}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 이벤트 큐 */}
|
||||
<section className="dashboard-card">
|
||||
<h2>이벤트 큐</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>대기</span><strong>{mon.queue.pending}</strong></li>
|
||||
<li><span>용량</span><strong>{mon.queue.capacity?.toLocaleString('ko-KR') ?? '—'}</strong></li>
|
||||
<li><span>적재율</span><strong>{Math.round((mon.queue.fillRatio ?? 0) * 1000) / 10}%</strong></li>
|
||||
<li><span>드롭</span><strong className={mon.queue.dropped > 0 ? 'warn' : ''}>{mon.queue.dropped}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* 멀티스레드 프로세스 */}
|
||||
<section className="dashboard-card dashboard-card--wide">
|
||||
<h2>멀티스레드 프로세스</h2>
|
||||
<div className="dash-proc-grid">
|
||||
{mon.processes.map(p => (
|
||||
<ProcessCard key={p.id} proc={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 스레드 목록 */}
|
||||
<section className="dashboard-card dashboard-card--wide">
|
||||
<h2>관련 스레드</h2>
|
||||
{mon.threads.length === 0 ? (
|
||||
<p className="dashboard-muted">표시할 스레드 없음</p>
|
||||
) : (
|
||||
<table className="dashboard-table dashboard-table--compact">
|
||||
<thead>
|
||||
<tr><th>이름</th><th>ID</th><th>상태</th><th>데몬</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mon.threads.map(t => (
|
||||
<tr key={`${t.id}-${t.name}`}>
|
||||
<td><code>{t.name}</code></td>
|
||||
<td>{t.id}</td>
|
||||
<td>{t.state}</td>
|
||||
<td>{t.daemon ? 'Y' : 'N'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 틱 정체 종목 */}
|
||||
{mon.staleMarkets.length > 0 && (
|
||||
<section className="dashboard-card dashboard-card--wide dash-stale">
|
||||
<h2>틱 정체 종목 ({mon.staleMarkets.length})</h2>
|
||||
<table className="dashboard-table dashboard-table--compact">
|
||||
<thead>
|
||||
<tr><th>종목</th><th>마지막 틱 경과</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mon.staleMarkets.slice(0, 12).map(s => (
|
||||
<tr key={s.market}>
|
||||
<td>
|
||||
<button type="button" className="dashboard-link" onClick={() => onGoChart?.(s.market)}>
|
||||
{s.market.replace('KRW-', '')}
|
||||
</button>
|
||||
</td>
|
||||
<td>{s.lastTickAgeSec}초</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="dashboard-card">
|
||||
<h2>매매 모드</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>운영 모드</span><strong>{String(app?.tradingMode ?? '—')}</strong></li>
|
||||
<li><span>실거래 자동</span><strong>{app?.liveAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
|
||||
<li><span>모의 자동</span><strong>{app?.paperAutoTradeEnabled ? 'ON' : 'OFF'}</strong></li>
|
||||
<li><span>전략 체크</span><strong>{app?.liveStrategyCheck ? 'ON' : 'OFF'}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-card">
|
||||
<h2>모니터링</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>관심종목</span><strong>{data.watchlistCount}</strong></li>
|
||||
<li><span>전략 감시</span><strong>{data.monitoredMarkets}</strong></li>
|
||||
<li><span>FCM</span><strong>
|
||||
{data.fcm.pushEnabled ? (data.fcm.available ? '활성' : '설정됨·서버 미연결') : 'OFF'}
|
||||
</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-card">
|
||||
<h2>모의투자</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>현금</span><strong>{fmtKrw(data.paper.cashBalance)} KRW</strong></li>
|
||||
<li><span>총자산</span><strong>{fmtKrw(data.paper.totalAsset)} KRW</strong></li>
|
||||
<li><span>보유 종목</span><strong>{data.paper.positions?.length ?? 0}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-card">
|
||||
<h2>실거래</h2>
|
||||
<ul className="dashboard-kv">
|
||||
<li><span>KRW</span><strong>{fmtKrw(data.live.krwBalance)}</strong></li>
|
||||
<li><span>보유</span><strong>{data.live.positions?.length ?? 0} 종목</strong></li>
|
||||
<li><span>연동</span><strong>{data.live.configured ? 'OK' : '미설정'}</strong></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-card dashboard-card--wide">
|
||||
<h2>최근 시그널</h2>
|
||||
{data.recentSignals.length === 0 ? (
|
||||
<p className="dashboard-muted">최근 시그널 없음</p>
|
||||
) : (
|
||||
<table className="dashboard-table">
|
||||
<thead>
|
||||
<tr><th>종목</th><th>방향</th><th>가격</th><th>분봉</th><th>시각</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recentSignals.map(s => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<button type="button" className="dashboard-link" onClick={() => onGoChart?.(s.market)}>
|
||||
{s.market.replace('KRW-', '')}
|
||||
</button>
|
||||
</td>
|
||||
<td className={s.signalType === 'BUY' ? 'buy' : 'sell'}>{s.signalType}</td>
|
||||
<td>{s.price?.toLocaleString('ko-KR')}</td>
|
||||
<td>{s.candleType}</td>
|
||||
<td>{s.createdAt?.slice(0, 19) ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 드래그 가능한 모달 프레임 — 그라데이션 타이틀바(gc-popup-header) + 화면 중앙 초기 배치
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
export interface DraggableModalFrameProps {
|
||||
onClose: () => void;
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
overlayClassName?: string;
|
||||
dialogClassName?: string;
|
||||
bodyClassName?: string;
|
||||
zIndex?: number;
|
||||
width?: number | string;
|
||||
closeOnBackdrop?: boolean;
|
||||
}
|
||||
|
||||
export const DraggableModalFrame: React.FC<DraggableModalFrameProps> = ({
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
overlayClassName = 'sp-modal-overlay',
|
||||
dialogClassName = 'sp-modal',
|
||||
bodyClassName = 'sp-modal-body',
|
||||
zIndex = 1000,
|
||||
width,
|
||||
closeOnBackdrop = true,
|
||||
}) => {
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={overlayClassName}
|
||||
style={{ zIndex }}
|
||||
onMouseDown={e => {
|
||||
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={dialogClassName}
|
||||
style={{
|
||||
...panelStyle,
|
||||
...(width != null ? { width } : {}),
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header sp-modal-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title sp-modal-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="gc-popup-close"
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={bodyClassName}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DraggableModalFrame;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { Drawing } from '../types';
|
||||
|
||||
// 도구별 한국어 이름
|
||||
const DRAWING_LABELS: Record<string, string> = {
|
||||
hline: '수평선',
|
||||
vline: '수직선',
|
||||
trendline: '추세선',
|
||||
ray: '레이',
|
||||
fib: '피보나치',
|
||||
fibtz: '피보나치 타임존',
|
||||
measure: '가격 측정',
|
||||
rect: '사각형',
|
||||
channel: '채널',
|
||||
pencil: '자유 드로잉',
|
||||
text: '텍스트',
|
||||
};
|
||||
|
||||
/* ── SVG Icons (IndicatorContextToolbar 와 동일 스타일) ── */
|
||||
const IcEye = ({ visible }: { visible: boolean }) => visible ? (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z"/>
|
||||
<circle cx="7" cy="7" r="2"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z" strokeOpacity="0.35"/>
|
||||
<line x1="2" y1="2" x2="12" y2="12"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcGear = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<circle cx="7" cy="7" r="2.3"/>
|
||||
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcTrash = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<line x1="2" y1="4.5" x2="12" y2="4.5"/>
|
||||
<path d="M5 4.5 V3 Q5 2 7 2 Q9 2 9 3 V4.5"/>
|
||||
<path d="M3 4.5 L3.5 12 Q3.5 12.5 4 12.5 L10 12.5 Q10.5 12.5 10.5 12 L11 4.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
interface DrawingContextToolbarProps {
|
||||
drawing: Drawing;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
onSettings: () => void;
|
||||
onToggleVisible: () => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DrawingContextToolbar: React.FC<DrawingContextToolbarProps> = ({
|
||||
drawing, screenX, screenY,
|
||||
onSettings, onToggleVisible, onRemove, onClose,
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const tid = setTimeout(() => document.addEventListener('mousedown', handler), 100);
|
||||
return () => { clearTimeout(tid); document.removeEventListener('mousedown', handler); };
|
||||
}, [onClose]);
|
||||
|
||||
const W = 220;
|
||||
let left = screenX + 8;
|
||||
let top = screenY + 8;
|
||||
if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
|
||||
if (top + 50 > window.innerHeight - 8) top = screenY - 58;
|
||||
|
||||
const label = DRAWING_LABELS[drawing.type] ?? drawing.type;
|
||||
const isHidden = drawing.visible === false;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="ind-ctx-toolbar"
|
||||
style={{ left, top }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 색상 닷 + 이름 */}
|
||||
<span className="ind-ctx-name" title={label}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: drawing.color,
|
||||
marginRight: 5,
|
||||
verticalAlign: 'middle',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{/* 가시성 토글 */}
|
||||
<button
|
||||
className="ind-ctx-btn"
|
||||
title={isHidden ? '표시' : '숨기기'}
|
||||
onClick={onToggleVisible}
|
||||
>
|
||||
<IcEye visible={!isHidden} />
|
||||
</button>
|
||||
|
||||
{/* 설정 */}
|
||||
<button
|
||||
className="ind-ctx-btn"
|
||||
title="설정 (더블클릭)"
|
||||
onClick={onSettings}
|
||||
>
|
||||
<IcGear />
|
||||
</button>
|
||||
|
||||
{/* 삭제 */}
|
||||
<button
|
||||
className="ind-ctx-btn ind-ctx-del"
|
||||
title="삭제"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<IcTrash />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingContextToolbar;
|
||||
@@ -0,0 +1,228 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Drawing, DrawingStyle } from '../types';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#FF6D00','#FF5722','#EF5350','#E91E63','#9C27B0',
|
||||
'#673AB7','#3F51B5','#2196F3','#03A9F4','#00BCD4',
|
||||
'#009688','#4CAF50','#8BC34A','#CDDC39','#FFC107',
|
||||
'#FF9800','#795548','#9E9E9E','#607D8B','#FFFFFF',
|
||||
];
|
||||
|
||||
const LINE_WIDTHS = [1, 2, 3, 4] as const;
|
||||
const LINE_STYLES: { value: DrawingStyle; label: string }[] = [
|
||||
{ value: 'solid', label: '실선' },
|
||||
{ value: 'dashed', label: '파선' },
|
||||
{ value: 'dotted', label: '점선' },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
drawing: Drawing;
|
||||
onSave: (updated: Drawing) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const DrawingSettingsModal: React.FC<Props> = ({ drawing, onSave, onCancel }) => {
|
||||
const [color, setColor] = useState(drawing.color);
|
||||
const [lineWidth, setLineWidth] = useState(drawing.lineWidth);
|
||||
const [style, setStyle] = useState<DrawingStyle>(drawing.style);
|
||||
const [text, setText] = useState(drawing.text ?? '');
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
...drawing,
|
||||
color,
|
||||
lineWidth,
|
||||
style,
|
||||
...(drawing.type === 'text' ? { text: text.trim() || drawing.text } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dsm-overlay"
|
||||
style={{ zIndex: 1200 }}
|
||||
onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="dsm-dialog"
|
||||
style={{
|
||||
...panelStyle,
|
||||
minWidth: 300,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title">드로잉 설정</span>
|
||||
<button type="button" className="gc-popup-close" onClick={onCancel} aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div className="dsm-body">
|
||||
|
||||
{/* 색상 */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted, #6e7191)', marginBottom: 8 }}>색상</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{PRESET_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setColor(c)}
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: c,
|
||||
border: color === c ? '2px solid #fff' : '2px solid transparent',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 커스텀 컬러 피커 */}
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={e => setColor(e.target.value)}
|
||||
title="커스텀 색상"
|
||||
style={{
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
background: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선 굵기 (pencil·text·measure 제외) */}
|
||||
{drawing.type !== 'text' && drawing.type !== 'measure' && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted, #6e7191)', marginBottom: 8 }}>선 굵기</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{LINE_WIDTHS.map(w => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => setLineWidth(w)}
|
||||
style={{
|
||||
width: 34,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: lineWidth === w ? 'var(--accent-bg, rgba(122,162,247,0.15))' : 'transparent',
|
||||
border: lineWidth === w ? '1px solid var(--accent, #7aa2f7)' : '1px solid var(--border, rgba(122,162,247,0.2))',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 20, height: w, background: 'currentColor', borderRadius: 1 }} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 선 스타일 (hline, vline, trendline, ray, channel 계열) */}
|
||||
{!['text', 'measure', 'pencil', 'fib', 'rect'].includes(drawing.type) && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted, #6e7191)', marginBottom: 8 }}>선 스타일</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{LINE_STYLES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setStyle(value)}
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: style === value ? 'var(--accent-bg, rgba(122,162,247,0.15))' : 'transparent',
|
||||
border: style === value ? '1px solid var(--accent, #7aa2f7)' : '1px solid var(--border, rgba(122,162,247,0.2))',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 텍스트 (text 타입만) */}
|
||||
{drawing.type === 'text' && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted, #6e7191)', marginBottom: 8 }}>텍스트</div>
|
||||
<input
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid var(--border, rgba(122,162,247,0.25))',
|
||||
borderRadius: 4,
|
||||
padding: '6px 8px',
|
||||
color: 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="dsm-footer">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border, rgba(122,162,247,0.25))',
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>취소</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'var(--accent, #7aa2f7)',
|
||||
border: 'none',
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer',
|
||||
color: '#1a1b26',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingSettingsModal;
|
||||
@@ -0,0 +1,627 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import type { ChartMode } from '../types';
|
||||
|
||||
export interface DrawingToolbarProps {
|
||||
activeTool: string;
|
||||
mode: ChartMode;
|
||||
onTool: (tool: string) => void;
|
||||
onClearAll: () => void;
|
||||
onUndo: () => void;
|
||||
locked: boolean;
|
||||
visible: boolean;
|
||||
onToggleLock: () => void;
|
||||
onToggleEye: () => void;
|
||||
// 신규 확장 props (optional — App.tsx 점진 적용)
|
||||
indicatorsVisible?: boolean;
|
||||
magnetMode?: 'off' | 'weak' | 'strong';
|
||||
snapToIndicator?: boolean;
|
||||
drawingCount?: number;
|
||||
indicatorCount?: number;
|
||||
onMagnetMode?: (m: 'off' | 'weak' | 'strong') => void;
|
||||
onSnapToIndicator?: (v: boolean) => void;
|
||||
onHideDrawings?: () => void;
|
||||
onHideIndicators?: () => void;
|
||||
onHideAll?: () => void;
|
||||
onClearIndicators?: () => void;
|
||||
keepLockedOnDelete?: boolean;
|
||||
onToggleKeepLocked?: () => void;
|
||||
}
|
||||
|
||||
// ─── SVG 헬퍼 ──────────────────────────────────────────────────────────────
|
||||
const S = (p: React.SVGProps<SVGSVGElement>) =>
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"
|
||||
stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" {...p}/>;
|
||||
|
||||
// ─── 아이콘 ────────────────────────────────────────────────────────────────
|
||||
const IcCursor = () => <S><path d="M3 2L3 14L6.5 10.5L9.5 16L11 15.3L8 9.5L12.5 9.5Z" strokeWidth="1.4" fill="currentColor" fillOpacity="0.15"/></S>;
|
||||
// 업비트 즐겨찾기 툴바 아이콘
|
||||
const IcUpbitHline = () => <S strokeWidth="1.5"><line x1="2" y1="9" x2="16" y2="9"/><circle cx="9" cy="9" r="1.6" fill="currentColor" stroke="none"/></S>;
|
||||
const IcUpbitVline = () => <S strokeWidth="1.5"><line x1="9" y1="2" x2="9" y2="16"/><circle cx="9" cy="9" r="1.6" fill="currentColor" stroke="none"/></S>;
|
||||
const IcUpbitPeriod = () => <S strokeWidth="1.4"><line x1="5" y1="3" x2="5" y2="15"/><line x1="13" y1="3" x2="13" y2="15"/><circle cx="5" cy="9" r="1.4" fill="currentColor" stroke="none"/><circle cx="13" cy="9" r="1.4" fill="currentColor" stroke="none"/><line x1="6.5" y1="9" x2="11.5" y2="9"/><polyline points="6.5,10 5,9 6.5,8" strokeWidth="1.2"/><polyline points="11.5,10 13,9 11.5,8" strokeWidth="1.2"/></S>;
|
||||
const IcUpbitFibTz = () => <S strokeWidth="1.4"><line x1="4" y1="4" x2="4" y2="16"/><line x1="8" y1="6" x2="8" y2="16" strokeOpacity="0.75"/><line x1="12" y1="3" x2="12" y2="16" strokeOpacity="0.9"/><line x1="15" y1="8" x2="15" y2="16" strokeOpacity="0.55"/><circle cx="4" cy="10" r="1.3" fill="currentColor" stroke="none"/><circle cx="8" cy="11" r="1.3" fill="currentColor" stroke="none"/><circle cx="12" cy="9" r="1.3" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/></S>;
|
||||
const IcFavorite = () => <S strokeWidth="1.3"><path d="M9 2.5 L11 7 L16 7.5 L12.5 11 L13.5 16 L9 13.5 L4.5 16 L5.5 11 L2 7.5 L7 7 Z" fill="currentColor" fillOpacity="0.25"/></S>;
|
||||
// 선 그룹
|
||||
const IcTrendline = () => <S strokeWidth="1.5"><line x1="3" y1="15" x2="15" y2="3"/><circle cx="3" cy="15" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="3" r="1.5" fill="currentColor" stroke="none"/></S>;
|
||||
const IcRay = () => <S strokeWidth="1.4"><line x1="3" y1="14" x2="16" y2="5"/><circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none"/><polyline points="14,5.5 16,5 15.5,7" strokeWidth="1.3"/></S>;
|
||||
const IcInfoLine = () => <S strokeWidth="1.4"><line x1="1" y1="9" x2="17" y2="9"/><rect x="5" y="4" width="8" height="4" rx="1" strokeWidth="1.2"/><text x="9" y="7.5" fontSize="4.5" fill="currentColor" stroke="none" textAnchor="middle">i</text></S>;
|
||||
const IcExtLine = () => <S strokeWidth="1.5"><line x1="1" y1="15" x2="17" y2="3"/><polyline points="15,3 17,3 17,5" strokeWidth="1.3"/><polyline points="3,15 1,15 1,13" strokeWidth="1.3"/></S>;
|
||||
const IcTrendAngle = () => <S strokeWidth="1.4"><line x1="3" y1="15" x2="15" y2="7"/><path d="M3 15 Q8 15 10 11" strokeDasharray="2 1.5" strokeOpacity="0.7"/><text x="9" y="15" fontSize="6" fill="currentColor" stroke="none">θ</text></S>;
|
||||
const IcHline = () => <S strokeWidth="1.4"><line x1="1" y1="9" x2="17" y2="9"/><line x1="1" y1="5" x2="17" y2="5" strokeOpacity="0.4" strokeDasharray="2 2"/><line x1="1" y1="13" x2="17" y2="13" strokeOpacity="0.4" strokeDasharray="2 2"/></S>;
|
||||
const IcHlineRay = () => <S strokeWidth="1.4"><line x1="3" y1="9" x2="17" y2="9"/><circle cx="3" cy="9" r="1.5" fill="currentColor" stroke="none"/><polyline points="15,7 17,9 15,11" strokeWidth="1.3"/></S>;
|
||||
const IcVline = () => <S strokeWidth="1.4"><line x1="9" y1="1" x2="9" y2="17"/><polyline points="7,4 9,1 11,4"/><polyline points="7,14 9,17 11,14"/></S>;
|
||||
const IcCrossLine = () => <S strokeWidth="1.4"><line x1="2" y1="9" x2="16" y2="9"/><line x1="9" y1="2" x2="9" y2="16"/><circle cx="9" cy="9" r="1.8" fill="currentColor" stroke="none" fillOpacity="0.7"/></S>;
|
||||
const IcChannel = () => <S strokeWidth="1.4"><line x1="2" y1="13" x2="16" y2="5"/><line x1="2" y1="16" x2="16" y2="8" strokeOpacity="0.5" strokeDasharray="3 2"/></S>;
|
||||
const IcParaCh = () => <S strokeWidth="1.4"><line x1="2" y1="12" x2="16" y2="5"/><line x1="2" y1="15" x2="16" y2="8"/></S>;
|
||||
const IcDisjCh = () => <S strokeWidth="1.3"><line x1="2" y1="13" x2="9" y2="8"/><line x1="2" y1="15" x2="9" y2="10" strokeOpacity="0.6"/><line x1="10" y1="10" x2="16" y2="6"/><line x1="10" y1="13" x2="16" y2="9" strokeOpacity="0.6"/></S>;
|
||||
const IcFlatTop = () => <S strokeWidth="1.4"><line x1="2" y1="5" x2="16" y2="5"/><path d="M2 5 L4 9 L7 5 L10 11 L13 5 L16 5" strokeOpacity="0.7"/><line x1="2" y1="14" x2="16" y2="14" strokeOpacity="0.5"/></S>;
|
||||
// 피보나치 그룹
|
||||
const IcFib = () => <S strokeWidth="1.3"><line x1="2" y1="3" x2="16" y2="3"/><line x1="2" y1="7" x2="16" y2="7" strokeOpacity="0.6"/><line x1="2" y1="9" x2="16" y2="9"/><line x1="2" y1="12" x2="16" y2="12" strokeOpacity="0.6"/><line x1="2" y1="15" x2="16" y2="15"/><circle cx="2" cy="3" r="1.5" fill="currentColor" stroke="none"/><circle cx="2" cy="15" r="1.5" fill="currentColor" stroke="none"/></S>;
|
||||
const IcFibExt = () => <S strokeWidth="1.3"><line x1="2" y1="6" x2="16" y2="6" strokeOpacity="0.5"/><line x1="2" y1="9" x2="16" y2="9"/><line x1="2" y1="12" x2="16" y2="12" strokeOpacity="0.5"/><line x1="2" y1="15" x2="16" y2="15"/><line x1="2" y1="3" x2="16" y2="3" strokeOpacity="0.3" strokeDasharray="2 2"/><circle cx="2" cy="9" r="1.5" fill="currentColor" stroke="none"/><circle cx="2" cy="15" r="1.5" fill="currentColor" stroke="none"/></S>;
|
||||
const IcFibCh = () => <S strokeWidth="1.3"><line x1="2" y1="14" x2="16" y2="5"/><line x1="2" y1="11" x2="16" y2="2" strokeOpacity="0.6"/><line x1="2" y1="17" x2="16" y2="8" strokeOpacity="0.4"/></S>;
|
||||
const IcFibTZ = () => <S strokeWidth="1.3"><line x1="2" y1="2" x2="2" y2="16"/><line x1="5" y1="2" x2="5" y2="16" strokeOpacity="0.7"/><line x1="8" y1="2" x2="8" y2="16" strokeOpacity="0.5"/><line x1="11" y1="2" x2="11" y2="16" strokeOpacity="0.7"/><line x1="15" y1="2" x2="15" y2="16" strokeOpacity="0.4"/></S>;
|
||||
const IcFibFan = () => <S strokeWidth="1.3"><line x1="2" y1="16" x2="16" y2="3"/><line x1="2" y1="16" x2="16" y2="7" strokeOpacity="0.7"/><line x1="2" y1="16" x2="16" y2="11" strokeOpacity="0.5"/><line x1="2" y1="16" x2="16" y2="14" strokeOpacity="0.3"/></S>;
|
||||
const IcFibTrendT = () => <S strokeWidth="1.3"><line x1="3" y1="14" x2="15" y2="6"/><line x1="5" y1="6" x2="5" y2="14" strokeOpacity="0.6"/><line x1="8" y1="6" x2="8" y2="14" strokeOpacity="0.7"/><line x1="11" y1="6" x2="11" y2="14" strokeOpacity="0.5"/></S>;
|
||||
const IcFibCircles = () => <S strokeWidth="1.3"><circle cx="9" cy="9" r="2"/><circle cx="9" cy="9" r="5"/><circle cx="9" cy="9" r="7.5" strokeOpacity="0.4"/></S>;
|
||||
const IcFibSpiral = () => <S strokeWidth="1.3"><path d="M9 9 Q12 6 13 9 Q14 13 9 14 Q4 14 4 9 Q4 4 9 3 Q15 3 16 9" strokeOpacity="0.8"/></S>;
|
||||
const IcFibArc = () => <S strokeWidth="1.3"><line x1="2" y1="14" x2="16" y2="14" strokeOpacity="0.5"/><path d="M2 14 Q9 5 16 14" strokeOpacity="0.9"/><path d="M2 14 Q9 2 16 14" strokeOpacity="0.6"/></S>;
|
||||
const IcFibWedge = () => <S strokeWidth="1.3"><path d="M2 15 L16 9 L16 15 Z"/><path d="M2 15 L16 6" strokeOpacity="0.5"/></S>;
|
||||
const IcPitchfork = () => <S strokeWidth="1.4"><line x1="3" y1="9" x2="10" y2="7"/><line x1="10" y1="7" x2="16" y2="4"/><line x1="10" y1="7" x2="16" y2="10"/><circle cx="3" cy="9" r="1.5" fill="currentColor" stroke="none"/></S>;
|
||||
const IcPitchSchiff = () => <S strokeWidth="1.4"><line x1="3" y1="10" x2="9" y2="7"/><line x1="9" y1="7" x2="16" y2="3" strokeDasharray="3 2"/><line x1="9" y1="7" x2="16" y2="11"/></S>;
|
||||
const IcPitchInside = () => <S strokeWidth="1.4"><line x1="3" y1="9" x2="11" y2="7"/><line x1="11" y1="7" x2="16" y2="5" strokeOpacity="0.6"/><line x1="11" y1="7" x2="16" y2="9" strokeOpacity="0.6"/></S>;
|
||||
const IcGannBox = () => <S strokeWidth="1.3"><rect x="3" y="4" width="12" height="10" rx="0.5"/><line x1="3" y1="4" x2="15" y2="14" strokeOpacity="0.6"/><line x1="15" y1="4" x2="3" y2="14" strokeOpacity="0.4"/></S>;
|
||||
const IcGannSq = () => <S strokeWidth="1.3"><rect x="4" y="4" width="10" height="10" rx="0.5"/><line x1="4" y1="4" x2="14" y2="14" strokeOpacity="0.6"/><line x1="14" y1="4" x2="4" y2="14" strokeOpacity="0.6"/></S>;
|
||||
const IcGannFan = () => <S strokeWidth="1.3"><line x1="2" y1="16" x2="16" y2="2"/><line x1="2" y1="16" x2="16" y2="4" strokeOpacity="0.6"/><line x1="2" y1="16" x2="16" y2="8" strokeOpacity="0.5"/><line x1="2" y1="16" x2="16" y2="13" strokeOpacity="0.4"/></S>;
|
||||
// 패턴 그룹
|
||||
const IcXABCD = () => <S strokeWidth="1.4"><path d="M2 15 L6 5 L10 13 L13 7 L16 11"/><circle cx="2" cy="15" r="1.2" fill="currentColor" stroke="none"/><circle cx="6" cy="5" r="1.2" fill="currentColor" stroke="none"/><circle cx="10" cy="13" r="1.2" fill="currentColor" stroke="none"/><circle cx="13" cy="7" r="1.2" fill="currentColor" stroke="none"/><circle cx="16" cy="11" r="1.2" fill="currentColor" stroke="none"/></S>;
|
||||
const IcCypher = () => <S strokeWidth="1.4"><path d="M2 14 L7 4 L11 12 L14 6 L16 10"/><circle cx="2" cy="14" r="1.2" fill="currentColor" stroke="none"/><circle cx="7" cy="4" r="1.2" fill="currentColor" stroke="none"/><circle cx="11" cy="12" r="1.2" fill="currentColor" stroke="none"/><circle cx="14" cy="6" r="1.2" fill="currentColor" stroke="none"/><circle cx="16" cy="10" r="1.2" fill="currentColor" stroke="none"/></S>;
|
||||
const IcABCD = () => <S strokeWidth="1.4"><path d="M3 14 L8 4 L13 12 L16 6"/><circle cx="3" cy="14" r="1.2" fill="currentColor" stroke="none"/><circle cx="8" cy="4" r="1.2" fill="currentColor" stroke="none"/><circle cx="13" cy="12" r="1.2" fill="currentColor" stroke="none"/><circle cx="16" cy="6" r="1.2" fill="currentColor" stroke="none"/></S>;
|
||||
const IcHeadShould = () => <S strokeWidth="1.4"><path d="M2 13 L5 8 L7 11 L9 4 L11 11 L13 8 L16 13"/></S>;
|
||||
const IcWedgePat = () => <S strokeWidth="1.4"><path d="M2 5 L16 7"/><path d="M2 14 L16 11"/><path d="M5 5.4 L5 13.2" strokeOpacity="0.4" strokeDasharray="2 2"/><path d="M9 5.9 L9 12.5" strokeOpacity="0.4" strokeDasharray="2 2"/><path d="M13 6.4 L13 11.8" strokeOpacity="0.4" strokeDasharray="2 2"/></S>;
|
||||
const IcThreeDrives = () => <S strokeWidth="1.4"><path d="M2 13 L5 8 L8 11 L11 6 L14 9 L16 6"/><circle cx="5" cy="8" r="1.2" fill="currentColor" stroke="none"/><circle cx="11" cy="6" r="1.2" fill="currentColor" stroke="none"/><circle cx="16" cy="6" r="1.2" fill="currentColor" stroke="none"/></S>;
|
||||
const IcElliottImp = () => <S strokeWidth="1.3"><path d="M2 14 L5 6 L8 12 L11 4 L14 10 L16 8"/><text x="3" y="17" fontSize="4" fill="currentColor" stroke="none">1 2 3 4 5</text></S>;
|
||||
const IcElliottCorr = () => <S strokeWidth="1.3"><path d="M2 6 L7 14 L12 8 L16 14"/><text x="3" y="4" fontSize="4.5" fill="currentColor" stroke="none">A B C</text></S>;
|
||||
const IcElliottTri = () => <S strokeWidth="1.3"><path d="M2 8 L5 14 L8 9 L11 14 L14 10 L16 14"/><text x="2" y="4" fontSize="3.5" fill="currentColor" stroke="none">ABCDE</text></S>;
|
||||
const IcElliottDbl = () => <S strokeWidth="1.3"><path d="M2 14 L5 8 L8 13 L11 7 L14 12 L16 10"/><text x="2" y="4" fontSize="4" fill="currentColor" stroke="none">W X Y</text></S>;
|
||||
// 프로젝션 그룹
|
||||
const IcLongPos = () => <S strokeWidth="1.3"><rect x="3" y="5" width="12" height="5" rx="0.5" fill="rgba(76,175,80,0.3)" stroke="#4CAF50"/><rect x="3" y="10" width="12" height="4" rx="0.5" fill="rgba(239,83,80,0.2)" stroke="#EF5350" strokeDasharray="2 1.5"/><line x1="3" y1="10" x2="15" y2="10" stroke="#9E9E9E"/></S>;
|
||||
const IcShortPos = () => <S strokeWidth="1.3"><rect x="3" y="9" width="12" height="5" rx="0.5" fill="rgba(239,83,80,0.3)" stroke="#EF5350"/><rect x="3" y="5" width="12" height="4" rx="0.5" fill="rgba(76,175,80,0.2)" stroke="#4CAF50" strokeDasharray="2 1.5"/><line x1="3" y1="9" x2="15" y2="9" stroke="#9E9E9E"/></S>;
|
||||
const IcForecast = () => <S strokeWidth="1.3"><line x1="2" y1="14" x2="9" y2="8"/><line x1="9" y1="8" x2="16" y2="5" strokeDasharray="3 2" strokeOpacity="0.7"/><circle cx="9" cy="8" r="2" fill="currentColor" stroke="none" fillOpacity="0.5"/></S>;
|
||||
const IcBarPattern = () => <S strokeWidth="1.3"><rect x="2" y="3" width="6" height="12" rx="0.5" strokeOpacity="0.6"/><rect x="2" y="5" width="6" height="8" rx="0.5" fill="currentColor" fillOpacity="0.2"/><line x1="5" y1="3" x2="5" y2="5"/><line x1="5" y1="13" x2="5" y2="15"/><line x1="10" y1="3" x2="16" y2="6" strokeDasharray="2 1.5" strokeOpacity="0.6"/><line x1="10" y1="13" x2="16" y2="14" strokeDasharray="2 1.5" strokeOpacity="0.6"/></S>;
|
||||
const IcGhostFeed = () => <S strokeWidth="1.3"><rect x="2" y="5" width="6" height="8" rx="0.5" strokeOpacity="0.4" strokeDasharray="2 1.5"/><rect x="10" y="4" width="6" height="10" rx="0.5" strokeOpacity="0.4" strokeDasharray="2 1.5"/><line x1="5" y1="5" x2="5" y2="3" strokeOpacity="0.4"/><line x1="13" y1="4" x2="13" y2="2" strokeOpacity="0.4"/></S>;
|
||||
const IcProjection = () => <S strokeWidth="1.3"><path d="M2 13 L8 8"/><path d="M8 8 Q12 5 16 7" strokeDasharray="3 2" strokeOpacity="0.7"/><circle cx="8" cy="8" r="2" fill="currentColor" stroke="none" fillOpacity="0.5"/><circle cx="2" cy="13" r="1.5" fill="currentColor" stroke="none"/></S>;
|
||||
const IcVWAP = () => <S strokeWidth="1.3"><path d="M2 9 Q6 6 9 9 Q12 12 16 9" strokeOpacity="0.8"/><circle cx="2" cy="9" r="2" fill="currentColor" stroke="none" fillOpacity="0.7"/><line x1="2" y1="6" x2="2" y2="12" strokeOpacity="0.5"/></S>;
|
||||
const IcVolProfile = () => <S strokeWidth="1.3"><line x1="3" y1="2" x2="3" y2="16"/><line x1="3" y1="5" x2="9" y2="5" strokeWidth="2.5" strokeOpacity="0.5"/><line x1="3" y1="8" x2="14" y2="8" strokeWidth="2.5" strokeOpacity="0.7"/><line x1="3" y1="11" x2="11" y2="11" strokeWidth="2.5" strokeOpacity="0.5"/><line x1="3" y1="14" x2="7" y2="14" strokeWidth="2.5" strokeOpacity="0.4"/></S>;
|
||||
const IcPriceRange = () => <S strokeWidth="1.4"><line x1="2" y1="9" x2="16" y2="9"/><line x1="2" y1="6" x2="2" y2="12"/><line x1="16" y1="6" x2="16" y2="12"/></S>;
|
||||
// 도형 그룹
|
||||
const IcRect = () => <S strokeWidth="1.4"><rect x="3" y="5" width="12" height="8" rx="0.5"/></S>;
|
||||
const IcRotRect = () => <S strokeWidth="1.4"><path d="M9 2 L16 7 L13 16 L3 14 L2 7 Z"/></S>;
|
||||
const IcCircle = () => <S strokeWidth="1.4"><circle cx="9" cy="9" r="6.5"/></S>;
|
||||
const IcEllipse = () => <S strokeWidth="1.4"><ellipse cx="9" cy="9" rx="7" ry="4.5"/></S>;
|
||||
const IcTriangle = () => <S strokeWidth="1.4"><path d="M9 3 L16 15 L2 15 Z"/></S>;
|
||||
const IcParallelo = () => <S strokeWidth="1.4"><path d="M4 14 L7 4 L16 4 L13 14 Z"/></S>;
|
||||
const IcPolyline = () => <S strokeWidth="1.4"><path d="M2 14 L6 6 L10 11 L14 5"/><circle cx="2" cy="14" r="1.5" fill="currentColor" stroke="none"/><circle cx="14" cy="5" r="1.5" fill="currentColor" stroke="none"/></S>;
|
||||
const IcArc = () => <S strokeWidth="1.4"><path d="M2 15 Q9 1 16 15"/></S>;
|
||||
// 브러시 그룹
|
||||
const IcPencil = () => <S strokeWidth="1.4"><path d="M3 15L3.5 11L13 2.5Q14.5 1 16 2.5Q17.5 4 16 5.5L6.5 15Z"/><line x1="11" y1="4" x2="14" y2="7"/></S>;
|
||||
const IcHighlight = () => <S><path d="M3 13 Q9 8 15 13" strokeOpacity="0.5" strokeWidth="5" stroke="#FFEB3B" fill="none"/><path d="M3 13 Q9 8 15 13" strokeWidth="1.4" stroke="currentColor" fill="none"/></S>;
|
||||
const IcArrowMark = () => <S strokeWidth="1.4"><line x1="3" y1="14" x2="15" y2="5"/><circle cx="3" cy="14" r="1.5" fill="currentColor" stroke="none"/><polyline points="11,4 15,5 14,9" strokeWidth="1.3" fill="currentColor" fillOpacity="0.5"/></S>;
|
||||
const IcArrowAnch = () => <S strokeWidth="1.4"><circle cx="4" cy="14" r="2" fill="currentColor" stroke="none" fillOpacity="0.5"/><line x1="4" y1="12" x2="14" y2="5" strokeDasharray="2 1.5"/><polyline points="11,4 14,5 13,8" fill="currentColor" fillOpacity="0.6"/></S>;
|
||||
const IcArrowUp = () => <S strokeWidth="1.4"><path d="M9 14 L9 7"/><path d="M5 10 L9 6 L13 10" fill="currentColor" fillOpacity="0.5"/></S>;
|
||||
const IcArrowDown = () => <S strokeWidth="1.4"><path d="M9 4 L9 11"/><path d="M5 8 L9 12 L13 8" fill="currentColor" fillOpacity="0.5"/></S>;
|
||||
const IcArrowLeft = () => <S strokeWidth="1.4"><path d="M14 9 L7 9"/><path d="M10 5 L6 9 L10 13" fill="currentColor" fillOpacity="0.5"/></S>;
|
||||
const IcArrowRight = () => <S strokeWidth="1.4"><path d="M4 9 L11 9"/><path d="M8 5 L12 9 L8 13" fill="currentColor" fillOpacity="0.5"/></S>;
|
||||
// 텍스트 그룹
|
||||
const IcText = () => <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><text x="3" y="14" fontSize="13" fontWeight="700" fill="currentColor" fontFamily="sans-serif">T</text></svg>;
|
||||
const IcFixedText = () => <svg width="18" height="18" viewBox="0 0 18 18" fill="none"><text x="3" y="14" fontSize="13" fontWeight="700" fill="currentColor" fontFamily="sans-serif">T</text><circle cx="15" cy="15" r="3" fill="currentColor" fillOpacity="0.5" stroke="none"/></svg>;
|
||||
const IcNote = () => <S strokeWidth="1.3"><rect x="2" y="3" width="14" height="12" rx="1.5"/><line x1="5" y1="7" x2="13" y2="7" strokeOpacity="0.7"/><line x1="5" y1="9.5" x2="13" y2="9.5" strokeOpacity="0.7"/><line x1="5" y1="12" x2="10" y2="12" strokeOpacity="0.7"/><circle cx="16" cy="3" r="2.5" stroke="none" fill="currentColor" fillOpacity="0.5"/></S>;
|
||||
const IcPriceNote = () => <S strokeWidth="1.3"><line x1="2" y1="9" x2="8" y2="9"/><rect x="8" y="5" width="8" height="8" rx="1"/><text x="12" y="10.5" fontSize="5" fill="currentColor" stroke="none" textAnchor="middle">$</text></S>;
|
||||
const IcPin = () => <S strokeWidth="1.3"><path d="M9 3 Q12 3 12 7 Q12 10 9 15 Q6 10 6 7 Q6 3 9 3 Z"/><circle cx="9" cy="7" r="1.8" stroke="none" fill="currentColor" fillOpacity="0.6"/></S>;
|
||||
const IcTable = () => <S strokeWidth="1.3"><rect x="2" y="3" width="14" height="12" rx="1"/><line x1="2" y1="7" x2="16" y2="7"/><line x1="2" y1="11" x2="16" y2="11"/><line x1="7" y1="3" x2="7" y2="15"/><line x1="12" y1="3" x2="12" y2="15"/></S>;
|
||||
const IcCallout = () => <S strokeWidth="1.3"><rect x="2" y="3" width="12" height="8" rx="1.5"/><path d="M7 11 L5 15 L10 11"/></S>;
|
||||
const IcComment = () => <S strokeWidth="1.3"><path d="M2 3 Q2 2 3 2 L15 2 Q16 2 16 3 L16 11 Q16 12 15 12 L8 12 L5 15 L5 12 L3 12 Q2 12 2 11 Z"/></S>;
|
||||
const IcPriceLabel = () => <S strokeWidth="1.3"><path d="M2 9 L6 9 L8 6 L12 6 L14 9 L12 12 L8 12 L6 9"/></S>;
|
||||
const IcGuide = () => <S strokeWidth="1.3"><line x1="1" y1="9" x2="17" y2="9" strokeDasharray="3 2" strokeOpacity="0.8"/><circle cx="9" cy="9" r="3" strokeOpacity="0.6"/></S>;
|
||||
const IcFlag = () => <S strokeWidth="1.3"><line x1="5" y1="3" x2="5" y2="15"/><path d="M5 3 L15 6 L5 9 Z" fill="currentColor" fillOpacity="0.4"/></S>;
|
||||
// 측정 그룹
|
||||
const IcMeasure = () => <S strokeWidth="1.4"><line x1="2" y1="9" x2="16" y2="9"/><line x1="2" y1="6" x2="2" y2="12"/><line x1="16" y1="6" x2="16" y2="12"/></S>;
|
||||
const IcDateRange = () => <S strokeWidth="1.3"><rect x="2" y="4" width="14" height="12" rx="1"/><line x1="2" y1="8" x2="16" y2="8"/><line x1="6" y1="2" x2="6" y2="6"/><line x1="12" y1="2" x2="12" y2="6"/></S>;
|
||||
const IcDatePriceR = () => <S strokeWidth="1.3"><rect x="2" y="3" width="14" height="12" rx="1" strokeOpacity="0.7"/><line x1="2" y1="9" x2="16" y2="9" strokeOpacity="0.6"/><line x1="9" y1="3" x2="9" y2="15" strokeOpacity="0.6"/></S>;
|
||||
const IcDataWindow = () => <S strokeWidth="1.3"><rect x="2" y="3" width="14" height="12" rx="1"/><line x1="2" y1="7" x2="16" y2="7"/><line x1="7" y1="3" x2="7" y2="7"/></S>;
|
||||
const IcZoom = () => <S strokeWidth="1.4"><circle cx="8" cy="8" r="5"/><line x1="12" y1="12" x2="16" y2="16"/><line x1="6" y1="8" x2="10" y2="8"/><line x1="8" y1="6" x2="8" y2="10"/></S>;
|
||||
const IcMagnifier = () => <S strokeWidth="1.4"><circle cx="8" cy="8" r="5"/><line x1="12" y1="12" x2="16" y2="16"/></S>;
|
||||
// 하단 버튼 아이콘
|
||||
const IcMagnetWeak = () => <S strokeWidth="1.4"><path d="M5 14 L5 8 Q5 3 9 3 Q13 3 13 8 L13 14"/><line x1="3" y1="14" x2="7" y2="14" strokeWidth="2"/><line x1="11" y1="14" x2="15" y2="14" strokeWidth="2"/></S>;
|
||||
const IcMagnetStrg = () => <S strokeWidth="1.4"><path d="M5 14 L5 8 Q5 3 9 3 Q13 3 13 8 L13 14"/><line x1="3" y1="14" x2="7" y2="14" strokeWidth="2"/><line x1="11" y1="14" x2="15" y2="14" strokeWidth="2"/><line x1="9" y1="9" x2="9" y2="13" strokeDasharray="1.5 1" strokeOpacity="0.7"/></S>;
|
||||
const IcLock = () => <S strokeWidth="1.4"><rect x="4" y="9" width="10" height="7" rx="1"/><path d="M6 9V6Q6 3 9 3Q12 3 12 6V9"/></S>;
|
||||
const IcEye = () => <S strokeWidth="1.4"><path d="M1 9Q5 3 9 3Q13 3 17 9Q13 15 9 15Q5 15 1 9Z"/><circle cx="9" cy="9" r="2.5"/></S>;
|
||||
const IcEyeOff = () => <S strokeWidth="1.4"><path d="M1 9Q5 3 9 3Q13 3 17 9Q13 15 9 15Q5 15 1 9Z" strokeOpacity="0.3"/><line x1="3" y1="3" x2="15" y2="15"/></S>;
|
||||
const IcTrash = () => <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round"><line x1="3" y1="5" x2="13" y2="5"/><path d="M6 5V3Q6 2 8 2Q10 2 10 3V5"/><path d="M4 5L4.5 14Q4.5 14.5 5 14.5L11 14.5Q11.5 14.5 11.5 14L12 5"/></svg>;
|
||||
const IcUndo = () => <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M3 7C3 4 5.5 2 8.5 2C11.5 2 14 4 14 7C14 10 11.5 12 8.5 12"/><polyline points="1,5 3,7 5,5"/></svg>;
|
||||
const IcLayers = () => <S strokeWidth="1.4"><path d="M2 12 L9 15 L16 12"/><path d="M2 9 L9 12 L16 9"/><path d="M2 6 L9 9 L16 6"/></S>;
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────
|
||||
type FlyoutItem =
|
||||
| { kind: 'tool'; id: string; title: string; shortcut?: string; icon: React.ReactNode }
|
||||
| { kind: 'sep'; label?: string };
|
||||
|
||||
interface GroupDef {
|
||||
id: number;
|
||||
label: string;
|
||||
defaultIcon: React.ReactNode;
|
||||
items: FlyoutItem[];
|
||||
}
|
||||
|
||||
// ─── 그룹 정의 ──────────────────────────────────────────────────────────────
|
||||
const T = (id: string, title: string, icon: React.ReactNode, shortcut?: string): FlyoutItem =>
|
||||
({ kind: 'tool', id, title, icon, shortcut });
|
||||
const SEP = (label?: string): FlyoutItem => ({ kind: 'sep', label });
|
||||
|
||||
/** 업비트 차트 좌측 즐겨찾기 플로팅 툴바와 동일 4종 */
|
||||
export const FAVORITE_DRAWING_TOOLS = ['hline', 'vline', 'date_range', 'fibtz'] as const;
|
||||
|
||||
const GROUPS: GroupDef[] = [
|
||||
{
|
||||
id: 0, label: '즐겨찾기', defaultIcon: <IcFavorite />,
|
||||
items: [
|
||||
T('hline', '가로줄', <IcUpbitHline />, '⌥H'),
|
||||
T('vline', '세로줄', <IcUpbitVline />, '⌥V'),
|
||||
T('date_range', '기간', <IcUpbitPeriod />),
|
||||
T('fibtz', '피보나치 타임존', <IcUpbitFibTz />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 1, label: '라인', defaultIcon: <IcTrendline />,
|
||||
items: [
|
||||
T('trendline', '추세줄', <IcTrendline />, '⌥T'),
|
||||
T('ray', '빛', <IcRay />),
|
||||
T('info_line', '인포 라인', <IcInfoLine />),
|
||||
T('extended_line', '익스텐디드 라인', <IcExtLine />),
|
||||
T('trend_angle', '추세각', <IcTrendAngle />),
|
||||
SEP(),
|
||||
T('hline', '가로줄', <IcHline />, '⌥H'),
|
||||
T('hline_ray', '가로빛', <IcHlineRay />, '⌥J'),
|
||||
T('vline', '수직선', <IcVline />, '⌥V'),
|
||||
T('cross_line', '크로스 라인', <IcCrossLine />, '⌥C'),
|
||||
SEP('채널'),
|
||||
T('channel', '추세 채널', <IcChannel />),
|
||||
T('parallel_channel','패러렐 채널', <IcParaCh />),
|
||||
T('disjoint_channel','디스조인트 채널',<IcDisjCh />),
|
||||
T('flat_top_bottom','플랫 탑/바텀', <IcFlatTop />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2, label: '피보나치', defaultIcon: <IcFib />,
|
||||
items: [
|
||||
T('fib', '피보나치 되돌림', <IcFib />, '⌥F'),
|
||||
T('fib_ext', '추세기반 피보나치 확장', <IcFibExt />),
|
||||
T('fib_channel', '피보나치 채널', <IcFibCh />),
|
||||
T('fibtz', '피보나치 타임존', <IcFibTZ />),
|
||||
T('fib_speed_fan', '스피드 리지스턴스 팬', <IcFibFan />),
|
||||
T('fib_trend_time','추세기반 피보나치 시간', <IcFibTrendT />),
|
||||
T('fib_circles', '피보나치 서클', <IcFibCircles />),
|
||||
T('fib_spiral', '피보나치 스파이럴', <IcFibSpiral />),
|
||||
T('fib_speed_arc', '스피드 리지스턴스 아크', <IcFibArc />),
|
||||
T('fib_wedge', '피보나치 웻지', <IcFibWedge />),
|
||||
SEP('피치포크'),
|
||||
T('pitchfork', '앤드류스 피치포크', <IcPitchfork />),
|
||||
T('pitchfork_schiff', '쉬프 피치포크', <IcPitchSchiff />),
|
||||
T('pitchfork_inside', '인사이드 피치포크', <IcPitchInside />),
|
||||
SEP('갠'),
|
||||
T('gann_box', '갠 박스', <IcGannBox />),
|
||||
T('gann_square', '갠 스퀘어', <IcGannSq />),
|
||||
T('gann_fan', '갠 팬', <IcGannFan />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3, label: '패턴', defaultIcon: <IcXABCD />,
|
||||
items: [
|
||||
T('xabcd', 'XABCD 패턴', <IcXABCD />),
|
||||
T('cypher', '사이퍼 패턴', <IcCypher />),
|
||||
T('head_shoulders', '헤드 앤 숄더', <IcHeadShould />),
|
||||
T('abcd', 'ABCD 패턴', <IcABCD />),
|
||||
T('wedge_pattern', '세모 패턴', <IcWedgePat />),
|
||||
T('three_drives', '쓰리 드라이브 패턴', <IcThreeDrives />),
|
||||
SEP('엘리엇 웨이브'),
|
||||
T('elliott_impulse', '엘리엇 임펄스 파동 (12345)', <IcElliottImp />),
|
||||
T('elliott_correction', '엘리엇 코렉션 파동 (ABC)', <IcElliottCorr />),
|
||||
T('elliott_triangle_wave', '엘리엇 트라이앵글 웨이브 (ABCDE)',<IcElliottTri />),
|
||||
T('elliott_double_combo', '엘리엇 다블콤보 파동 (WXY)', <IcElliottDbl />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4, label: '프로젝션', defaultIcon: <IcLongPos />,
|
||||
items: [
|
||||
T('long_position', '매수 포지션', <IcLongPos />),
|
||||
T('short_position', '숏 포지션', <IcShortPos />),
|
||||
T('forecast', '예측', <IcForecast />),
|
||||
T('bar_pattern', '봉패턴', <IcBarPattern />),
|
||||
T('ghost_feed', '고스트피드', <IcGhostFeed />),
|
||||
T('projection_tool','프로젝션', <IcProjection />),
|
||||
SEP('볼륨-기반'),
|
||||
T('anchored_vwap', '앵커드 VWAP', <IcVWAP />),
|
||||
T('fixed_volume_profile', '픽스트 레인지 볼륨 프로파일', <IcVolProfile />),
|
||||
SEP('계측기'),
|
||||
T('price_range', '가격범위', <IcPriceRange />),
|
||||
T('date_range', '날짜 범위', <IcDateRange />),
|
||||
T('date_price_range','날짜&가격 범위', <IcDatePriceR />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5, label: '도형', defaultIcon: <IcRect />,
|
||||
items: [
|
||||
T('rect', '네모', <IcRect />, '⌥⇧R'),
|
||||
T('rotated_rect', '회전 사각형', <IcRotRect />),
|
||||
T('circle', '원', <IcCircle />),
|
||||
T('ellipse', '타원', <IcEllipse />),
|
||||
T('triangle', '삼각형', <IcTriangle />),
|
||||
T('parallelogram', '평행사변형', <IcParallelo />),
|
||||
T('polyline', '다각형', <IcPolyline />),
|
||||
T('arc', '호', <IcArc />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 6, label: '브러시', defaultIcon: <IcPencil />,
|
||||
items: [
|
||||
T('pencil', '붓', <IcPencil />),
|
||||
T('highlight', '하이라이터', <IcHighlight />),
|
||||
SEP('화살'),
|
||||
T('arrow_mark', '화살표', <IcArrowMark />),
|
||||
T('anchored_arrow', '화살표', <IcArrowAnch />),
|
||||
T('arrow_mark_up', '위화살표', <IcArrowUp />),
|
||||
T('arrow_mark_down', '아래화살표', <IcArrowDown />),
|
||||
T('arrow_mark_left', '왼화살표', <IcArrowLeft />),
|
||||
T('arrow_mark_right', '오른화살표', <IcArrowRight />),
|
||||
SEP('세이프'),
|
||||
T('rect', '네모', <IcRect />, '⌥⇧R'),
|
||||
T('circle', '원', <IcCircle />),
|
||||
T('triangle', '삼각형', <IcTriangle />),
|
||||
T('ellipse', '타원', <IcEllipse />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 7, label: '텍스트 & 노트', defaultIcon: <IcText />,
|
||||
items: [
|
||||
T('text', '텍스트', <IcText />, 'T'),
|
||||
T('fixed_text', '고정위치문자', <IcFixedText />),
|
||||
T('note_text', '노트', <IcNote />),
|
||||
T('price_note', '프라이스 노트', <IcPriceNote />),
|
||||
T('pin_mark', '핀', <IcPin />),
|
||||
T('table_tool', '테이블', <IcTable />),
|
||||
T('callout', '콜아웃', <IcCallout />),
|
||||
T('comment_mark', '코멘트', <IcComment />),
|
||||
T('price_label', '가격라벨', <IcPriceLabel />),
|
||||
T('guide_line', '길잡이', <IcGuide />),
|
||||
T('flag_mark', '플래그 마크', <IcFlag />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 8, label: '측정', defaultIcon: <IcMeasure />,
|
||||
items: [
|
||||
T('measure', '가격 측정', <IcMeasure />, 'M'),
|
||||
T('date_range', '날짜 범위', <IcDateRange />),
|
||||
T('date_price_range', '날짜&가격 범위', <IcDatePriceR />),
|
||||
T('data_window', '데이터 창', <IcDataWindow />),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 9, label: '확대/축소', defaultIcon: <IcZoom />,
|
||||
items: [
|
||||
T('zoom', '확대/축소', <IcZoom />),
|
||||
T('magnifier', '돋보기', <IcMagnifier />),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** 차트 모드에서도 사용 가능한 확대 도구 (드로잉 툴바는 chart 모드에서 기본 비활성) */
|
||||
export const CHART_ZOOM_TOOLS = new Set(['zoom', 'magnifier']);
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
const DrawingToolbar: React.FC<DrawingToolbarProps> = ({
|
||||
activeTool, mode, onTool, onClearAll, onUndo,
|
||||
locked, visible, onToggleLock, onToggleEye,
|
||||
indicatorsVisible = true,
|
||||
magnetMode = 'off',
|
||||
snapToIndicator = false,
|
||||
drawingCount = 0,
|
||||
indicatorCount = 0,
|
||||
onMagnetMode,
|
||||
onSnapToIndicator,
|
||||
onHideDrawings,
|
||||
onHideIndicators,
|
||||
onHideAll,
|
||||
onClearIndicators,
|
||||
keepLockedOnDelete = false,
|
||||
onToggleKeepLocked,
|
||||
}) => {
|
||||
const disabled = mode === 'chart';
|
||||
const canSelectTool = (toolId: string) => !disabled || CHART_ZOOM_TOOLS.has(toolId);
|
||||
|
||||
const [selectedPerGroup, setSelectedPerGroup] = useState<Record<number, string>>({});
|
||||
const [openGroup, setOpenGroup] = useState<number | null>(null);
|
||||
const [flyoutKind, setFlyoutKind] = useState<'group' | 'magnet' | 'eye' | 'trash' | null>(null);
|
||||
const [flyoutPos, setFlyoutPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 });
|
||||
|
||||
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (openTimer.current) clearTimeout(openTimer.current);
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
}, []);
|
||||
|
||||
const getRepIcon = (group: GroupDef): React.ReactNode => {
|
||||
const selId = selectedPerGroup[group.id];
|
||||
if (selId) {
|
||||
const it = group.items.find(i => i.kind === 'tool' && i.id === selId);
|
||||
if (it && it.kind === 'tool') return it.icon;
|
||||
}
|
||||
const active = group.items.find(i => i.kind === 'tool' && i.id === activeTool);
|
||||
if (active && active.kind === 'tool') return active.icon;
|
||||
return group.defaultIcon;
|
||||
};
|
||||
|
||||
const isGroupActive = (group: GroupDef) =>
|
||||
group.items.some(i => i.kind === 'tool' && i.id === activeTool);
|
||||
|
||||
const openFlyout = (kind: 'group' | 'magnet' | 'eye' | 'trash', groupId: number | null, el: HTMLElement) => {
|
||||
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
|
||||
if (openTimer.current) clearTimeout(openTimer.current);
|
||||
openTimer.current = setTimeout(() => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
setFlyoutPos({ top: rect.top, left: rect.right + 4 });
|
||||
setOpenGroup(groupId);
|
||||
setFlyoutKind(kind);
|
||||
}, 80);
|
||||
};
|
||||
|
||||
const scheduleClose = () => {
|
||||
if (openTimer.current) { clearTimeout(openTimer.current); openTimer.current = null; }
|
||||
closeTimer.current = setTimeout(() => { setFlyoutKind(null); setOpenGroup(null); }, 200);
|
||||
};
|
||||
|
||||
const cancelClose = () => {
|
||||
if (closeTimer.current) { clearTimeout(closeTimer.current); closeTimer.current = null; }
|
||||
};
|
||||
|
||||
const selectTool = (groupId: number, toolId: string) => {
|
||||
if (!canSelectTool(toolId)) return;
|
||||
setSelectedPerGroup(prev => ({ ...prev, [groupId]: toolId }));
|
||||
setFlyoutKind(null); setOpenGroup(null);
|
||||
if (openTimer.current) clearTimeout(openTimer.current);
|
||||
if (closeTimer.current) clearTimeout(closeTimer.current);
|
||||
onTool(toolId);
|
||||
};
|
||||
|
||||
const handleGroupClick = (group: GroupDef) => {
|
||||
if (disabled && group.id !== 9) return;
|
||||
const selId = selectedPerGroup[group.id];
|
||||
const toolId = (selId && group.items.find(i => i.kind === 'tool' && i.id === selId))
|
||||
? selId
|
||||
: (group.items.find(i => i.kind === 'tool' && i.id === activeTool) as { kind: 'tool'; id: string } | undefined)?.id
|
||||
?? (group.items.find(i => i.kind === 'tool') as { kind: 'tool'; id: string } | undefined)?.id;
|
||||
if (toolId && canSelectTool(toolId)) onTool(toolId);
|
||||
};
|
||||
|
||||
const closeFlyout = () => { setFlyoutKind(null); setOpenGroup(null); };
|
||||
|
||||
// 현재 마그넷 아이콘
|
||||
const magnetIcon = magnetMode === 'strong' ? <IcMagnetStrg /> : <IcMagnetWeak />;
|
||||
const eyeIsOff = !visible && !indicatorsVisible;
|
||||
|
||||
return (
|
||||
<div className={`tv-drawing-sidebar ${disabled ? 'chart-mode' : 'trading-mode'}`}>
|
||||
|
||||
{/* ── 커서 ── */}
|
||||
<button
|
||||
className={`tv-side-btn ${activeTool === 'cursor' && !disabled ? 'active' : ''}`}
|
||||
title="커서 (Esc)"
|
||||
onClick={() => !disabled && onTool('cursor')}
|
||||
>
|
||||
<IcCursor />
|
||||
</button>
|
||||
|
||||
<div className="tv-side-sep" />
|
||||
|
||||
{/* ── 툴 그룹 (즐겨찾기 → 라인 …) ── */}
|
||||
{GROUPS.map(group => (
|
||||
<div key={group.id} className="tv-side-group">
|
||||
<button
|
||||
className={`tv-side-btn tv-side-group-btn${group.id === 0 ? ' tv-side-group-btn--favorite' : ''} ${isGroupActive(group) && (!disabled || group.id === 9) ? 'active' : ''}`}
|
||||
onMouseEnter={e => openFlyout('group', group.id, e.currentTarget)}
|
||||
onMouseLeave={scheduleClose}
|
||||
onClick={() => handleGroupClick(group)}
|
||||
title={group.label}
|
||||
>
|
||||
{getRepIcon(group)}
|
||||
<span className="tv-side-expand-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="tv-side-spacer" />
|
||||
<div className="tv-side-sep" />
|
||||
|
||||
{/* ── 하단 버튼: 마그넷 ── */}
|
||||
<button
|
||||
className={`tv-side-btn ${magnetMode !== 'off' ? 'active' : ''}`}
|
||||
title="마그넷"
|
||||
onMouseEnter={e => openFlyout('magnet', null, e.currentTarget)}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
{magnetIcon}
|
||||
</button>
|
||||
|
||||
{/* ── 드로잉 잠금 ── */}
|
||||
<button
|
||||
className={`tv-side-btn ${locked ? 'active tv-side-lock-on' : ''}`}
|
||||
title={locked ? '잠금 해제' : '드로잉 잠금'}
|
||||
onClick={() => !disabled && onToggleLock()}
|
||||
>
|
||||
<IcLock />
|
||||
</button>
|
||||
|
||||
{/* ── 표시/숨기기 토글 (플라이아웃) ── */}
|
||||
<button
|
||||
className={`tv-side-btn ${eyeIsOff ? 'tv-side-eye-off active' : (!visible || !indicatorsVisible) ? 'tv-side-eye-partial' : ''}`}
|
||||
title="표시/숨기기"
|
||||
onMouseEnter={e => openFlyout('eye', null, e.currentTarget)}
|
||||
onMouseLeave={scheduleClose}
|
||||
onClick={() => !disabled && onToggleEye()}
|
||||
>
|
||||
{eyeIsOff ? <IcEyeOff /> : <IcEye />}
|
||||
</button>
|
||||
|
||||
<div className="tv-side-sep" />
|
||||
|
||||
{/* ── 실행 취소 ── */}
|
||||
<button className="tv-side-btn" title="실행 취소 (Ctrl+Z)" onClick={() => !disabled && onUndo()}>
|
||||
<IcUndo />
|
||||
</button>
|
||||
|
||||
{/* ── 삭제 (플라이아웃) ── */}
|
||||
<button
|
||||
className="tv-side-btn tv-side-clear"
|
||||
title="삭제"
|
||||
onMouseEnter={e => openFlyout('trash', null, e.currentTarget)}
|
||||
onMouseLeave={scheduleClose}
|
||||
onClick={() => !disabled && onClearAll()}
|
||||
>
|
||||
<IcTrash />
|
||||
</button>
|
||||
|
||||
{/* ── 레이어 ── */}
|
||||
<button className="tv-side-btn" title="레이어">
|
||||
<IcLayers />
|
||||
</button>
|
||||
|
||||
{/* ════════════════════════════════════════════
|
||||
── 플라이아웃 팝업 (fixed 위치)
|
||||
════════════════════════════════════════════ */}
|
||||
{flyoutKind !== null && (!disabled || (flyoutKind === 'group' && openGroup === 9)) && (
|
||||
<div
|
||||
className="tv-side-flyout"
|
||||
style={{ top: flyoutPos.top, left: flyoutPos.left }}
|
||||
onMouseEnter={cancelClose}
|
||||
onMouseLeave={scheduleClose}
|
||||
>
|
||||
{/* ── 툴 그룹 플라이아웃 ── */}
|
||||
{flyoutKind === 'group' && (() => {
|
||||
const group = GROUPS.find(g => g.id === openGroup);
|
||||
if (!group) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="tv-side-flyout-header">{group.label}</div>
|
||||
{group.items.map((item, idx) =>
|
||||
item.kind === 'sep' ? (
|
||||
<div key={`sep-${idx}`} className="tv-side-flyout-sep">
|
||||
{item.label && <span className="tv-side-flyout-sec-label">{item.label}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`tv-side-flyout-item ${activeTool === item.id ? 'active' : ''}${item.kind === 'tool' && !canSelectTool(item.id) ? ' disabled' : ''}`}
|
||||
disabled={item.kind === 'tool' && !canSelectTool(item.id)}
|
||||
onClick={() => item.kind === 'tool' && selectTool(group.id, item.id)}
|
||||
>
|
||||
<span className="tv-side-flyout-icon">{item.icon}</span>
|
||||
<span className="tv-side-flyout-title">{item.title}</span>
|
||||
{item.shortcut && <span className="tv-side-flyout-shortcut">{item.shortcut}</span>}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ── 마그넷 플라이아웃 ── */}
|
||||
{flyoutKind === 'magnet' && (
|
||||
<>
|
||||
<div className="tv-side-flyout-header">마그넷</div>
|
||||
{(['weak','strong'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`tv-side-flyout-item ${magnetMode === m ? 'active' : ''}`}
|
||||
onClick={() => { onMagnetMode?.(m); closeFlyout(); }}
|
||||
>
|
||||
<span className="tv-side-flyout-icon">{m === 'weak' ? <IcMagnetWeak /> : <IcMagnetStrg />}</span>
|
||||
<span className="tv-side-flyout-title">{m === 'weak' ? '위크 마그넷' : '스트롱 마그넷'}</span>
|
||||
</button>
|
||||
))}
|
||||
{magnetMode !== 'off' && (
|
||||
<button className="tv-side-flyout-item" onClick={() => { onMagnetMode?.('off'); closeFlyout(); }}>
|
||||
<span className="tv-side-flyout-icon"><IcMagnetWeak /></span>
|
||||
<span className="tv-side-flyout-title" style={{ opacity: 0.5 }}>마그넷 끄기</span>
|
||||
</button>
|
||||
)}
|
||||
<div className="tv-side-flyout-sep" />
|
||||
<div className="tv-side-flyout-toggle-row">
|
||||
<span className="tv-side-flyout-title">인디케이터에 스냅</span>
|
||||
<button
|
||||
className={`tv-flyout-toggle ${snapToIndicator ? 'on' : ''}`}
|
||||
onClick={() => onSnapToIndicator?.(!snapToIndicator)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 눈 플라이아웃 ── */}
|
||||
{flyoutKind === 'eye' && (
|
||||
<>
|
||||
<div className="tv-side-flyout-header">표시/숨기기</div>
|
||||
<button className="tv-side-flyout-item" onClick={() => { onHideDrawings?.(); closeFlyout(); }}>
|
||||
<span className="tv-side-flyout-icon"><IcEye /></span>
|
||||
<span className="tv-side-flyout-title">드로잉 숨기기</span>
|
||||
</button>
|
||||
<button className="tv-side-flyout-item" onClick={() => { onHideIndicators?.(); closeFlyout(); }}>
|
||||
<span className="tv-side-flyout-icon"><IcEye /></span>
|
||||
<span className="tv-side-flyout-title">인디케이터 숨기기</span>
|
||||
</button>
|
||||
<button className="tv-side-flyout-item" onClick={() => { onHideAll?.(); closeFlyout(); }}>
|
||||
<span className="tv-side-flyout-icon"><IcEyeOff /></span>
|
||||
<span className="tv-side-flyout-title">모두 숨기기</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 삭제 플라이아웃 ── */}
|
||||
{flyoutKind === 'trash' && (
|
||||
<>
|
||||
<div className="tv-side-flyout-header">삭제</div>
|
||||
<button
|
||||
className="tv-side-flyout-item"
|
||||
onClick={() => { onClearAll(); closeFlyout(); }}
|
||||
>
|
||||
<span className="tv-side-flyout-icon"><IcTrash /></span>
|
||||
<span className="tv-side-flyout-title">{drawingCount} 드로잉 없애기</span>
|
||||
</button>
|
||||
<button
|
||||
className="tv-side-flyout-item"
|
||||
onClick={() => { onClearIndicators?.(); closeFlyout(); }}
|
||||
>
|
||||
<span className="tv-side-flyout-icon"><IcLayers /></span>
|
||||
<span className="tv-side-flyout-title">{indicatorCount} 인디케이터 없애기</span>
|
||||
</button>
|
||||
<button
|
||||
className="tv-side-flyout-item"
|
||||
onClick={() => { onClearAll(); onClearIndicators?.(); closeFlyout(); }}
|
||||
>
|
||||
<span className="tv-side-flyout-icon"><IcTrash /></span>
|
||||
<span className="tv-side-flyout-title">{drawingCount} 드로잉 & {indicatorCount} 인디케이터 없애기</span>
|
||||
</button>
|
||||
<div className="tv-side-flyout-sep" />
|
||||
<div className="tv-side-flyout-toggle-row">
|
||||
<span className="tv-side-flyout-title">잠긴 드로잉은 항상 제거하세요</span>
|
||||
<button
|
||||
className={`tv-flyout-toggle ${keepLockedOnDelete ? 'on' : ''}`}
|
||||
onClick={() => onToggleKeepLocked?.()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawingToolbar;
|
||||
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Drawing, FibTZSettings, FibTZLevel, FibTZBaseline, DrawingStyle } from '../types';
|
||||
import { DEFAULT_FIB_TZ_SETTINGS } from '../types';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
const LINE_STYLES: { value: DrawingStyle; label: string }[] = [
|
||||
{ value: 'solid', label: '━━' },
|
||||
{ value: 'dashed', label: '╌╌' },
|
||||
{ value: 'dotted', label: '···' },
|
||||
];
|
||||
const LINE_WIDTHS = [1, 2, 3, 4] as const;
|
||||
|
||||
// ─── Sub-components ──────────────────────────────────────────────────────────
|
||||
|
||||
function ColorCell({
|
||||
color,
|
||||
onChange,
|
||||
}: {
|
||||
color: string;
|
||||
onChange: (c: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<label
|
||||
title="색상 선택"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: 28,
|
||||
height: 22,
|
||||
borderRadius: 3,
|
||||
background: color,
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
style={{ opacity: 0, position: 'absolute', inset: 0, width: '100%', height: '100%', cursor: 'pointer' }}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StyleSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: DrawingStyle;
|
||||
onChange: (s: DrawingStyle) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value as DrawingStyle)}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(122,162,247,0.25)',
|
||||
borderRadius: 3,
|
||||
color: 'inherit',
|
||||
fontSize: 12,
|
||||
padding: '2px 4px',
|
||||
width: 54,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{LINE_STYLES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function WidthSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (w: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(122,162,247,0.25)',
|
||||
borderRadius: 3,
|
||||
color: 'inherit',
|
||||
fontSize: 12,
|
||||
padding: '2px 4px',
|
||||
width: 44,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{LINE_WIDTHS.map(w => (
|
||||
<option key={w} value={w}>{w}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
drawing: Drawing;
|
||||
onSave: (updated: Drawing) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const FibTZSettingsModal: React.FC<Props> = ({ drawing, onSave, onCancel }) => {
|
||||
const init = drawing.fibtzSettings ?? DEFAULT_FIB_TZ_SETTINGS;
|
||||
|
||||
const [baseline, setBaseline] = useState<FibTZBaseline>({ ...init.baseline });
|
||||
const [levels, setLevels] = useState<FibTZLevel[]>(
|
||||
init.levels.map(l => ({ ...l }))
|
||||
);
|
||||
|
||||
const updateBaseline = (patch: Partial<FibTZBaseline>) =>
|
||||
setBaseline(prev => ({ ...prev, ...patch }));
|
||||
|
||||
const updateLevel = (idx: number, patch: Partial<FibTZLevel>) =>
|
||||
setLevels(prev => prev.map((l, i) => i === idx ? { ...l, ...patch } : l));
|
||||
|
||||
const handleReset = () => {
|
||||
setBaseline({ ...DEFAULT_FIB_TZ_SETTINGS.baseline });
|
||||
setLevels(DEFAULT_FIB_TZ_SETTINGS.levels.map(l => ({ ...l })));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const settings: FibTZSettings = { baseline, levels };
|
||||
onSave({ ...drawing, fibtzSettings: settings });
|
||||
};
|
||||
|
||||
const cellStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
fontSize: 10,
|
||||
color: 'rgba(192,202,245,0.55)',
|
||||
textAlign: 'center',
|
||||
paddingBottom: 6,
|
||||
};
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dsm-overlay"
|
||||
style={{ zIndex: 1200 }}
|
||||
onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="dsm-dialog"
|
||||
style={{
|
||||
...panelStyle,
|
||||
width: 460,
|
||||
maxHeight: '90vh',
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title">피보나치 타임존 설정</span>
|
||||
<button type="button" className="gc-popup-close" onClick={onCancel} aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div className="dsm-body" style={{ color: 'var(--text, #c0caf5)' }}>
|
||||
|
||||
{/* ── 기준선 (0) ── */}
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontSize: 11, color: 'rgba(192,202,245,0.6)', marginBottom: 8, fontWeight: 600 }}>
|
||||
기준선 (항상 표시)
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '28px 28px 54px 44px',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: 5,
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
{/* 항상 활성화 표시 */}
|
||||
<div style={{ ...cellStyle }}>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: 3,
|
||||
background: 'var(--accent, #7aa2f7)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none">
|
||||
<polyline points="1.5,4.5 3.5,7 7.5,2" stroke="#fff" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<ColorCell color={baseline.color} onChange={c => updateBaseline({ color: c })} />
|
||||
<StyleSelect value={baseline.style} onChange={s => updateBaseline({ style: s })} />
|
||||
<WidthSelect value={baseline.lineWidth} onChange={w => updateBaseline({ lineWidth: w })} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 레벨 목록 ── */}
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(192,202,245,0.6)', marginBottom: 6, fontWeight: 600 }}>
|
||||
레벨
|
||||
</div>
|
||||
|
||||
{/* 헤더 행 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '28px 60px 28px 54px 44px',
|
||||
gap: 8,
|
||||
padding: '0 10px',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<div style={headerStyle}>표시</div>
|
||||
<div style={headerStyle}>값</div>
|
||||
<div style={headerStyle}>색상</div>
|
||||
<div style={headerStyle}>스타일</div>
|
||||
<div style={headerStyle}>두께</div>
|
||||
</div>
|
||||
|
||||
{/* 레벨 행들 */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{levels.map((lv, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '28px 60px 28px 54px 44px',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
background: idx % 2 === 0 ? 'rgba(255,255,255,0.03)' : 'transparent',
|
||||
borderRadius: 4,
|
||||
padding: '5px 10px',
|
||||
}}
|
||||
>
|
||||
{/* 표시 체크박스 */}
|
||||
<div style={cellStyle}>
|
||||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lv.enabled}
|
||||
onChange={e => updateLevel(idx, { enabled: e.target.checked })}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: 3,
|
||||
background: lv.enabled ? 'var(--accent, #7aa2f7)' : 'rgba(255,255,255,0.1)',
|
||||
border: lv.enabled ? 'none' : '1px solid rgba(255,255,255,0.25)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{lv.enabled && (
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none">
|
||||
<polyline points="1.5,4.5 3.5,7 7.5,2" stroke="#fff" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 값 (편집 가능) */}
|
||||
<input
|
||||
type="number"
|
||||
value={lv.value}
|
||||
min={1}
|
||||
onChange={e => updateLevel(idx, { value: Math.max(1, Number(e.target.value)) })}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(122,162,247,0.25)',
|
||||
borderRadius: 3,
|
||||
color: 'inherit',
|
||||
fontSize: 12,
|
||||
padding: '3px 5px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 색상 */}
|
||||
<ColorCell color={lv.color} onChange={c => updateLevel(idx, { color: c })} />
|
||||
|
||||
{/* 스타일 */}
|
||||
<StyleSelect value={lv.style} onChange={s => updateLevel(idx, { style: s })} />
|
||||
|
||||
{/* 두께 */}
|
||||
<WidthSelect value={lv.lineWidth} onChange={w => updateLevel(idx, { lineWidth: w })} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="dsm-footer" style={{ marginTop: 18 }}>
|
||||
<button
|
||||
onClick={handleReset}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(122,162,247,0.3)',
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>초기화</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(122,162,247,0.3)',
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>취소</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
padding: '6px 16px',
|
||||
background: 'var(--accent, #7aa2f7)',
|
||||
border: 'none',
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer',
|
||||
color: '#1a1b26',
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FibTZSettingsModal;
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 보조지표 일괄 설정 — Main 16종 + 전체 보조지표, 검색 필터
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||||
import {
|
||||
buildAllIndicatorDraftMap,
|
||||
applyAllDraftToChart,
|
||||
allDraftListFromMap,
|
||||
indicatorTypesOnChart,
|
||||
type MainIndicatorDraftMap,
|
||||
} from '../utils/indicatorMainConfig';
|
||||
import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor';
|
||||
import {
|
||||
getSettingsIndicatorTypes,
|
||||
filterSettingsIndicatorTypes,
|
||||
partitionFilteredTypes,
|
||||
} from '../utils/indicatorSettingsList';
|
||||
import IndicatorSettingsListSearch from './IndicatorSettingsListSearch';
|
||||
import IndicatorSettingsListRow from './IndicatorSettingsListRow';
|
||||
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||||
import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab';
|
||||
|
||||
export interface IndicatorBulkSettingsModalProps {
|
||||
open: boolean;
|
||||
indicators: IndicatorConfig[];
|
||||
chartMarket?: string;
|
||||
onApply: (result: {
|
||||
chartIndicators: IndicatorConfig[];
|
||||
allConfigs: IndicatorConfig[];
|
||||
}) => void;
|
||||
onLiveChartChange: (chartIndicators: IndicatorConfig[]) => void;
|
||||
onClose: () => void;
|
||||
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
||||
getVisualConfig: (
|
||||
type: string,
|
||||
defaultPlots?: PlotDef[],
|
||||
defaultHlines?: HLineDef[],
|
||||
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors };
|
||||
}
|
||||
|
||||
const ALL_TYPES = getSettingsIndicatorTypes();
|
||||
|
||||
const IndicatorBulkSettingsModal: React.FC<IndicatorBulkSettingsModalProps> = ({
|
||||
open,
|
||||
indicators,
|
||||
chartMarket = '',
|
||||
onApply,
|
||||
onLiveChartChange,
|
||||
onClose,
|
||||
getParams,
|
||||
getVisualConfig,
|
||||
}) => {
|
||||
const [draftMap, setDraftMap] = useState<MainIndicatorDraftMap>({});
|
||||
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(new Set());
|
||||
const [search, setSearch] = useState('');
|
||||
const {
|
||||
panelRef: dialogRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
const onChartTypes = useMemo(() => indicatorTypesOnChart(indicators), [indicators]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterSettingsIndicatorTypes(ALL_TYPES, search),
|
||||
[search],
|
||||
);
|
||||
const { main: filteredMain, other: filteredOther } = useMemo(
|
||||
() => partitionFilteredTypes(filtered),
|
||||
[filtered],
|
||||
);
|
||||
|
||||
const prevOpenRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (open && !prevOpenRef.current) {
|
||||
setDraftMap(buildAllIndicatorDraftMap(indicators, getParams, getVisualConfig));
|
||||
setExpandedTypes(new Set());
|
||||
setSearch('');
|
||||
}
|
||||
prevOpenRef.current = open;
|
||||
}, [open, indicators, getParams, getVisualConfig]);
|
||||
|
||||
const setTypeEnabled = useCallback((type: string, enabled: boolean) => {
|
||||
const next = new Set(onChartTypes);
|
||||
if (enabled) next.add(type);
|
||||
else next.delete(type);
|
||||
onLiveChartChange(applyAllDraftToChart(indicators, draftMap, next));
|
||||
}, [onChartTypes, indicators, draftMap, onLiveChartChange]);
|
||||
|
||||
const updateType = useCallback((type: string, updated: IndicatorConfig) => {
|
||||
setDraftMap(prev => ({ ...prev, [type]: updated }));
|
||||
}, []);
|
||||
|
||||
const toggleExpand = useCallback((type: string) => {
|
||||
setExpandedTypes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
if (!window.confirm('목록에 있는 모든 보조지표 설정을 기본값으로 되돌릴까요?')) return;
|
||||
setDraftMap(prev => {
|
||||
const next = { ...prev };
|
||||
for (const type of ALL_TYPES) {
|
||||
const cfg = next[type];
|
||||
if (!cfg) continue;
|
||||
const reset = resetConfigToDefaults(cfg);
|
||||
const active = indicators.find(i => i.type === type);
|
||||
next[type] = {
|
||||
...reset,
|
||||
id: onChartTypes.has(type) && active ? active.id : `template_${type}`,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [indicators, onChartTypes]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const allConfigs = allDraftListFromMap(draftMap);
|
||||
const chartIndicators = applyAllDraftToChart(indicators, draftMap, onChartTypes);
|
||||
onApply({ chartIndicators, allConfigs });
|
||||
onClose();
|
||||
}, [draftMap, onChartTypes, indicators, onApply, onClose]);
|
||||
|
||||
const renderRows = (types: string[]) => types.map(type => {
|
||||
const cfg = draftMap[type];
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<IndicatorSettingsListRow
|
||||
key={type}
|
||||
type={type}
|
||||
config={cfg}
|
||||
enabled={onChartTypes.has(type)}
|
||||
chartMarket={chartMarket}
|
||||
expanded={expandedTypes.has(type)}
|
||||
variant="bulk"
|
||||
onToggleExpand={() => toggleExpand(type)}
|
||||
onToggleEnabled={on => setTypeEnabled(type, on)}
|
||||
onChange={updated => updateType(type, updated)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="ibsm-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="ibsm-dialog ibsm-dialog-wide"
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 5001,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header ibsm-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title ibsm-header-title">📊 보조지표 설정</span>
|
||||
<div className="ibsm-header-actions">
|
||||
<button type="button" className="ibsm-icon-btn" onClick={handleResetAll} title="전체 기본값">↻</button>
|
||||
<button type="button" className="gc-popup-close ibsm-icon-btn" onClick={onClose} title="닫기">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IndicatorSettingsListSearch
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
totalCount={ALL_TYPES.length}
|
||||
filteredCount={filtered.length}
|
||||
className="ibsm-search-block"
|
||||
/>
|
||||
|
||||
<p className="ibsm-hint">
|
||||
<strong>Main</strong> 탭 {MAIN_INDICATOR_TYPES.length}종을 상단에 두었습니다. 우측 스위치로 차트에 즉시 추가·제거하고,
|
||||
▼에서 파라미터·색상을 편집할 수 있습니다. <strong>적용</strong> 시 DB 기본값에 저장됩니다.
|
||||
</p>
|
||||
|
||||
<div className="ibsm-body">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="no-results ind-settings-no-results">
|
||||
'{search}' 검색 결과 없음
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredMain.length > 0 && (
|
||||
<section className="ind-settings-section">
|
||||
<h3 className="ind-settings-section-title">
|
||||
Main 보조지표 <span className="ind-settings-section-count">{filteredMain.length}</span>
|
||||
</h3>
|
||||
{renderRows(filteredMain)}
|
||||
</section>
|
||||
)}
|
||||
{filteredOther.length > 0 && (
|
||||
<section className="ind-settings-section">
|
||||
<h3 className="ind-settings-section-title">
|
||||
기타 보조지표 <span className="ind-settings-section-count">{filteredOther.length}</span>
|
||||
</h3>
|
||||
{renderRows(filteredOther)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ibsm-footer">
|
||||
<button type="button" className="ism-btn-cancel" onClick={onClose}>취소</button>
|
||||
<button type="button" className="ism-btn-ok" onClick={handleApply}>적용</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorBulkSettingsModal;
|
||||
@@ -0,0 +1,154 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { getIndicatorChartTitle } from '../utils/indicatorRegistry';
|
||||
import { formatBbLegendLabel } from '../utils/bollingerConfig';
|
||||
|
||||
interface IndicatorContextToolbarProps {
|
||||
indicatorId: string;
|
||||
config: IndicatorConfig | null;
|
||||
chartType?: string;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
onSettings: () => void;
|
||||
onToggleHidden: () => void;
|
||||
onRemove: () => void;
|
||||
onClose: () => void;
|
||||
/** 툴바 자체에 마우스 진입 시 hide 타이머 취소 */
|
||||
onMouseEnterToolbar?: () => void;
|
||||
/** 툴바에서 마우스 이탈 시 hide 타이머 재시작 */
|
||||
onMouseLeaveToolbar?: () => void;
|
||||
}
|
||||
|
||||
/* SVG Icons */
|
||||
const IcEye = ({ hidden }: { hidden: boolean }) => hidden ? (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z" strokeOpacity="0.35"/>
|
||||
<line x1="2" y1="2" x2="12" y2="12"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||
<path d="M1 7 Q4 2.5 7 2.5 Q10 2.5 13 7 Q10 11.5 7 11.5 Q4 11.5 1 7 Z"/>
|
||||
<circle cx="7" cy="7" r="2"/>
|
||||
</svg>
|
||||
);
|
||||
const IcGear = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<circle cx="7" cy="7" r="2.3"/>
|
||||
<path d="M7 1 L7.7 2.8 L9.8 2 L9.8 4.2 L11.8 5 L11 7 L11.8 9 L9.8 9.8 L9.8 12 L7.7 11.2 L7 13 L6.3 11.2 L4.2 12 L4.2 9.8 L2.2 9 L3 7 L2.2 5 L4.2 4.2 L4.2 2 L6.3 2.8 Z"/>
|
||||
</svg>
|
||||
);
|
||||
const IcTrash = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.4">
|
||||
<line x1="2" y1="4.5" x2="12" y2="4.5"/>
|
||||
<path d="M5 4.5 V3 Q5 2 7 2 Q9 2 9 3 V4.5"/>
|
||||
<path d="M3 4.5 L3.5 12 Q3.5 12.5 4 12.5 L10 12.5 Q10.5 12.5 10.5 12 L11 4.5"/>
|
||||
</svg>
|
||||
);
|
||||
const IcMore = () => (
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
||||
<circle cx="2.5" cy="7" r="1.3"/>
|
||||
<circle cx="7" cy="7" r="1.3"/>
|
||||
<circle cx="11.5"cy="7" r="1.3"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 인디케이터 파라미터 요약 문자열 (예: "RSI (14)") */
|
||||
function paramSummary(config: IndicatorConfig): string {
|
||||
if (config.type === 'BollingerBands') {
|
||||
return formatBbLegendLabel(config.params ?? {});
|
||||
}
|
||||
const name = getIndicatorChartTitle(config.type);
|
||||
const nums = Object.values(config.params)
|
||||
.filter(v => typeof v === 'number')
|
||||
.slice(0, 2)
|
||||
.join(', ');
|
||||
return nums ? `${name} (${nums})` : name;
|
||||
}
|
||||
|
||||
const IndicatorContextToolbar: React.FC<IndicatorContextToolbarProps> = ({
|
||||
indicatorId, config, chartType, screenX, screenY,
|
||||
onSettings, onToggleHidden, onRemove, onClose,
|
||||
onMouseEnterToolbar, onMouseLeaveToolbar,
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// hover 모드(onMouseEnterToolbar 존재)에선 hover leave 로만 닫힘
|
||||
// 클릭 모드에선 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
if (onMouseEnterToolbar) return; // hover 모드 → 외부 클릭 핸들러 불필요
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const tid = setTimeout(() => document.addEventListener('mousedown', handler), 100);
|
||||
return () => { clearTimeout(tid); document.removeEventListener('mousedown', handler); };
|
||||
}, [onClose, onMouseEnterToolbar]);
|
||||
|
||||
// pane 좌측 상단 기준 (screenX = pane left, screenY = pane top)
|
||||
const W = 240;
|
||||
const isMain = indicatorId === '__main__';
|
||||
/** 메인 캔들 pane 상단 tv-legend(OHLCV) 행 아래에 배치 */
|
||||
const TOP_GAP_MAIN = 38;
|
||||
let left = screenX + 8;
|
||||
let top = screenY + (isMain ? TOP_GAP_MAIN : 8);
|
||||
// 화면 오른쪽 경계 보정
|
||||
if (left + W > window.innerWidth - 8) left = window.innerWidth - W - 8;
|
||||
|
||||
const isHidden = config?.hidden === true;
|
||||
const label = isMain
|
||||
? (chartType === 'candlestick' ? '캔들스틱' : chartType ?? '메인 차트')
|
||||
: (config ? paramSummary(config) : indicatorId);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="ind-ctx-toolbar"
|
||||
style={{ left, top }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onMouseEnter={onMouseEnterToolbar}
|
||||
onMouseLeave={onMouseLeaveToolbar}
|
||||
>
|
||||
{/* 이름 */}
|
||||
<span className="ind-ctx-name" title={label}>{label}</span>
|
||||
|
||||
{/* 가시성 토글 */}
|
||||
{!isMain && (
|
||||
<button
|
||||
className="ind-ctx-btn"
|
||||
title={isHidden ? '표시' : '숨기기'}
|
||||
onClick={onToggleHidden}
|
||||
>
|
||||
<IcEye hidden={isHidden} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 설정 */}
|
||||
<button
|
||||
className="ind-ctx-btn"
|
||||
title="설정 (더블클릭)"
|
||||
onClick={onSettings}
|
||||
>
|
||||
<IcGear />
|
||||
</button>
|
||||
|
||||
{/* 삭제 */}
|
||||
{!isMain && (
|
||||
<button
|
||||
className="ind-ctx-btn ind-ctx-del"
|
||||
title="삭제"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<IcTrash />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 더보기 */}
|
||||
<button className="ind-ctx-btn" title="더보기">
|
||||
<IcMore />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorContextToolbar;
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* 설정 화면 — 보조지표 기본값 (Main 16종 상단 + 전체 지표, 검색 필터)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||||
import { buildMainIndicatorConfig } from '../utils/indicatorMainConfig';
|
||||
import { indicatorTypesOnChart } from '../utils/indicatorMainConfig';
|
||||
import IndicatorSettingsForm from './IndicatorSettingsForm';
|
||||
import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor';
|
||||
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||||
import {
|
||||
getSettingsIndicatorTypes,
|
||||
filterSettingsIndicatorTypes,
|
||||
partitionFilteredTypes,
|
||||
} from '../utils/indicatorSettingsList';
|
||||
import IndicatorSettingsListSearch from './IndicatorSettingsListSearch';
|
||||
import IndicatorSettingsListRow from './IndicatorSettingsListRow';
|
||||
import { MAIN_INDICATOR_TYPES } from '../utils/indicatorMainTab';
|
||||
|
||||
export interface IndicatorMainDefaultsPanelProps {
|
||||
chartIndicators: IndicatorConfig[];
|
||||
onAddToChart: (type: string, config?: IndicatorConfig) => void;
|
||||
onRemoveFromChart: (type: string) => void;
|
||||
getParams: (type: string, defaults: Record<string, number | string | boolean>) => Record<string, number | string | boolean>;
|
||||
saveParams: (type: string, params: Record<string, number | string | boolean>) => void;
|
||||
getVisualConfig: (
|
||||
type: string,
|
||||
defaultPlots?: PlotDef[],
|
||||
defaultHlines?: HLineDef[],
|
||||
) => { plots: PlotDef[]; hlines: HLineDef[]; cloudColors?: IchimokuCloudColors };
|
||||
saveVisual: (
|
||||
type: string,
|
||||
plots?: PlotDef[],
|
||||
hlines?: HLineDef[],
|
||||
cloudColors?: IchimokuCloudColors,
|
||||
) => void;
|
||||
}
|
||||
|
||||
function persistDefaults(
|
||||
updated: IndicatorConfig,
|
||||
saveParams: IndicatorMainDefaultsPanelProps['saveParams'],
|
||||
saveVisual: IndicatorMainDefaultsPanelProps['saveVisual'],
|
||||
) {
|
||||
saveParams(updated.type, updated.params);
|
||||
saveVisual(updated.type, updated.plots, updated.hlines, updated.cloudColors);
|
||||
}
|
||||
|
||||
const ALL_TYPES = getSettingsIndicatorTypes();
|
||||
|
||||
const IndicatorMainDefaultsPanel: React.FC<IndicatorMainDefaultsPanelProps> = ({
|
||||
chartIndicators,
|
||||
onAddToChart,
|
||||
onRemoveFromChart,
|
||||
getParams,
|
||||
saveParams,
|
||||
getVisualConfig,
|
||||
saveVisual,
|
||||
}) => {
|
||||
const onChartTypes = useMemo(() => indicatorTypesOnChart(chartIndicators), [chartIndicators]);
|
||||
const [expandedTypes, setExpandedTypes] = useState<Set<string>>(() => new Set());
|
||||
const [configs, setConfigs] = useState<Record<string, IndicatorConfig>>({});
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterSettingsIndicatorTypes(ALL_TYPES, search),
|
||||
[search],
|
||||
);
|
||||
const { main: filteredMain, other: filteredOther } = useMemo(
|
||||
() => partitionFilteredTypes(filtered),
|
||||
[filtered],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const next: Record<string, IndicatorConfig> = {};
|
||||
for (const type of ALL_TYPES) {
|
||||
const cfg = buildMainIndicatorConfig(type, chartIndicators, getParams, getVisualConfig);
|
||||
if (cfg) next[type] = cfg;
|
||||
}
|
||||
setConfigs(next);
|
||||
}, [chartIndicators, getParams, getVisualConfig]);
|
||||
|
||||
const toggleExpand = useCallback((type: string) => {
|
||||
setExpandedTypes(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleConfigChange = useCallback((type: string, updated: IndicatorConfig) => {
|
||||
setConfigs(prev => ({ ...prev, [type]: updated }));
|
||||
}, []);
|
||||
|
||||
const handleToggleChart = useCallback((type: string, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
onAddToChart(type, configs[type]);
|
||||
} else {
|
||||
onRemoveFromChart(type);
|
||||
}
|
||||
}, [configs, onAddToChart, onRemoveFromChart]);
|
||||
|
||||
const handleRowDefaults = useCallback((type: string) => {
|
||||
const base = configs[type];
|
||||
if (!base) return;
|
||||
const reset = resetConfigToDefaults(base);
|
||||
persistDefaults(reset, saveParams, saveVisual);
|
||||
handleConfigChange(type, reset);
|
||||
}, [configs, saveParams, saveVisual, handleConfigChange]);
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
if (!window.confirm('목록에 있는 모든 보조지표 기본값을 초기화할까요?')) return;
|
||||
const next: Record<string, IndicatorConfig> = {};
|
||||
for (const type of ALL_TYPES) {
|
||||
const base = configs[type] ?? buildMainIndicatorConfig(type, [], getParams, getVisualConfig);
|
||||
if (!base) continue;
|
||||
const reset = resetConfigToDefaults(base);
|
||||
next[type] = reset;
|
||||
persistDefaults(reset, saveParams, saveVisual);
|
||||
}
|
||||
setConfigs(next);
|
||||
}, [configs, getParams, getVisualConfig, saveParams, saveVisual]);
|
||||
|
||||
const renderRows = (types: string[]) => types.map(type => {
|
||||
const cfg = configs[type];
|
||||
if (!cfg) return null;
|
||||
return (
|
||||
<IndicatorSettingsListRow
|
||||
key={type}
|
||||
type={type}
|
||||
config={cfg}
|
||||
enabled={onChartTypes.has(type)}
|
||||
expanded={expandedTypes.has(type)}
|
||||
variant="settings"
|
||||
onToggleExpand={() => toggleExpand(type)}
|
||||
onToggleEnabled={on => handleToggleChart(type, on)}
|
||||
onChange={updated => {
|
||||
persistDefaults(updated, saveParams, saveVisual);
|
||||
handleConfigChange(type, updated);
|
||||
}}
|
||||
onRowDefaults={() => handleRowDefaults(type)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="stg-ind-intro">
|
||||
<p>
|
||||
지표 추가 팝업 <strong>Main</strong> 탭 {MAIN_INDICATOR_TYPES.length}종을 상단에 두었고, 그 아래에 등록된 모든 보조지표가 있습니다.
|
||||
우측 스위치로 차트에 바로 추가·제거할 수 있으며, 파라미터·색상 변경은 DB 기본값에 자동 저장됩니다.
|
||||
</p>
|
||||
<button type="button" className="stg-ind-reset-all" onClick={handleResetAll}>
|
||||
전체 기본값 복원
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<IndicatorSettingsListSearch
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
totalCount={ALL_TYPES.length}
|
||||
filteredCount={filtered.length}
|
||||
className="stg-ind-search-block"
|
||||
/>
|
||||
|
||||
<div className="stg-ind-list">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="no-results ind-settings-no-results">
|
||||
'{search}' 검색 결과 없음
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredMain.length > 0 && (
|
||||
<section className="ind-settings-section">
|
||||
<h3 className="ind-settings-section-title">
|
||||
Main 보조지표 <span className="ind-settings-section-count">{filteredMain.length}</span>
|
||||
</h3>
|
||||
{renderRows(filteredMain)}
|
||||
</section>
|
||||
)}
|
||||
{filteredOther.length > 0 && (
|
||||
<section className="ind-settings-section">
|
||||
<h3 className="ind-settings-section-title">
|
||||
기타 보조지표 <span className="ind-settings-section-count">{filteredOther.length}</span>
|
||||
</h3>
|
||||
{renderRows(filteredOther)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorMainDefaultsPanel;
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import { INDICATOR_REGISTRY, type IndicatorDef, type IndicatorCategory } from '../utils/indicatorRegistry';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { getNumericParamSpec } from '../utils/indicatorParamSpec';
|
||||
import NumericParamInput from './NumericParamInput';
|
||||
|
||||
interface IndicatorModalProps {
|
||||
activeIndicators: IndicatorConfig[];
|
||||
onAdd: (def: IndicatorDef, params: Record<string, number | string | boolean>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CATEGORIES: IndicatorCategory[] = [
|
||||
'Moving Averages', 'Channels & Bands', 'Oscillators', 'Momentum',
|
||||
'Trend', 'Volatility', 'Volume', 'Candlestick Patterns',
|
||||
];
|
||||
|
||||
const IndicatorModal: React.FC<IndicatorModalProps> = ({ activeIndicators, onAdd, onRemove, onClose }) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<IndicatorCategory | 'All'>('All');
|
||||
const [selected, setSelected] = useState<IndicatorDef | null>(null);
|
||||
const [params, setParams] = useState<Record<string, number | string | boolean>>({});
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return INDICATOR_REGISTRY.filter(d => {
|
||||
const matchCat = category === 'All' || d.category === category;
|
||||
const matchQ = !search || d.name.toLowerCase().includes(search.toLowerCase()) || d.shortName.toLowerCase().includes(search.toLowerCase());
|
||||
return matchCat && matchQ;
|
||||
});
|
||||
}, [search, category]);
|
||||
|
||||
const select = (def: IndicatorDef) => {
|
||||
setSelected(def);
|
||||
setParams({ ...def.defaultParams });
|
||||
};
|
||||
|
||||
const isActive = (type: string) => activeIndicators.some(a => a.type === type);
|
||||
|
||||
const handleAdd = () => {
|
||||
if (!selected) return;
|
||||
onAdd(selected, params);
|
||||
setSelected(null);
|
||||
};
|
||||
|
||||
const handleRemoveAll = (type: string) => {
|
||||
activeIndicators.filter(a => a.type === type).forEach(a => onRemove(a.id));
|
||||
};
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="indicator-modal"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 5001,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header modal-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title modal-title">📈 지표 추가</span>
|
||||
<span className="modal-subtitle">{INDICATOR_REGISTRY.length}개 지표 지원</span>
|
||||
<button type="button" className="gc-popup-close modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-search">
|
||||
<input
|
||||
className="modal-search-input"
|
||||
placeholder="지표 검색..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="modal-cats">
|
||||
{(['All', ...CATEGORIES] as Array<'All' | IndicatorCategory>).map(c => (
|
||||
<button
|
||||
key={c}
|
||||
className={`cat-btn ${category === c ? 'active' : ''}`}
|
||||
onClick={() => setCategory(c)}
|
||||
>{c === 'All' ? `전체 (${INDICATOR_REGISTRY.length})` : c} {c !== 'All' && `(${INDICATOR_REGISTRY.filter(d => d.category === c).length})`}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="modal-body">
|
||||
<div className="indicator-list">
|
||||
{filtered.map(def => (
|
||||
<div
|
||||
key={def.type}
|
||||
className={`indicator-item ${selected?.type === def.type ? 'selected' : ''} ${isActive(def.type) ? 'in-use' : ''}`}
|
||||
onClick={() => select(def)}
|
||||
>
|
||||
<div className="indicator-item-left">
|
||||
<span className="indicator-name">{def.name}</span>
|
||||
<span className="indicator-desc">{def.description}</span>
|
||||
</div>
|
||||
<div className="indicator-item-right">
|
||||
{def.overlay && <span className="badge overlay">오버레이</span>}
|
||||
{def.returnsMarkers && <span className="badge markers">마커</span>}
|
||||
{isActive(def.type) ? (
|
||||
<button className="rm-btn" onClick={e => { e.stopPropagation(); handleRemoveAll(def.type); }}>제거</button>
|
||||
) : (
|
||||
<button className="add-btn" onClick={e => { e.stopPropagation(); select(def); onAdd(def, { ...def.defaultParams }); }}>+</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="no-results">'{search}' 검색 결과 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected && (
|
||||
<div className="indicator-settings">
|
||||
<div className="settings-title">{selected.name} 설정</div>
|
||||
<div className="settings-desc">{selected.description}</div>
|
||||
{Object.entries(params).map(([key, val]) => (
|
||||
<div key={key} className="param-row">
|
||||
<label className="param-label">{key}</label>
|
||||
{typeof val === 'boolean' ? (
|
||||
<input type="checkbox" checked={val as boolean} onChange={e => setParams(p => ({ ...p, [key]: e.target.checked }))} />
|
||||
) : typeof val === 'number' ? (
|
||||
<NumericParamInput
|
||||
className="param-input"
|
||||
value={val as number}
|
||||
spec={getNumericParamSpec(selected.type, key, val as number)}
|
||||
onChange={v => setParams(p => ({ ...p, [key]: v }))}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="param-input"
|
||||
type="text"
|
||||
value={val as string}
|
||||
onChange={e => setParams(p => ({ ...p, [key]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="settings-actions">
|
||||
<button className="settings-add-btn" onClick={handleAdd}>지표 추가</button>
|
||||
<button className="settings-cancel" onClick={() => setSelected(null)}>취소</button>
|
||||
</div>
|
||||
{selected.plots.length > 0 && (
|
||||
<div className="plot-preview">
|
||||
{selected.plots.map(p => (
|
||||
<div key={p.id} className="plot-legend-row">
|
||||
<span className="plot-color" style={{ background: p.color }} />
|
||||
<span className="plot-title">{p.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeIndicators.length > 0 && (
|
||||
<div className="modal-active">
|
||||
<div className="active-title">활성 지표 ({activeIndicators.length})</div>
|
||||
<div className="active-list">
|
||||
{activeIndicators.map(a => (
|
||||
<span key={a.id} className="active-tag">
|
||||
{a.type}
|
||||
<button onClick={() => onRemove(a.id)}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorModal;
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* 보조지표 설정 폼 — 개별 설정 모달·일괄 설정 팝업에서 동일 UI/로직 공유
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { IndicatorConfig, Timeframe } from '../types';
|
||||
import { getIndicatorDef } from '../utils/indicatorRegistry';
|
||||
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||||
import { getPlotLabel } from '../utils/indicatorLabels';
|
||||
import { getNumericParamSpec } from '../utils/indicatorParamSpec';
|
||||
import NumericParamInput from './NumericParamInput';
|
||||
import SmaPeriodInput from './SmaPeriodInput';
|
||||
import {
|
||||
SMA_DEFAULT_SRC,
|
||||
SMA_MA_COUNT,
|
||||
smaPeriodKey,
|
||||
smaPlotId,
|
||||
} from '../utils/smaConfig';
|
||||
import PlotLineStylePicker from './PlotLineStylePicker';
|
||||
import {
|
||||
GlobalParamRow,
|
||||
IchimokuCloudSection,
|
||||
BollingerSymbolSection,
|
||||
BollingerBackgroundRow,
|
||||
SettingsOutputFooter,
|
||||
UnifiedIndicatorBody,
|
||||
} from './IndicatorSettingsSections';
|
||||
import {
|
||||
createEditorSnapshot,
|
||||
configFromEditorSnapshot,
|
||||
resetConfigToDefaults,
|
||||
type IndicatorSettingsEditorSnapshot,
|
||||
} from '../utils/indicatorSettingsEditor';
|
||||
|
||||
export const ALL_TIMEFRAMES: { tf: Timeframe; label: string }[] = [
|
||||
{ tf: '1m', label: '1분' },
|
||||
{ tf: '3m', label: '3분' },
|
||||
{ tf: '5m', label: '5분' },
|
||||
{ tf: '15m', label: '15분' },
|
||||
{ tf: '30m', label: '30분' },
|
||||
{ tf: '1h', label: '1시간' },
|
||||
{ tf: '4h', label: '4시간' },
|
||||
{ tf: '1D', label: '일봉' },
|
||||
{ tf: '1W', label: '주봉' },
|
||||
{ tf: '1M', label: '월봉' },
|
||||
];
|
||||
|
||||
export interface IndicatorSettingsFormProps {
|
||||
config: IndicatorConfig;
|
||||
chartMarket?: string;
|
||||
/** embedded: 일괄 설정 카드 안 */
|
||||
variant?: 'modal' | 'embedded';
|
||||
/**
|
||||
* modal: 편집 중 부모 setState 없이 ref만 갱신 (포커스 유지).
|
||||
* embedded: 변경 즉시 onChange 호출.
|
||||
*/
|
||||
deferParentSync?: boolean;
|
||||
/** @deprecated 탭 제거 — 통합 화면만 사용 */
|
||||
inputsOnly?: boolean;
|
||||
/** 개별 설정 모달: 지표 전체 표시 여부 */
|
||||
chartHidden?: boolean;
|
||||
onChartHiddenChange?: (showOnChart: boolean) => void;
|
||||
onChange: (updated: IndicatorConfig) => void;
|
||||
}
|
||||
|
||||
function configFingerprint(c: IndicatorConfig): string {
|
||||
return JSON.stringify({
|
||||
id: c.id,
|
||||
params: c.params,
|
||||
plots: c.plots,
|
||||
plotVisibility: c.plotVisibility,
|
||||
hlines: c.hlines,
|
||||
hlinesBackground: c.hlinesBackground,
|
||||
bandBackground: c.bandBackground,
|
||||
cloudColors: c.cloudColors,
|
||||
lastValueVisible: c.lastValueVisible,
|
||||
timeframeVisibility: c.timeframeVisibility,
|
||||
hidden: c.hidden,
|
||||
});
|
||||
}
|
||||
|
||||
const IndicatorSettingsForm: React.FC<IndicatorSettingsFormProps> = ({
|
||||
config,
|
||||
chartMarket = '',
|
||||
variant = 'modal',
|
||||
deferParentSync = variant === 'modal',
|
||||
inputsOnly = false,
|
||||
chartHidden = false,
|
||||
onChartHiddenChange,
|
||||
onChange,
|
||||
}) => {
|
||||
const def = getIndicatorDef(config.type);
|
||||
const isSma = config.type === 'SMA';
|
||||
const isIchimoku = config.type === 'IchimokuCloud';
|
||||
const isBollinger = config.type === 'BollingerBands';
|
||||
|
||||
const [snap, setSnap] = useState(() => createEditorSnapshot(config));
|
||||
const baseRef = useRef(config);
|
||||
const fpRef = useRef(configFingerprint(config));
|
||||
const onChangeRef = useRef(onChange);
|
||||
const internalEmitRef = useRef(false);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const emitDraft = useCallback((next: IndicatorSettingsEditorSnapshot) => {
|
||||
const updated = configFromEditorSnapshot(baseRef.current, next);
|
||||
baseRef.current = updated;
|
||||
if (deferParentSync) {
|
||||
onChangeRef.current(updated);
|
||||
return;
|
||||
}
|
||||
internalEmitRef.current = true;
|
||||
onChangeRef.current(updated);
|
||||
}, [deferParentSync]);
|
||||
|
||||
const scheduleDraftEmit = useCallback((next: IndicatorSettingsEditorSnapshot) => {
|
||||
if (deferParentSync) {
|
||||
emitDraft(next);
|
||||
return;
|
||||
}
|
||||
emitDraft(next);
|
||||
}, [deferParentSync, emitDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deferParentSync) return;
|
||||
if (internalEmitRef.current) {
|
||||
internalEmitRef.current = false;
|
||||
fpRef.current = configFingerprint(config);
|
||||
baseRef.current = config;
|
||||
return;
|
||||
}
|
||||
const fp = configFingerprint(config);
|
||||
if (fp !== fpRef.current) {
|
||||
fpRef.current = fp;
|
||||
baseRef.current = config;
|
||||
setSnap(createEditorSnapshot(config));
|
||||
}
|
||||
}, [config, deferParentSync]);
|
||||
|
||||
const emit = useCallback((next: IndicatorSettingsEditorSnapshot) => {
|
||||
setSnap(next);
|
||||
scheduleDraftEmit(next);
|
||||
}, [scheduleDraftEmit]);
|
||||
|
||||
const patch = useCallback((partial: Partial<IndicatorSettingsEditorSnapshot>) => {
|
||||
setSnap(prev => {
|
||||
const next = { ...prev, ...partial };
|
||||
scheduleDraftEmit(next);
|
||||
return next;
|
||||
});
|
||||
}, [scheduleDraftEmit]);
|
||||
|
||||
const { params, plots, plotVis, hlines, hlinesBg, lastValueVisible, timeframeVis, cloudColors, bandBg } = snap;
|
||||
const hasHLines = (def?.hlines ?? []).length > 0 || hlines.length > 0;
|
||||
|
||||
const handleHLineVisible = useCallback((idx: number, visible: boolean) =>
|
||||
patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, visible } : h) }), [hlines, patch]);
|
||||
const handleHLinePrice = useCallback((idx: number, price: number) =>
|
||||
patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, price } : h) }), [hlines, patch]);
|
||||
const handleHLinePlotStyle = useCallback((idx: number, p: Partial<HLineDef>) =>
|
||||
patch({ hlines: hlines.map((h, i) => i === idx ? { ...h, ...p } : h) }), [hlines, patch]);
|
||||
|
||||
const handleParamChange = useCallback((key: string, raw: string | boolean) => {
|
||||
const oldVal = params[key];
|
||||
const defVal = def?.defaultParams?.[key];
|
||||
let nextParams: Record<string, number | string | boolean>;
|
||||
if (typeof oldVal === 'number' || typeof defVal === 'number') {
|
||||
const n = parseFloat(raw as string);
|
||||
const fallback = typeof oldVal === 'number' ? oldVal : (defVal as number);
|
||||
nextParams = { ...params, [key]: isNaN(n) ? fallback : n };
|
||||
} else if (typeof oldVal === 'boolean' || typeof defVal === 'boolean') {
|
||||
nextParams = { ...params, [key]: raw as boolean };
|
||||
} else {
|
||||
nextParams = { ...params, [key]: raw as string };
|
||||
}
|
||||
let nextPlotVis = plotVis;
|
||||
if (key === 'maType' && (config.type === 'CCI' || config.type === 'RSI')) {
|
||||
nextPlotVis = { ...plotVis, plot1: raw !== 'None' };
|
||||
}
|
||||
patch({ params: nextParams, plotVis: nextPlotVis });
|
||||
}, [params, plotVis, def, config.type, patch]);
|
||||
|
||||
const handlePlotStyle = useCallback((idx: number, p: Partial<PlotDef>) => {
|
||||
patch({ plots: plots.map((pl, i) => i === idx ? { ...pl, ...p } : pl) });
|
||||
}, [plots, patch]);
|
||||
|
||||
const handlePlotToggle = useCallback((plotId: string, enabled: boolean) => {
|
||||
patch({ plotVis: { ...plotVis, [plotId]: enabled } });
|
||||
}, [plotVis, patch]);
|
||||
|
||||
const handleSmaPeriod = useCallback((maIndex: number, v: number) => {
|
||||
patch({ params: { ...params, [smaPeriodKey(maIndex)]: v } });
|
||||
}, [params, patch]);
|
||||
|
||||
const handleSmaToggle = useCallback((plotIndex: number, enabled: boolean) => {
|
||||
patch({ plotVis: { ...plotVis, [smaPlotId(plotIndex)]: enabled } });
|
||||
}, [plotVis, patch]);
|
||||
|
||||
const handleDefaults = useCallback(() => {
|
||||
const reset = resetConfigToDefaults(config);
|
||||
baseRef.current = reset;
|
||||
fpRef.current = configFingerprint(reset);
|
||||
const nextSnap = createEditorSnapshot(reset);
|
||||
setSnap(nextSnap);
|
||||
onChange(reset);
|
||||
}, [config, onChange]);
|
||||
|
||||
const footerProps = {
|
||||
lastValueVisible,
|
||||
onLastValueVisible: (v: boolean) => patch({ lastValueVisible: v }),
|
||||
timeframeVis,
|
||||
onTimeframeVis: (tf: Timeframe, v: boolean) =>
|
||||
patch({ timeframeVis: { ...timeframeVis, [tf]: v } }),
|
||||
timeframes: ALL_TIMEFRAMES,
|
||||
chartHidden,
|
||||
onChartHidden: onChartHiddenChange,
|
||||
};
|
||||
|
||||
const sectionClass = variant === 'embedded'
|
||||
? 'ism-section unified-settings-section ibsm-embedded-form'
|
||||
: 'ism-section unified-settings-section';
|
||||
|
||||
return (
|
||||
<div className={sectionClass}>
|
||||
{variant === 'embedded' && (
|
||||
<div className="ibsm-form-toolbar">
|
||||
<button type="button" className="ibsm-form-defaults" onClick={handleDefaults}>
|
||||
기본값
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSma && (
|
||||
<div className="sma-inputs-section">
|
||||
<GlobalParamRow
|
||||
indicatorType="SMA"
|
||||
paramKey="src"
|
||||
value={String(params.src ?? SMA_DEFAULT_SRC)}
|
||||
onChange={(_, raw) => patch({ params: { ...params, src: raw as string } })}
|
||||
/>
|
||||
<div className="ism-sma-divider" />
|
||||
{Array.from({ length: SMA_MA_COUNT }, (_, i) => {
|
||||
const maNum = i + 1;
|
||||
const plotId = smaPlotId(i);
|
||||
const periodKey = smaPeriodKey(maNum);
|
||||
const enabled = plotVis[plotId] !== false;
|
||||
const plot = plots[i];
|
||||
return (
|
||||
<div key={plotId} className={`ism-row ism-sma-ma-row${enabled ? '' : ' ism-sma-ma-off'}`}>
|
||||
<label className="ism-toggle ism-sma-ma-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => handleSmaToggle(i, e.target.checked)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<label className="ism-label ism-sma-ma-label">{getPlotLabel(`MA${maNum}`)}</label>
|
||||
<div
|
||||
className="ism-control ism-sma-ma-control"
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
>
|
||||
<SmaPeriodInput
|
||||
value={Number(params[periodKey] ?? 1)}
|
||||
disabled={!enabled}
|
||||
onCommit={v => handleSmaPeriod(maNum, v)}
|
||||
/>
|
||||
{plot && !inputsOnly && (
|
||||
<PlotLineStylePicker
|
||||
disabled={!enabled}
|
||||
title={`MA${maNum}`}
|
||||
value={{
|
||||
color: plot.color,
|
||||
lineWidth: plot.lineWidth,
|
||||
lineStyle: plot.lineStyle,
|
||||
}}
|
||||
onChange={p => handlePlotStyle(i, p)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!inputsOnly && <SettingsOutputFooter {...footerProps} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isIchimoku && (
|
||||
<UnifiedIndicatorBody
|
||||
config={config}
|
||||
params={params}
|
||||
plots={plots}
|
||||
plotVis={plotVis}
|
||||
hlines={[]}
|
||||
hlinesBg={hlinesBg}
|
||||
hasHLines={false}
|
||||
lastValueVisible={lastValueVisible}
|
||||
timeframeVis={timeframeVis}
|
||||
timeframes={ALL_TIMEFRAMES}
|
||||
onParamChange={handleParamChange}
|
||||
onPlotToggle={handlePlotToggle}
|
||||
onPlotStyle={handlePlotStyle}
|
||||
onHLineVisible={handleHLineVisible}
|
||||
onHLinePrice={handleHLinePrice}
|
||||
onHLineStyle={handleHLinePlotStyle}
|
||||
onHlinesBg={v => patch({ hlinesBg: v })}
|
||||
onLastValueVisible={v => patch({ lastValueVisible: v })}
|
||||
onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })}
|
||||
chartHidden={chartHidden}
|
||||
onChartHidden={onChartHiddenChange}
|
||||
cloudSection={inputsOnly ? undefined : (
|
||||
<IchimokuCloudSection
|
||||
cloudColors={cloudColors}
|
||||
onChange={p => patch({ cloudColors: { ...cloudColors, ...p } })}
|
||||
/>
|
||||
)}
|
||||
inputsOnly={inputsOnly}
|
||||
showOutputFooter={!inputsOnly}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isSma && !isIchimoku && (
|
||||
<UnifiedIndicatorBody
|
||||
config={config}
|
||||
params={params}
|
||||
plots={plots}
|
||||
plotVis={plotVis}
|
||||
hlines={hlines}
|
||||
hlinesBg={hlinesBg}
|
||||
hasHLines={hasHLines}
|
||||
lastValueVisible={lastValueVisible}
|
||||
timeframeVis={timeframeVis}
|
||||
timeframes={ALL_TIMEFRAMES}
|
||||
onParamChange={handleParamChange}
|
||||
onPlotToggle={handlePlotToggle}
|
||||
onPlotStyle={handlePlotStyle}
|
||||
onHLineVisible={handleHLineVisible}
|
||||
onHLinePrice={handleHLinePrice}
|
||||
onHLineStyle={handleHLinePlotStyle}
|
||||
onHlinesBg={v => patch({ hlinesBg: v })}
|
||||
onLastValueVisible={v => patch({ lastValueVisible: v })}
|
||||
onTimeframeVis={(tf, v) => patch({ timeframeVis: { ...timeframeVis, [tf]: v } })}
|
||||
chartHidden={chartHidden}
|
||||
onChartHidden={onChartHiddenChange}
|
||||
inputsOnly={inputsOnly}
|
||||
showOutputFooter={!inputsOnly && !isBollinger}
|
||||
symbolSection={isBollinger ? (
|
||||
<BollingerSymbolSection
|
||||
symbolMode={String(params.symbolMode ?? 'main')}
|
||||
refSymbol={String(params.refSymbol ?? '')}
|
||||
chartMarket={chartMarket}
|
||||
onChange={p => {
|
||||
const next = { ...params };
|
||||
if (p.symbolMode !== undefined) next.symbolMode = p.symbolMode;
|
||||
if (p.refSymbol !== undefined) next.refSymbol = p.refSymbol;
|
||||
patch({ params: next });
|
||||
}}
|
||||
/>
|
||||
) : undefined}
|
||||
afterPlotsSection={isBollinger ? (
|
||||
<BollingerBackgroundRow value={bandBg} onChange={v => patch({ bandBg: v })} />
|
||||
) : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorSettingsForm;
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { getIndicatorDef } from '../utils/indicatorRegistry';
|
||||
import { getIndicatorListLabels } from '../utils/indicatorSettingsList';
|
||||
import IndicatorSettingsForm from './IndicatorSettingsForm';
|
||||
|
||||
export interface IndicatorSettingsListRowProps {
|
||||
type: string;
|
||||
config: IndicatorConfig;
|
||||
enabled: boolean;
|
||||
chartMarket?: string;
|
||||
expanded: boolean;
|
||||
variant: 'bulk' | 'settings';
|
||||
onToggleExpand: () => void;
|
||||
onToggleEnabled: (enabled: boolean) => void;
|
||||
onChange: (updated: IndicatorConfig) => void;
|
||||
onRowDefaults?: () => void;
|
||||
}
|
||||
|
||||
const IndicatorSettingsListRow: React.FC<IndicatorSettingsListRowProps> = ({
|
||||
type,
|
||||
config,
|
||||
enabled,
|
||||
chartMarket = '',
|
||||
expanded,
|
||||
variant,
|
||||
onToggleExpand,
|
||||
onToggleEnabled,
|
||||
onChange,
|
||||
onRowDefaults,
|
||||
}) => {
|
||||
const labels = getIndicatorListLabels(type);
|
||||
const def = getIndicatorDef(type);
|
||||
|
||||
const cardClass =
|
||||
variant === 'bulk'
|
||||
? `ibsm-card ibsm-main-row${enabled ? ' ibsm-main-row--active' : ''}${expanded ? ' ibsm-card-expanded' : ''}`
|
||||
: `stg-ind-card${enabled ? ' stg-ind-card--on-chart' : ''}${expanded ? ' stg-ind-card--expanded' : ''}`;
|
||||
|
||||
const headClass = variant === 'bulk' ? 'ibsm-card-main ibsm-main-row-head' : 'stg-ind-card-head';
|
||||
const infoClass = variant === 'bulk' ? 'ibsm-main-row-info' : 'stg-ind-card-titles';
|
||||
const koClass = variant === 'bulk' ? 'ibsm-main-row-ko' : 'stg-ind-card-name';
|
||||
const enClass = variant === 'bulk' ? 'ibsm-main-row-en' : 'stg-ind-card-sub';
|
||||
const badgesClass = variant === 'bulk' ? 'ibsm-main-row-badges' : 'stg-ind-card-badges';
|
||||
const onBadgeClass = variant === 'bulk' ? 'ibsm-main-badge ibsm-main-badge--on' : 'stg-ind-badge stg-ind-badge--on';
|
||||
const offBadgeClass = variant === 'bulk' ? 'ibsm-main-badge' : 'stg-ind-badge';
|
||||
const expandClass = variant === 'bulk' ? 'ibsm-expand' : 'stg-ind-card-expand';
|
||||
const expandWrapClass = variant === 'bulk' ? 'ibsm-card-expand' : 'stg-ind-card-body';
|
||||
|
||||
return (
|
||||
<div className={cardClass}>
|
||||
<div className={headClass}>
|
||||
<div className={infoClass}>
|
||||
<span className={koClass}>{labels.ko}</span>
|
||||
<span className={enClass}>{labels.en}</span>
|
||||
</div>
|
||||
<div className={badgesClass}>
|
||||
{def?.overlay && <span className="ind-panel-badge">오버레이</span>}
|
||||
{def?.returnsMarkers && <span className="ind-panel-badge">마커</span>}
|
||||
{enabled ? (
|
||||
<span className={onBadgeClass}>차트 적용</span>
|
||||
) : (
|
||||
<span className={offBadgeClass}>미적용</span>
|
||||
)}
|
||||
</div>
|
||||
<label
|
||||
className={`ibsm-toggle${variant === 'settings' ? ' stg-ind-toggle' : ''}`}
|
||||
title={enabled ? '차트에서 제거' : '차트에 추가'}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => onToggleEnabled(e.target.checked)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
{variant === 'settings' && onRowDefaults && (
|
||||
<button type="button" className="stg-ind-card-defaults" onClick={onRowDefaults}>
|
||||
기본값
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={expandClass}
|
||||
onClick={onToggleExpand}
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? '접기' : '설정 펼치기'}
|
||||
>
|
||||
{expanded ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className={expandWrapClass}>
|
||||
<IndicatorSettingsForm
|
||||
config={config}
|
||||
chartMarket={chartMarket}
|
||||
variant="embedded"
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorSettingsListRow;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface IndicatorSettingsListSearchProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
totalCount: number;
|
||||
filteredCount: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 보조지표 설정 목록 상단 검색 (지표 추가 팝업과 동일 UX) */
|
||||
const IndicatorSettingsListSearch: React.FC<IndicatorSettingsListSearchProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
totalCount,
|
||||
filteredCount,
|
||||
className = '',
|
||||
}) => (
|
||||
<div className={`ind-settings-search-wrap ${className}`.trim()}>
|
||||
<input
|
||||
type="search"
|
||||
className="modal-search-input ind-settings-search-input"
|
||||
placeholder="지표 검색... (이름·한글명·약어)"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
aria-label="보조지표 검색"
|
||||
/>
|
||||
<span className="ind-settings-search-meta">
|
||||
{value.trim()
|
||||
? `검색 결과 ${filteredCount} / ${totalCount}`
|
||||
: `전체 ${totalCount}개`}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IndicatorSettingsListSearch;
|
||||
@@ -0,0 +1,124 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { getIndicatorDef, enrichIndicatorConfig } from '../utils/indicatorRegistry';
|
||||
import IndicatorSettingsForm from './IndicatorSettingsForm';
|
||||
import { resetConfigToDefaults } from '../utils/indicatorSettingsEditor';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
interface IndicatorSettingsModalProps {
|
||||
config: IndicatorConfig;
|
||||
chartMarket?: string;
|
||||
onSave: (updated: IndicatorConfig) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** 차트·설정 화면 공통 — 입력·스타일·가시성 단일 화면 (탭 없음) */
|
||||
const IndicatorSettingsModal: React.FC<IndicatorSettingsModalProps> = ({
|
||||
config,
|
||||
chartMarket = '',
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const def = getIndicatorDef(config.type);
|
||||
const draftRef = useRef(enrichIndicatorConfig(config));
|
||||
const [formRevision, setFormRevision] = useState(0);
|
||||
const [chartHidden, setChartHidden] = useState(() => draftRef.current.hidden ?? false);
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
const openedIdRef = React.useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (openedIdRef.current !== config.id) {
|
||||
openedIdRef.current = config.id;
|
||||
draftRef.current = enrichIndicatorConfig(config);
|
||||
setChartHidden(draftRef.current.hidden ?? false);
|
||||
setFormRevision(r => r + 1);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
const title = def
|
||||
? `${def.koreanName} (${def.name})`
|
||||
: config.type;
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(enrichIndicatorConfig(draftRef.current));
|
||||
}, [onSave]);
|
||||
|
||||
const handleDefaults = useCallback(() => {
|
||||
draftRef.current = resetConfigToDefaults(draftRef.current);
|
||||
setChartHidden(draftRef.current.hidden ?? false);
|
||||
setFormRevision(r => r + 1);
|
||||
}, []);
|
||||
|
||||
const handleHidden = useCallback((showOnChart: boolean) => {
|
||||
draftRef.current = { ...draftRef.current, hidden: !showOnChart };
|
||||
setChartHidden(!showOnChart);
|
||||
}, []);
|
||||
|
||||
const handleDraftRef = useCallback((updated: IndicatorConfig) => {
|
||||
draftRef.current = updated;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ism-overlay"
|
||||
onMouseDown={e => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="ism-dialog ism-dialog-unified"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 5001,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title">{title}</span>
|
||||
<button type="button" className="gc-popup-close" onClick={onCancel}>✕</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ism-body ism-body-unified"
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
>
|
||||
<IndicatorSettingsForm
|
||||
key={`${config.id}-${formRevision}`}
|
||||
config={draftRef.current}
|
||||
chartMarket={chartMarket}
|
||||
variant="modal"
|
||||
deferParentSync
|
||||
chartHidden={chartHidden}
|
||||
onChartHiddenChange={handleHidden}
|
||||
onChange={handleDraftRef}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="ism-footer">
|
||||
<button type="button" className="ism-btn-defaults" onClick={handleDefaults}>
|
||||
기본값 <span style={{ opacity: 0.6, fontSize: 11 }}>▾</span>
|
||||
</button>
|
||||
<div className="ism-footer-right">
|
||||
<button type="button" className="ism-btn-cancel" onClick={onCancel}>취소</button>
|
||||
<button type="button" className="ism-btn-ok" onClick={handleSave}>확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorSettingsModal;
|
||||
@@ -0,0 +1,595 @@
|
||||
import React from 'react';
|
||||
import type { IndicatorConfig, HlinesBackground, Timeframe } from '../types';
|
||||
import type { PlotDef, HLineDef } from '../utils/indicatorRegistry';
|
||||
import { getHLineLabel } from '../utils/indicatorRegistry';
|
||||
import { getParamLabel, getPlotLabel, getSrcOptionLabel } from '../utils/indicatorLabels';
|
||||
import { getIchimokuPlotTitle } from '../utils/ichimokuConfig';
|
||||
import {
|
||||
getNumericParamSpec,
|
||||
getHlinePriceSpec,
|
||||
} from '../utils/indicatorParamSpec';
|
||||
import {
|
||||
getGlobalParamKeys,
|
||||
getPlotParamKeys,
|
||||
plotParamsOnGlobalRowOnly,
|
||||
plotRowHasNoInputs,
|
||||
} from '../utils/indicatorSettingsLayout';
|
||||
import NumericParamInput from './NumericParamInput';
|
||||
import PlotLineStylePicker from './PlotLineStylePicker';
|
||||
import ColorInput from './ColorInput';
|
||||
import { colorToRgbaString, cloudColorOpacityPercent } from '../utils/ichimokuConfig';
|
||||
import type { IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||||
|
||||
const SRC_OPTIONS = ['close', 'open', 'high', 'low', 'hl2', 'hlc3', 'ohlc4'];
|
||||
|
||||
const MA_TYPE_OPTIONS_CCI = ['None', 'SMA', 'EMA', 'WMA'] as const;
|
||||
const MA_TYPE_OPTIONS_OBV = ['SMA', 'EMA', 'WMA'] as const;
|
||||
|
||||
/** 업비트 OBV 드롭다운 표기 */
|
||||
const OBV_MA_TYPE_LABELS: Record<string, string> = {
|
||||
SMA: '단순이동평균',
|
||||
EMA: '지수 이동 평균',
|
||||
WMA: '가중이동평균',
|
||||
};
|
||||
|
||||
const MA_TYPE_LABELS: Record<string, string> = {
|
||||
None: '없음 (None)',
|
||||
SMA: '단순이동평균 (SMA)',
|
||||
EMA: '지수이동평균 (EMA)',
|
||||
WMA: '가중이동평균 (WMA)',
|
||||
'SMMA (RMA)': 'RMA (SMMA)',
|
||||
VWMA: '거래량가중이동평균 (VWMA)',
|
||||
};
|
||||
|
||||
function isMaTypeParam(indicatorType: string, key: string, value: unknown): boolean {
|
||||
if (key !== 'maType' || typeof value !== 'string') return false;
|
||||
return indicatorType === 'CCI' || indicatorType === 'OBV' || indicatorType === 'RSI';
|
||||
}
|
||||
|
||||
function maTypeOptionsFor(indicatorType: string): readonly string[] {
|
||||
if (indicatorType === 'OBV') return MA_TYPE_OPTIONS_OBV;
|
||||
return MA_TYPE_OPTIONS_CCI;
|
||||
}
|
||||
|
||||
function isSrcParam(key: string, value: unknown): boolean {
|
||||
return (key === 'src' || key.toLowerCase().includes('src')) && typeof value === 'string';
|
||||
}
|
||||
|
||||
export { ColorInput };
|
||||
|
||||
/* ── 단일 파라미터 컨트롤 ─────────────────────────────────────── */
|
||||
export const ParamField: React.FC<{
|
||||
indicatorType: string;
|
||||
paramKey: string;
|
||||
value: number | string | boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string, raw: string | boolean) => void;
|
||||
}> = ({ indicatorType, paramKey, value, disabled, onChange }) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<label className="ism-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value}
|
||||
disabled={disabled}
|
||||
onChange={e => onChange(paramKey, e.target.checked)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
if (isMaTypeParam(indicatorType, paramKey, value)) {
|
||||
return (
|
||||
<select
|
||||
className="ism-select ism-param-inline-select"
|
||||
value={value as string}
|
||||
disabled={disabled}
|
||||
onChange={e => onChange(paramKey, e.target.value)}
|
||||
>
|
||||
{maTypeOptionsFor(indicatorType).map(o => (
|
||||
<option key={o} value={o}>
|
||||
{indicatorType === 'OBV'
|
||||
? (OBV_MA_TYPE_LABELS[o] ?? o)
|
||||
: (MA_TYPE_LABELS[o] ?? o)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (isSrcParam(paramKey, value)) {
|
||||
return (
|
||||
<select
|
||||
className="ism-select ism-param-inline-select"
|
||||
value={value as string}
|
||||
disabled={disabled}
|
||||
onChange={e => onChange(paramKey, e.target.value)}
|
||||
>
|
||||
{SRC_OPTIONS.map(o => (
|
||||
<option key={o} value={o}>{getSrcOptionLabel(o)}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return (
|
||||
<NumericParamInput
|
||||
className="ism-input ism-narrow ism-sma-period-input"
|
||||
value={value}
|
||||
spec={getNumericParamSpec(indicatorType, paramKey, value)}
|
||||
disabled={disabled}
|
||||
onChange={v => onChange(paramKey, String(v))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
className="ism-input ism-narrow"
|
||||
value={value as string}
|
||||
disabled={disabled}
|
||||
onChange={e => onChange(paramKey, e.target.value)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 플롯 설정 행 (on/off · 이름 · 입력 · 색상·선) ─────────────── */
|
||||
export const PlotSettingsRow: React.FC<{
|
||||
indicatorType: string;
|
||||
plot: PlotDef;
|
||||
plotIndex: number;
|
||||
plots: PlotDef[];
|
||||
params: Record<string, number | string | boolean>;
|
||||
enabled: boolean;
|
||||
inputsOnly?: boolean;
|
||||
onToggle: (plotId: string, on: boolean) => void;
|
||||
onParamChange: (key: string, raw: string | boolean) => void;
|
||||
onPlotStyle: (plotIndex: number, patch: Partial<PlotDef>) => void;
|
||||
}> = ({
|
||||
indicatorType, plot, plotIndex, plots, params, enabled, inputsOnly,
|
||||
onToggle, onParamChange, onPlotStyle,
|
||||
}) => {
|
||||
const paramKeys = getPlotParamKeys(indicatorType, plot.id, plotIndex, plots, params);
|
||||
const noInputs = plotRowHasNoInputs(indicatorType, plot.id, plotIndex, plots, params);
|
||||
const globalOnlyInputs = plotParamsOnGlobalRowOnly(indicatorType, plot.id);
|
||||
const label = indicatorType === 'IchimokuCloud'
|
||||
? getIchimokuPlotTitle(plot.id)
|
||||
: getPlotLabel(plot.title);
|
||||
const isHistogram = plot.type === 'histogram';
|
||||
|
||||
return (
|
||||
<div className={`ism-row ism-sma-ma-row${enabled ? '' : ' ism-sma-ma-off'}`}>
|
||||
<label className="ism-toggle ism-sma-ma-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => onToggle(plot.id, e.target.checked)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<label className="ism-label ism-sma-ma-label">{label}</label>
|
||||
<div className="ism-control ism-sma-ma-control">
|
||||
{noInputs && globalOnlyInputs ? (
|
||||
<span className="ism-hlines-bg-spacer" aria-hidden />
|
||||
) : noInputs ? (
|
||||
<span className="ism-ichimoku-auto-param" title="상위 설정 또는 자동 계산">자동</span>
|
||||
) : (
|
||||
<div className="ism-plot-param-inputs">
|
||||
{paramKeys.map(key => (
|
||||
<ParamField
|
||||
key={key}
|
||||
indicatorType={indicatorType}
|
||||
paramKey={key}
|
||||
value={params[key]}
|
||||
disabled={!enabled}
|
||||
onChange={onParamChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!inputsOnly && (
|
||||
isHistogram ? (
|
||||
<ColorInput
|
||||
value={plot.color}
|
||||
onChange={v => onPlotStyle(plotIndex, { color: v })}
|
||||
/>
|
||||
) : (
|
||||
<PlotLineStylePicker
|
||||
disabled={!enabled}
|
||||
title={label}
|
||||
value={{
|
||||
color: plot.color,
|
||||
lineWidth: plot.lineWidth,
|
||||
lineStyle: plot.lineStyle,
|
||||
}}
|
||||
onChange={patch => onPlotStyle(plotIndex, patch)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 전역 파라미터 행 (토글 없음) ─────────────────────────────── */
|
||||
export const GlobalParamRow: React.FC<{
|
||||
indicatorType: string;
|
||||
paramKey: string;
|
||||
value: number | string | boolean;
|
||||
onChange: (key: string, raw: string | boolean) => void;
|
||||
}> = ({ indicatorType, paramKey, value, onChange }) => (
|
||||
<div className="ism-row ism-global-param-row">
|
||||
<span className="ism-label ism-sma-ma-label">{getParamLabel(paramKey, indicatorType)}</span>
|
||||
<div className="ism-control ism-sma-ma-control">
|
||||
<ParamField
|
||||
indicatorType={indicatorType}
|
||||
paramKey={paramKey}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── 수평선 행 ───────────────────────────────────────────────── */
|
||||
export const HLineSettingsRow: React.FC<{
|
||||
indicatorType: string;
|
||||
hl: HLineDef;
|
||||
idx: number;
|
||||
allPrices: number[];
|
||||
inputsOnly?: boolean;
|
||||
onVisible: (idx: number, v: boolean) => void;
|
||||
onPrice: (idx: number, v: number) => void;
|
||||
onStyle: (idx: number, patch: Partial<HLineDef>) => void;
|
||||
}> = ({ indicatorType, hl, idx, allPrices, inputsOnly, onVisible, onPrice, onStyle }) => {
|
||||
const label = hl.label ?? getHLineLabel(hl.price, allPrices);
|
||||
const isOn = hl.visible !== false;
|
||||
return (
|
||||
<div className={`ism-row ism-sma-ma-row${isOn ? '' : ' ism-sma-ma-off'}`}>
|
||||
<label className="ism-toggle ism-sma-ma-toggle">
|
||||
<input type="checkbox" checked={isOn} onChange={e => onVisible(idx, e.target.checked)} />
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<label className="ism-label ism-sma-ma-label">{label}</label>
|
||||
<div className="ism-control ism-sma-ma-control">
|
||||
<NumericParamInput
|
||||
className="ism-input ism-narrow ism-sma-period-input"
|
||||
value={hl.price}
|
||||
spec={getHlinePriceSpec(indicatorType, hl.price)}
|
||||
disabled={!isOn}
|
||||
onChange={v => onPrice(idx, v)}
|
||||
/>
|
||||
{!inputsOnly && (
|
||||
<PlotLineStylePicker
|
||||
disabled={!isOn}
|
||||
title={label}
|
||||
value={{
|
||||
color: hl.color,
|
||||
lineWidth: hl.lineWidth,
|
||||
lineStyle: hl.lineStyle ?? 'dashed',
|
||||
}}
|
||||
onChange={patch => onStyle(idx, patch)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 출력 · 타임프레임 (하단 공통) ───────────────────────────── */
|
||||
export const SettingsOutputFooter: React.FC<{
|
||||
lastValueVisible: boolean;
|
||||
onLastValueVisible: (v: boolean) => void;
|
||||
timeframeVis: Partial<Record<Timeframe, boolean>>;
|
||||
onTimeframeVis: (tf: Timeframe, v: boolean) => void;
|
||||
timeframes: { tf: Timeframe; label: string }[];
|
||||
/** 개별 설정 모달: 지표 전체 숨김 */
|
||||
chartHidden?: boolean;
|
||||
onChartHidden?: (showOnChart: boolean) => void;
|
||||
}> = ({
|
||||
lastValueVisible, onLastValueVisible, timeframeVis, onTimeframeVis, timeframes,
|
||||
chartHidden, onChartHidden,
|
||||
}) => (
|
||||
<>
|
||||
<div className="ism-ichimoku-cloud-divider" />
|
||||
<div className="ism-output-section-title">출력 · 표시</div>
|
||||
{onChartHidden != null && (
|
||||
<div className="ism-row ism-ichimoku-extra-row">
|
||||
<span className="ism-label">차트에 표시</span>
|
||||
<div className="ism-control">
|
||||
<label className="ism-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={chartHidden !== true}
|
||||
onChange={e => onChartHidden(e.target.checked)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="ism-row ism-ichimoku-extra-row">
|
||||
<span className="ism-label">가격축 라벨·설명</span>
|
||||
<div className="ism-control">
|
||||
<label className="ism-toggle" title="이 지표만 우측 가격축 금액·설명 표시 (차트 설정 전역 스위치가 켜져 있을 때 적용)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={lastValueVisible}
|
||||
onChange={e => onLastValueVisible(e.target.checked)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-tf-grid ism-ichimoku-tf-grid">
|
||||
{timeframes.map(({ tf, label }) => (
|
||||
<label key={tf} className="ism-tf-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={timeframeVis[tf] !== false}
|
||||
onChange={e => onTimeframeVis(tf, e.target.checked)}
|
||||
/>
|
||||
<span className="ism-tf-label">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
/* ── 일목 구름 색상 ─────────────────────────────────────────── */
|
||||
export const IchimokuCloudSection: React.FC<{
|
||||
cloudColors: IchimokuCloudColors;
|
||||
onChange: (patch: Partial<IchimokuCloudColors>) => void;
|
||||
}> = ({ cloudColors, onChange }) => (
|
||||
<>
|
||||
<div className="ism-ichimoku-cloud-divider" />
|
||||
<div className="ism-ichimoku-cloud-section">
|
||||
<h4 className="ism-ichimoku-cloud-title">구름 영역 색상</h4>
|
||||
<p className="ism-ichimoku-cloud-hint">
|
||||
선행스팬1·선행스팬2가 모두 켜져 있을 때만 구름이 표시됩니다.
|
||||
</p>
|
||||
<div className="ism-ichimoku-cloud-grid">
|
||||
<div className="ism-ichimoku-cloud-card">
|
||||
<span className="ism-ichimoku-cloud-card-label">상승 구름 (선행1 > 선행2)</span>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bullishColor}
|
||||
onChange={v => onChange({ bullishColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bullishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bullishColor)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-card">
|
||||
<span className="ism-ichimoku-cloud-card-label">하락 구름 (선행1 < 선행2)</span>
|
||||
<div className="ism-ichimoku-cloud-card-row">
|
||||
<ColorInput
|
||||
value={cloudColors.bearishColor}
|
||||
onChange={v => onChange({ bearishColor: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-ichimoku-cloud-meta">
|
||||
{colorToRgbaString(cloudColors.bearishColor)}
|
||||
{' · '}투명도 {cloudColorOpacityPercent(cloudColors.bearishColor)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
/* ── 볼린저밴드 백그라운드 (업비트 모습 탭) ───────────────────── */
|
||||
export const BollingerBackgroundRow: React.FC<{
|
||||
value: HlinesBackground;
|
||||
onChange: (v: HlinesBackground) => void;
|
||||
}> = ({ value, onChange }) => (
|
||||
<div className={`ism-row ism-sma-ma-row${value.visible ? '' : ' ism-sma-ma-off'}`}>
|
||||
<label className="ism-toggle ism-sma-ma-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.visible}
|
||||
onChange={e => onChange({ ...value, visible: e.target.checked })}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<span className="ism-label ism-sma-ma-label">백그라운드 그리기</span>
|
||||
<div className="ism-control ism-sma-ma-control">
|
||||
<span className="ism-ichimoku-auto-param ism-hlines-bg-spacer" aria-hidden />
|
||||
<ColorInput
|
||||
value={value.color}
|
||||
onChange={c => onChange({ ...value, color: c })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── 수평선 배경 ─────────────────────────────────────────────── */
|
||||
export const HlinesBackgroundRow: React.FC<{
|
||||
value: HlinesBackground;
|
||||
onChange: (v: HlinesBackground) => void;
|
||||
}> = ({ value, onChange }) => (
|
||||
<>
|
||||
<div className="ism-ichimoku-cloud-divider" />
|
||||
<div className="ism-row ism-sma-ma-row">
|
||||
<label className="ism-toggle ism-sma-ma-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.visible}
|
||||
onChange={e => onChange({ ...value, visible: e.target.checked })}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
<span className="ism-label ism-sma-ma-label">수평선 배경</span>
|
||||
<div className="ism-control ism-sma-ma-control">
|
||||
<span className="ism-ichimoku-auto-param ism-hlines-bg-spacer" aria-hidden />
|
||||
<ColorInput
|
||||
value={value.color}
|
||||
onChange={c => onChange({ ...value, color: c })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
/* ── 볼린저밴드 심볼 (업비트 인풋) ───────────────────────────── */
|
||||
export const BollingerSymbolSection: React.FC<{
|
||||
symbolMode: string;
|
||||
refSymbol: string;
|
||||
chartMarket: string;
|
||||
onChange: (patch: { symbolMode?: string; refSymbol?: string }) => void;
|
||||
}> = ({ symbolMode, refSymbol, chartMarket, onChange }) => {
|
||||
const isOther = symbolMode === 'other';
|
||||
return (
|
||||
<div className="ism-bb-symbol-section">
|
||||
<div className="ism-output-section-title">심볼</div>
|
||||
<label className="ism-bb-symbol-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="bb-symbol-mode"
|
||||
checked={!isOther}
|
||||
onChange={() => onChange({ symbolMode: 'main' })}
|
||||
/>
|
||||
<span>메인 차트 심볼</span>
|
||||
{chartMarket ? <span className="ism-bb-symbol-hint">{chartMarket}</span> : null}
|
||||
</label>
|
||||
<label className="ism-bb-symbol-option ism-bb-symbol-other">
|
||||
<input
|
||||
type="radio"
|
||||
name="bb-symbol-mode"
|
||||
checked={isOther}
|
||||
onChange={() => onChange({ symbolMode: 'other' })}
|
||||
/>
|
||||
<span>다른 심볼</span>
|
||||
<input
|
||||
type="text"
|
||||
className="ism-input ism-bb-ref-symbol"
|
||||
placeholder="KRW-BTC"
|
||||
value={refSymbol}
|
||||
disabled={!isOther}
|
||||
onChange={e => onChange({ refSymbol: e.target.value.toUpperCase() })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── 통합 본문 (일반 지표) ───────────────────────────────────── */
|
||||
export const UnifiedIndicatorBody: React.FC<{
|
||||
config: IndicatorConfig;
|
||||
params: Record<string, number | string | boolean>;
|
||||
plots: PlotDef[];
|
||||
plotVis: Record<string, boolean>;
|
||||
hlines: HLineDef[];
|
||||
hlinesBg: HlinesBackground;
|
||||
hasHLines: boolean;
|
||||
lastValueVisible: boolean;
|
||||
timeframeVis: Partial<Record<Timeframe, boolean>>;
|
||||
timeframes: { tf: Timeframe; label: string }[];
|
||||
onParamChange: (key: string, raw: string | boolean) => void;
|
||||
onPlotToggle: (plotId: string, on: boolean) => void;
|
||||
onPlotStyle: (idx: number, patch: Partial<PlotDef>) => void;
|
||||
onHLineVisible: (idx: number, v: boolean) => void;
|
||||
onHLinePrice: (idx: number, v: number) => void;
|
||||
onHLineStyle: (idx: number, patch: Partial<HLineDef>) => void;
|
||||
onHlinesBg: (v: HlinesBackground) => void;
|
||||
onLastValueVisible: (v: boolean) => void;
|
||||
onTimeframeVis: (tf: Timeframe, v: boolean) => void;
|
||||
cloudSection?: React.ReactNode;
|
||||
symbolSection?: React.ReactNode;
|
||||
/** 플롯 행 아래 추가 섹션 (BB 백그라운드 등) */
|
||||
afterPlotsSection?: React.ReactNode;
|
||||
/** false면 가격 스케일·타임프레임 보임 설정 숨김 (BB) */
|
||||
showOutputFooter?: boolean;
|
||||
inputsOnly?: boolean;
|
||||
chartHidden?: boolean;
|
||||
onChartHidden?: (showOnChart: boolean) => void;
|
||||
}> = (props) => {
|
||||
const {
|
||||
config, params, plots, plotVis, hlines, hlinesBg, hasHLines,
|
||||
lastValueVisible, timeframeVis, timeframes,
|
||||
onParamChange, onPlotToggle, onPlotStyle,
|
||||
onHLineVisible, onHLinePrice, onHLineStyle, onHlinesBg,
|
||||
onLastValueVisible, onTimeframeVis, cloudSection, symbolSection,
|
||||
afterPlotsSection,
|
||||
showOutputFooter = true,
|
||||
inputsOnly = false,
|
||||
chartHidden,
|
||||
onChartHidden,
|
||||
} = props;
|
||||
|
||||
const globalKeys = getGlobalParamKeys(config.type, params, plots);
|
||||
const hlinePrices = hlines.map(h => h.price);
|
||||
|
||||
return (
|
||||
<div className="ism-section unified-settings-section">
|
||||
{symbolSection}
|
||||
{symbolSection && globalKeys.length > 0 && <div className="ism-sma-divider" />}
|
||||
{globalKeys.map(key => (
|
||||
<GlobalParamRow
|
||||
key={key}
|
||||
indicatorType={config.type}
|
||||
paramKey={key}
|
||||
value={params[key]}
|
||||
onChange={onParamChange}
|
||||
/>
|
||||
))}
|
||||
|
||||
{globalKeys.length > 0 && plots.length > 0 && <div className="ism-sma-divider" />}
|
||||
|
||||
{plots.map((plot, idx) => (
|
||||
<PlotSettingsRow
|
||||
key={plot.id}
|
||||
indicatorType={config.type}
|
||||
plot={plot}
|
||||
plotIndex={idx}
|
||||
plots={plots}
|
||||
params={params}
|
||||
enabled={plotVis[plot.id] !== false}
|
||||
onToggle={onPlotToggle}
|
||||
onParamChange={onParamChange}
|
||||
onPlotStyle={onPlotStyle}
|
||||
inputsOnly={inputsOnly}
|
||||
/>
|
||||
))}
|
||||
|
||||
{cloudSection}
|
||||
|
||||
{afterPlotsSection}
|
||||
|
||||
{hlines.length > 0 && (
|
||||
<>
|
||||
<div className="ism-sma-divider" />
|
||||
<div className="ism-output-section-title">수평선</div>
|
||||
{hlines.map((hl, idx) => (
|
||||
<HLineSettingsRow
|
||||
key={idx}
|
||||
indicatorType={config.type}
|
||||
hl={hl}
|
||||
idx={idx}
|
||||
allPrices={hlinePrices}
|
||||
onVisible={onHLineVisible}
|
||||
onPrice={onHLinePrice}
|
||||
onStyle={onHLineStyle}
|
||||
inputsOnly={inputsOnly}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasHLines && !inputsOnly && <HlinesBackgroundRow value={hlinesBg} onChange={onHlinesBg} />}
|
||||
|
||||
{showOutputFooter && (
|
||||
<SettingsOutputFooter
|
||||
lastValueVisible={lastValueVisible}
|
||||
onLastValueVisible={onLastValueVisible}
|
||||
timeframeVis={timeframeVis}
|
||||
onTimeframeVis={onTimeframeVis}
|
||||
timeframes={timeframes}
|
||||
chartHidden={chartHidden}
|
||||
onChartHidden={onChartHidden}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* 보조지표 설정 — 스타일 탭 (플롯·수평선 색상/굵기/타입)
|
||||
* 타이틀 열 너비를 섹션 내 최장 라벨에 맞춰 컨트롤 상하 정렬
|
||||
*/
|
||||
import React, { useLayoutEffect, useRef } from 'react';
|
||||
import type { PlotDef, HLineDef, HLineStyle } from '../utils/indicatorRegistry';
|
||||
import { getHLineLabel } from '../utils/indicatorRegistry';
|
||||
import { getPlotLabel } from '../utils/indicatorLabels';
|
||||
import { getHlinePriceSpec } from '../utils/indicatorParamSpec';
|
||||
import NumericParamInput from './NumericParamInput';
|
||||
import { ColorInput } from './IndicatorSettingsSections';
|
||||
import { LINE_WIDTH_OPTIONS, LINE_STYLE_OPTIONS } from '../utils/plotColorUtils';
|
||||
import { IchimokuCloudSection } from './IndicatorSettingsSections';
|
||||
import { getIchimokuPlotTitle, type IchimokuCloudColors } from '../utils/ichimokuConfig';
|
||||
|
||||
/** 스타일 탭 선 유형 드롭다운 (업비트 표기) */
|
||||
const LINE_STYLE_TAB_LABELS: Record<HLineStyle, string> = {
|
||||
solid: '라인',
|
||||
dashed: '점선',
|
||||
dotted: '도트',
|
||||
};
|
||||
|
||||
export interface IndicatorSettingsStyleSectionProps {
|
||||
indicatorType: string;
|
||||
plots: PlotDef[];
|
||||
hlines: HLineDef[];
|
||||
cloudColors?: IchimokuCloudColors;
|
||||
showIchimokuCloud?: boolean;
|
||||
onPlotStyle: (idx: number, patch: Partial<PlotDef>) => void;
|
||||
onHLineStyle: (idx: number, patch: Partial<HLineDef>) => void;
|
||||
onHLinePrice?: (idx: number, price: number) => void;
|
||||
onCloudColors?: (patch: Partial<IchimokuCloudColors>) => void;
|
||||
}
|
||||
|
||||
function measureTitleColumn(sectionEl: HTMLElement | null, measureEl: HTMLElement | null) {
|
||||
if (!sectionEl || !measureEl) return;
|
||||
let maxW = 0;
|
||||
measureEl.querySelectorAll<HTMLElement>('.ism-plot-title').forEach(el => {
|
||||
maxW = Math.max(maxW, el.offsetWidth);
|
||||
});
|
||||
if (maxW > 0) {
|
||||
sectionEl.style.setProperty('--ism-plot-title-width', `${Math.ceil(maxW)}px`);
|
||||
}
|
||||
}
|
||||
|
||||
const PlotStyleRow: React.FC<{
|
||||
label: string;
|
||||
plot: PlotDef;
|
||||
disabled?: boolean;
|
||||
onStyle: (patch: Partial<PlotDef>) => void;
|
||||
}> = ({ label, plot, disabled, onStyle }) => {
|
||||
const isHistogram = plot.type === 'histogram';
|
||||
const lineWidth = plot.lineWidth ?? 1;
|
||||
const lineStyle = plot.lineStyle ?? 'solid';
|
||||
|
||||
return (
|
||||
<div className="ism-style-row">
|
||||
<div className="ism-plot-title-cell">
|
||||
<span className="ism-plot-title">{label}</span>
|
||||
</div>
|
||||
<div className="ism-style-field">
|
||||
<span className="ism-style-label">색상</span>
|
||||
<ColorInput
|
||||
value={plot.color}
|
||||
onChange={v => onStyle({ color: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="ism-style-field">
|
||||
<span className="ism-style-label">굵기</span>
|
||||
{isHistogram ? (
|
||||
<span className="ism-style-field-spacer ism-num-combo-spacer" aria-hidden />
|
||||
) : (
|
||||
<select
|
||||
className="ism-select ism-style-width-select"
|
||||
value={lineWidth}
|
||||
disabled={disabled}
|
||||
onChange={e => onStyle({ lineWidth: Number(e.target.value) })}
|
||||
>
|
||||
{LINE_WIDTH_OPTIONS.map(w => (
|
||||
<option key={w} value={w}>{w}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="ism-style-field">
|
||||
<span className="ism-style-label">타입</span>
|
||||
{isHistogram ? (
|
||||
<span className="ism-style-type-static">히스토그램</span>
|
||||
) : (
|
||||
<select
|
||||
className="ism-select ism-style-type-select"
|
||||
value={lineStyle}
|
||||
disabled={disabled}
|
||||
onChange={e => onStyle({ lineStyle: e.target.value as HLineStyle })}
|
||||
>
|
||||
{LINE_STYLE_OPTIONS.map(s => (
|
||||
<option key={s} value={s}>{LINE_STYLE_TAB_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HLineStyleRow: React.FC<{
|
||||
indicatorType: string;
|
||||
label: string;
|
||||
hl: HLineDef;
|
||||
onStyle: (patch: Partial<HLineDef>) => void;
|
||||
onPrice?: (price: number) => void;
|
||||
}> = ({ indicatorType, label, hl, onStyle, onPrice }) => {
|
||||
const isOn = hl.visible !== false;
|
||||
const lineWidth = hl.lineWidth ?? 1;
|
||||
const lineStyle = hl.lineStyle ?? 'dashed';
|
||||
|
||||
return (
|
||||
<div className={`ism-hline-row${isOn ? '' : ' ism-hline-row--off'}`}>
|
||||
<div className="ism-hline-name-cell">
|
||||
<span className="ism-hline-label-text">{label}</span>
|
||||
</div>
|
||||
<div className="ism-hline-cell">
|
||||
{onPrice != null ? (
|
||||
<NumericParamInput
|
||||
className="ism-input ism-narrow"
|
||||
value={hl.price}
|
||||
spec={getHlinePriceSpec(indicatorType, hl.price)}
|
||||
disabled={!isOn}
|
||||
onChange={onPrice}
|
||||
/>
|
||||
) : (
|
||||
<span className="ism-hline-price-readonly">{hl.price}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="ism-hline-cell ism-hline-cell--style">
|
||||
<ColorInput value={hl.color} onChange={v => onStyle({ color: v })} />
|
||||
<select
|
||||
className="ism-select ism-style-width-select"
|
||||
value={lineWidth}
|
||||
disabled={!isOn}
|
||||
title="굵기"
|
||||
onChange={e => onStyle({ lineWidth: Number(e.target.value) })}
|
||||
>
|
||||
{LINE_WIDTH_OPTIONS.map(w => (
|
||||
<option key={w} value={w}>{w}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="ism-select ism-style-type-select"
|
||||
value={lineStyle}
|
||||
disabled={!isOn}
|
||||
title="타입"
|
||||
onChange={e => onStyle({ lineStyle: e.target.value as HLineStyle })}
|
||||
>
|
||||
{LINE_STYLE_OPTIONS.map(s => (
|
||||
<option key={s} value={s}>{LINE_STYLE_TAB_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const IndicatorSettingsStyleSection: React.FC<IndicatorSettingsStyleSectionProps> = ({
|
||||
indicatorType,
|
||||
plots,
|
||||
hlines,
|
||||
cloudColors,
|
||||
showIchimokuCloud,
|
||||
onPlotStyle,
|
||||
onHLineStyle,
|
||||
onHLinePrice,
|
||||
onCloudColors,
|
||||
}) => {
|
||||
const sectionRef = useRef<HTMLDivElement>(null);
|
||||
const measureRef = useRef<HTMLDivElement>(null);
|
||||
const hlinePrices = hlines.map(h => h.price);
|
||||
|
||||
const plotLabel = (p: PlotDef) => indicatorType === 'IchimokuCloud'
|
||||
? getIchimokuPlotTitle(p.id)
|
||||
: getPlotLabel(p.title);
|
||||
|
||||
const titleLabels = [
|
||||
...plots.map(plotLabel),
|
||||
...hlines.map(h => h.label ?? getHLineLabel(h.price, hlinePrices)),
|
||||
];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measureTitleColumn(sectionRef.current, measureRef.current);
|
||||
}, [titleLabels.join('\0'), plots.length, hlines.length]);
|
||||
|
||||
return (
|
||||
<div className="ism-section ism-style-tab-section">
|
||||
<div ref={measureRef} className="ism-plot-title-measure" aria-hidden>
|
||||
{titleLabels.map((t, i) => (
|
||||
<span key={i} className="ism-plot-title">{t}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={sectionRef} className="ism-style-section">
|
||||
{plots.map((plot, idx) => (
|
||||
<PlotStyleRow
|
||||
key={plot.id}
|
||||
label={plotLabel(plot)}
|
||||
plot={plot}
|
||||
onStyle={patch => onPlotStyle(idx, patch)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{hlines.length > 0 && (
|
||||
<>
|
||||
<hr className="ism-hline-divider" />
|
||||
<div className="ism-hline-header-row">
|
||||
<span>이름</span>
|
||||
<span>가격</span>
|
||||
<span>색상 · 굵기 · 타입</span>
|
||||
</div>
|
||||
{hlines.map((hl, idx) => (
|
||||
<HLineStyleRow
|
||||
key={idx}
|
||||
indicatorType={indicatorType}
|
||||
label={hl.label ?? getHLineLabel(hl.price, hlinePrices)}
|
||||
hl={hl}
|
||||
onStyle={patch => onHLineStyle(idx, patch)}
|
||||
onPrice={onHLinePrice ? v => onHLinePrice(idx, v) : undefined}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showIchimokuCloud && cloudColors && onCloudColors && (
|
||||
<IchimokuCloudSection cloudColors={cloudColors} onChange={onCloudColors} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IndicatorSettingsStyleSection;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 보조지표 설정 — 가시성 탭
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { Timeframe } from '../types';
|
||||
import { SettingsOutputFooter } from './IndicatorSettingsSections';
|
||||
|
||||
export interface IndicatorSettingsVisibilitySectionProps {
|
||||
hidden: boolean;
|
||||
onHidden: (v: boolean) => void;
|
||||
lastValueVisible: boolean;
|
||||
onLastValueVisible: (v: boolean) => void;
|
||||
timeframeVis: Partial<Record<Timeframe, boolean>>;
|
||||
onTimeframeVis: (tf: Timeframe, v: boolean) => void;
|
||||
timeframes: { tf: Timeframe; label: string }[];
|
||||
}
|
||||
|
||||
const IndicatorSettingsVisibilitySection: React.FC<IndicatorSettingsVisibilitySectionProps> = ({
|
||||
hidden,
|
||||
onHidden,
|
||||
lastValueVisible,
|
||||
onLastValueVisible,
|
||||
timeframeVis,
|
||||
onTimeframeVis,
|
||||
timeframes,
|
||||
}) => (
|
||||
<div className="ism-section ism-visibility-tab-section">
|
||||
<div className="ism-row">
|
||||
<span className="ism-label">차트에 표시</span>
|
||||
<div className="ism-control">
|
||||
<label className="ism-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!hidden}
|
||||
onChange={e => onHidden(!e.target.checked)}
|
||||
/>
|
||||
<span className="ism-toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<SettingsOutputFooter
|
||||
lastValueVisible={lastValueVisible}
|
||||
onLastValueVisible={onLastValueVisible}
|
||||
timeframeVis={timeframeVis}
|
||||
onTimeframeVis={onTimeframeVis}
|
||||
timeframes={timeframes}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default IndicatorSettingsVisibilitySection;
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* LayoutPicker
|
||||
* TradingView 스타일 레이아웃 선택 팝업.
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import type { LayoutDef, SyncOptions } from '../utils/layoutTypes';
|
||||
import { LAYOUTS } from '../utils/layoutTypes';
|
||||
|
||||
// grid-template-areas 문자열을 파싱해 셀 rect 배열 반환
|
||||
function parseAreas(def: LayoutDef, bw: number, bh: number, pad: number) {
|
||||
const areaGrid: string[][] = def.areas
|
||||
.split('"')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean)
|
||||
.map(row => row.split(/\s+/).filter(Boolean));
|
||||
|
||||
if (areaGrid.length === 0) return [];
|
||||
|
||||
const rowCount = areaGrid.length;
|
||||
const colCount = areaGrid[0]?.length ?? 1;
|
||||
const colW = bw / colCount;
|
||||
const rowH = bh / rowCount;
|
||||
|
||||
const cellMap = new Map<string, { c1: number; r1: number; c2: number; r2: number }>();
|
||||
for (let ri = 0; ri < areaGrid.length; ri++) {
|
||||
for (let ci = 0; ci < (areaGrid[ri]?.length ?? 0); ci++) {
|
||||
const name = areaGrid[ri][ci];
|
||||
if (!name || name === '.') continue;
|
||||
const existing = cellMap.get(name);
|
||||
if (!existing) {
|
||||
cellMap.set(name, { c1: ci, r1: ri, c2: ci + 1, r2: ri + 1 });
|
||||
} else {
|
||||
cellMap.set(name, {
|
||||
c1: Math.min(existing.c1, ci),
|
||||
r1: Math.min(existing.r1, ri),
|
||||
c2: Math.max(existing.c2, ci + 1),
|
||||
r2: Math.max(existing.r2, ri + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(cellMap.values()).map(r => ({
|
||||
x: pad + r.c1 * colW,
|
||||
y: pad + r.r1 * rowH,
|
||||
w: (r.c2 - r.c1) * colW,
|
||||
h: (r.r2 - r.r1) * rowH,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 레이아웃 미니 아이콘 (SVG) ────────────────────────────────────────────────
|
||||
const LayoutIcon: React.FC<{ def: LayoutDef; size?: number }> = ({ def, size = 34 }) => {
|
||||
const S = size;
|
||||
const PAD = 2;
|
||||
const BW = S - PAD * 2;
|
||||
const BH = S - PAD * 2;
|
||||
const rects = parseAreas(def, BW, BH, PAD);
|
||||
|
||||
return (
|
||||
<svg width={S} height={S} viewBox={`0 0 ${S} ${S}`} fill="none">
|
||||
<rect x={PAD} y={PAD} width={BW} height={BH} rx="2"
|
||||
stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
{rects.map((r, i) => (
|
||||
<rect key={i}
|
||||
x={r.x + 1} y={r.y + 1}
|
||||
width={r.w - 2} height={r.h - 2}
|
||||
rx="1"
|
||||
stroke="currentColor" strokeWidth="1"
|
||||
fill="currentColor" fillOpacity="0.15"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 토글 스위치 ──────────────────────────────────────────────────────────────
|
||||
const Toggle: React.FC<{ checked: boolean; onChange: () => void }> = ({ checked, onChange }) => (
|
||||
<button
|
||||
className={`lp-toggle${checked ? ' on' : ''}`}
|
||||
onClick={onChange}
|
||||
aria-pressed={checked}
|
||||
/>
|
||||
);
|
||||
|
||||
// 행 그룹: 같은 count 단위로 묶음
|
||||
const ROW_GROUPS = [1, 2, 3, 4, 5, 6, 8];
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
interface LayoutPickerProps {
|
||||
currentId: string;
|
||||
syncOptions: SyncOptions;
|
||||
onSelect: (def: LayoutDef) => void;
|
||||
onSyncChange: (key: keyof SyncOptions, value: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const LayoutPicker: React.FC<LayoutPickerProps> = ({
|
||||
currentId, syncOptions, onSelect, onSyncChange, onClose,
|
||||
}) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
||||
};
|
||||
setTimeout(() => document.addEventListener('mousedown', handler), 80);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="lp-popup">
|
||||
{/* 레이아웃 그리드 */}
|
||||
<div className="lp-grid-section">
|
||||
{ROW_GROUPS.map(count => {
|
||||
const group = LAYOUTS.filter(l => l.count === count);
|
||||
if (group.length === 0) return null;
|
||||
return (
|
||||
<div key={count} className="lp-row">
|
||||
<span className="lp-row-num">{count}</span>
|
||||
<div className="lp-row-icons">
|
||||
{group.map(def => (
|
||||
<button
|
||||
key={def.id}
|
||||
className={`lp-icon-btn${currentId === def.id ? ' active' : ''}`}
|
||||
title={def.label}
|
||||
onClick={() => { onSelect(def); }}
|
||||
>
|
||||
<LayoutIcon def={def} size={34} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="lp-divider" />
|
||||
|
||||
{/* SYNC IN LAYOUT */}
|
||||
<div className="lp-sync-section">
|
||||
<div className="lp-sync-label">SYNC IN LAYOUT</div>
|
||||
{([
|
||||
['symbol', 'Symbol'],
|
||||
['interval', 'Interval'],
|
||||
['crosshair', 'Crosshair'],
|
||||
['time', 'Time'],
|
||||
['dateRange', 'Date range'],
|
||||
] as [keyof SyncOptions, string][]).map(([key, label]) => (
|
||||
<div key={key} className="lp-sync-row">
|
||||
<span className="lp-sync-name">{label}</span>
|
||||
<Toggle
|
||||
checked={syncOptions[key]}
|
||||
onChange={() => onSyncChange(key, !syncOptions[key])}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutPicker;
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 실시간 전략 STOMP 구독 — 화면과 무관하게 동작, 시그널은 알림 Context로 전달
|
||||
*/
|
||||
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
|
||||
import { useLiveStrategyMarkers, type LiveMarker } from '../hooks/useLiveStrategyMarkers';
|
||||
import { useTradeNotification } from '../contexts/TradeNotificationContext';
|
||||
|
||||
export interface LiveSignalNotifierHandle {
|
||||
clearMarkers: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
markets: string[];
|
||||
/** 종목별 평가 분봉 — STOMP 구독 경로 */
|
||||
marketSubscriptions?: { market: string; candleType: string }[];
|
||||
activeMarket: string;
|
||||
enabled: boolean;
|
||||
popupEnabled: boolean;
|
||||
strategyId: number | undefined;
|
||||
executionType: string;
|
||||
strategyName: string | null;
|
||||
onMarkersChange: (markers: LiveMarker[]) => void;
|
||||
/** 차트 화면일 때만 마커를 차트에 반영 */
|
||||
chartMarkersActive: boolean;
|
||||
}
|
||||
|
||||
export const LiveSignalNotifier = forwardRef<LiveSignalNotifierHandle, Props>(function LiveSignalNotifier({
|
||||
markets,
|
||||
marketSubscriptions,
|
||||
activeMarket,
|
||||
enabled,
|
||||
popupEnabled: _popupEnabled,
|
||||
strategyName,
|
||||
executionType,
|
||||
onMarkersChange,
|
||||
chartMarkersActive,
|
||||
}, ref) {
|
||||
const { addNotification, refreshHistory } = useTradeNotification();
|
||||
const onMarkersRef = useRef(onMarkersChange);
|
||||
onMarkersRef.current = onMarkersChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || markets.length === 0) return;
|
||||
const id = window.setInterval(() => { void refreshHistory(); }, 20_000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [enabled, markets.length, refreshHistory]);
|
||||
|
||||
const { clearMarkers } = useLiveStrategyMarkers({
|
||||
markets,
|
||||
subscriptions: marketSubscriptions,
|
||||
activeMarket,
|
||||
enabled: enabled && markets.length > 0,
|
||||
onMarkersChange: markers => {
|
||||
if (chartMarkersActive) onMarkersRef.current(markers);
|
||||
},
|
||||
onSignal: (marker: LiveMarker) => {
|
||||
addNotification({
|
||||
market: marker.market,
|
||||
signalType: marker.signal,
|
||||
price: marker.price,
|
||||
candleTime: marker.time,
|
||||
strategyName,
|
||||
executionType,
|
||||
candleType: marketSubscriptions?.find(s => s.market === marker.market)?.candleType ?? '1m',
|
||||
});
|
||||
// STOMP만 수신·DB 저장 누락 시 배지 동기화
|
||||
window.setTimeout(() => { void refreshHistory(); }, 800);
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({ clearMarkers }), [clearMarkers]);
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
export default LiveSignalNotifier;
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* LiveStrategyPanel
|
||||
*
|
||||
* 실시간 전략 체크 — 전역 설정 UI.
|
||||
* 관심종목(★) 등록 종목 전체가 체크 대상이며, 여기서 전략·실행 방식을 지정합니다.
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import {
|
||||
saveLiveStrategySettings,
|
||||
type LiveStrategySettingsDto,
|
||||
} from '../utils/backendApi';
|
||||
|
||||
interface Strategy {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface LiveStrategyPanelProps {
|
||||
theme: Theme;
|
||||
market: string;
|
||||
strategies: Strategy[];
|
||||
settings: LiveStrategySettingsDto;
|
||||
watchlistCount?: number;
|
||||
monitoredMarkets?: string[];
|
||||
onClearMarkers?: () => void;
|
||||
onClose?: () => void;
|
||||
onSettingsChange?: (settings: LiveStrategySettingsDto) => void;
|
||||
}
|
||||
|
||||
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
theme, market, strategies, settings,
|
||||
watchlistCount = 0, monitoredMarkets = [],
|
||||
onClearMarkers, onClose, onSettingsChange,
|
||||
}) => {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
|
||||
const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
||||
const prev = settings;
|
||||
onSettingsChange?.(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveLiveStrategySettings(next);
|
||||
if (saved) {
|
||||
onSettingsChange?.(saved);
|
||||
onClearMarkers?.();
|
||||
} else {
|
||||
onSettingsChange?.(prev);
|
||||
window.alert('실시간 전략 설정 저장에 실패했습니다. 네트워크·서버 상태를 확인해 주세요.');
|
||||
}
|
||||
} catch {
|
||||
onSettingsChange?.(prev);
|
||||
window.alert('실시간 전략 설정 저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [settings, market, onClearMarkers, onSettingsChange]);
|
||||
|
||||
const isOn = settings.isLiveCheck;
|
||||
const execType = settings.executionType;
|
||||
const stratId = settings.strategyId;
|
||||
const monCount = monitoredMarkets.length;
|
||||
|
||||
return (
|
||||
<div className={`lsp-wrap lsp-wrap--${theme}`}>
|
||||
<div className="lsp-header">
|
||||
<svg className="lsp-icon" width="13" height="13" viewBox="0 0 14 14"
|
||||
fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="7" cy="7" r="5.5"/>
|
||||
<polyline points="5,7 7,9 10,5"/>
|
||||
</svg>
|
||||
<span className="lsp-title">실시간 전략 체크</span>
|
||||
{saving && <span className="lsp-saving">저장 중…</span>}
|
||||
{onClose && (
|
||||
<button className="lsp-close-btn" onClick={onClose} title="닫기">
|
||||
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
|
||||
<line x1="1" y1="1" x2="10" y2="10"/>
|
||||
<line x1="10" y1="1" x2="1" y2="10"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lsp-monitor-stats">
|
||||
<span>관심 {watchlistCount}개</span>
|
||||
<span className="lsp-monitor-sep">·</span>
|
||||
<span className={monCount > 0 ? 'lsp-monitor-on' : ''}>
|
||||
체크 대상 {monCount}개
|
||||
</span>
|
||||
</div>
|
||||
{isOn && stratId && watchlistCount > 0 && (
|
||||
<p className="lsp-monitor-hint">
|
||||
★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다.
|
||||
</p>
|
||||
)}
|
||||
{monCount > 0 && (
|
||||
<div className="lsp-monitor-list" title={monitoredMarkets.join(', ')}>
|
||||
{monitoredMarkets.slice(0, 8).map(m => (
|
||||
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span>
|
||||
))}
|
||||
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
|
||||
</div>
|
||||
)}
|
||||
{isOn && !stratId && (
|
||||
<p className="lsp-monitor-hint">전략을 선택하면 관심 {watchlistCount}개 종목에 적용됩니다.</p>
|
||||
)}
|
||||
|
||||
<div className="lsp-row">
|
||||
<span className="lsp-label">실시간 체크</span>
|
||||
<label className="lsp-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isOn}
|
||||
onChange={e => persist({ isLiveCheck: e.target.checked })}
|
||||
/>
|
||||
<span className="lsp-toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-row${!isOn ? ' lsp-row--disabled' : ''}`}>
|
||||
<span className="lsp-label">전략 선택</span>
|
||||
<select
|
||||
className="lsp-select"
|
||||
disabled={!isOn}
|
||||
value={stratId ?? ''}
|
||||
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">전략 선택…</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-exec-wrap${!isOn ? ' lsp-row--disabled' : ''}`}>
|
||||
<span className="lsp-label">실행 방식</span>
|
||||
<div className="lsp-radio-group">
|
||||
<label className="lsp-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="execType"
|
||||
value="CANDLE_CLOSE"
|
||||
checked={execType === 'CANDLE_CLOSE'}
|
||||
disabled={!isOn}
|
||||
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
|
||||
/>
|
||||
<span className="lsp-radio-label">
|
||||
<span className="lsp-radio-title">봉 마감 직후</span>
|
||||
<span className="lsp-radio-desc">완성된 캔들 기준으로 1회 판정</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="lsp-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="execType"
|
||||
value="REALTIME_TICK"
|
||||
checked={execType === 'REALTIME_TICK'}
|
||||
disabled={!isOn}
|
||||
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
|
||||
/>
|
||||
<span className="lsp-radio-label">
|
||||
<span className="lsp-radio-title">실시간 틱 (3초)</span>
|
||||
<span className="lsp-radio-desc">진행 중인 캔들 기준, 3초 주기 판정</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`lsp-exec-wrap${!isOn ? ' lsp-row--disabled' : ''}`}>
|
||||
<span className="lsp-label">시그널 모드</span>
|
||||
<div className="lsp-radio-group">
|
||||
<label className="lsp-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="lsp-posMode"
|
||||
value="LONG_ONLY"
|
||||
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
|
||||
disabled={!isOn}
|
||||
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
|
||||
/>
|
||||
<span className="lsp-radio-label">
|
||||
<span className="lsp-radio-title">보유 자산 기준</span>
|
||||
<span className="lsp-radio-desc">매수 이력 있을 때만 매도 허용</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="lsp-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="lsp-posMode"
|
||||
value="SIGNAL_ONLY"
|
||||
checked={settings.positionMode === 'SIGNAL_ONLY'}
|
||||
disabled={!isOn}
|
||||
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
|
||||
/>
|
||||
<span className="lsp-radio-label">
|
||||
<span className="lsp-radio-title">순수 지표 기준</span>
|
||||
<span className="lsp-radio-desc">포지션 무관, 지표 충족 시 즉시 매도</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOn && (
|
||||
<div className="lsp-status">
|
||||
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
|
||||
{stratId
|
||||
? `${strategies.find(s => s.id === stratId)?.name ?? '전략'} · 관심 ${monCount}종목`
|
||||
: '전략을 선택하세요'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveStrategyPanel;
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 로그인 모달 — 기본값 admin / admin
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { loginUser, type LoginResponse } from '../utils/backendApi';
|
||||
|
||||
const DEFAULT_USERNAME = 'admin';
|
||||
const DEFAULT_PASSWORD = 'admin';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (session: LoginResponse) => void;
|
||||
}
|
||||
|
||||
const LoginModal: React.FC<Props> = ({ open, onClose, onSuccess }) => {
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setUsername(DEFAULT_USERNAME);
|
||||
setPassword(DEFAULT_PASSWORD);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await loginUser(username.trim(), password);
|
||||
if (!res) {
|
||||
setError('로그인에 실패했습니다. 아이디와 비밀번호를 확인하세요.');
|
||||
return;
|
||||
}
|
||||
onSuccess(res);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="login-modal"
|
||||
role="dialog"
|
||||
aria-labelledby="login-modal-title"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="login-modal-header">
|
||||
<h2 id="login-modal-title">로그인</h2>
|
||||
<button type="button" className="login-modal-close" onClick={onClose} aria-label="닫기">×</button>
|
||||
</div>
|
||||
<p className="login-modal-hint">
|
||||
로그인 시 동일 계정의 설정이 모든 기기에서 공유됩니다. 로그인하지 않으면 이 브라우저(기기)별로 설정이 저장됩니다.
|
||||
</p>
|
||||
<form className="login-modal-form" onSubmit={submit}>
|
||||
<label className="login-modal-field">
|
||||
<span>아이디</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
<label className="login-modal-field">
|
||||
<span>비밀번호</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
className="login-modal-field--visible"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
{error && <p className="login-modal-error">{error}</p>}
|
||||
<div className="login-modal-actions">
|
||||
<button type="button" className="login-modal-btn login-modal-btn--ghost" onClick={onClose} disabled={loading}>
|
||||
취소
|
||||
</button>
|
||||
<button type="submit" className="login-modal-btn login-modal-btn--primary" disabled={loading}>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* MainChartSettingsModal
|
||||
* 메인 차트(캔들스틱) 설정 다이얼로그 — TradingView Settings 와 유사.
|
||||
* 탭: Symbol (캔들 색상) / 스타일 / 가시성 (미래 확장용)
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import type { MainChartStyle } from '../types';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
interface Props {
|
||||
style: MainChartStyle;
|
||||
onSave: (style: MainChartStyle) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'symbol';
|
||||
|
||||
const TABS: { id: Tab; label: string }[] = [
|
||||
{ id: 'symbol', label: '심볼 (Symbol)' },
|
||||
];
|
||||
|
||||
const ColorSwatch: React.FC<{ value: string; onChange: (v: string) => void }> = ({ value, onChange }) => {
|
||||
const hex6 = value.slice(0, 7);
|
||||
return (
|
||||
<label className="mcs-swatch" title={hex6}>
|
||||
<input
|
||||
type="color"
|
||||
value={hex6}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
<span style={{ background: hex6 }} />
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
const MainChartSettingsModal: React.FC<Props> = ({ style, onSave, onCancel }) => {
|
||||
const [tab, setTab] = useState<Tab>('symbol');
|
||||
const [draft, setDraft] = useState<MainChartStyle>({ ...style });
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
const set = (key: keyof MainChartStyle) => (v: string) =>
|
||||
setDraft(prev => ({ ...prev, [key]: v }));
|
||||
|
||||
const handleOk = () => onSave(draft);
|
||||
|
||||
const handleDefaults = () => setDraft({
|
||||
upColor: '#ff6b6b',
|
||||
downColor: '#4dabf7',
|
||||
borderUpColor: '#ff6b6b',
|
||||
borderDownColor: '#4dabf7',
|
||||
wickUpColor: '#ff6b6b',
|
||||
wickDownColor: '#4dabf7',
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mcs-overlay" onMouseDown={e => e.target === e.currentTarget && onCancel()}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="mcs-dialog"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 3001,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title">차트 설정 (Settings)</span>
|
||||
<button type="button" className="gc-popup-close" onClick={onCancel}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="mcs-tabs">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`mcs-tab${tab === t.id ? ' active' : ''}`}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mcs-body">
|
||||
{tab === 'symbol' && (
|
||||
<>
|
||||
<div className="mcs-section-label">캔들 (CANDLES)</div>
|
||||
<div className="mcs-row">
|
||||
<span className="mcs-row-label">상승 (Up)</span>
|
||||
<div className="mcs-row-colors">
|
||||
<ColorSwatch value={draft.upColor} onChange={set('upColor')} />
|
||||
<ColorSwatch value={draft.borderUpColor} onChange={set('borderUpColor')} />
|
||||
<ColorSwatch value={draft.wickUpColor} onChange={set('wickUpColor')} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mcs-row">
|
||||
<span className="mcs-row-label">하락 (Down)</span>
|
||||
<div className="mcs-row-colors">
|
||||
<ColorSwatch value={draft.downColor} onChange={set('downColor')} />
|
||||
<ColorSwatch value={draft.borderDownColor} onChange={set('borderDownColor')} />
|
||||
<ColorSwatch value={draft.wickDownColor} onChange={set('wickDownColor')} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mcs-footer">
|
||||
<button className="mcs-btn-defaults" onClick={handleDefaults}>기본값</button>
|
||||
<div className="mcs-footer-right">
|
||||
<button className="mcs-btn-cancel" onClick={onCancel}>취소</button>
|
||||
<button className="mcs-btn-ok" onClick={handleOk}>확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainChartSettingsModal;
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 좌측 마켓 패널
|
||||
*
|
||||
* 탭 구성:
|
||||
* 원화 - KRW 마켓 목록, 현재가(KRW)
|
||||
* BTC - KRW 마켓 목록, 현재가(USD 환산)
|
||||
* 보유 - 보유 종목 목록 (localStorage 관리)
|
||||
* 관심 - 즐겨찾기 목록 (localStorage 관리)
|
||||
*/
|
||||
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
|
||||
import type { TickerData, ChangeDir } from '../hooks/useMarketTicker';
|
||||
import {
|
||||
getFavorites, saveFavorites, toggleFavorite,
|
||||
getHoldings, addHolding, removeHolding,
|
||||
} from '../utils/marketStorage';
|
||||
// ─── 타입 ──────────────────────────────────────────────────────────────────
|
||||
type Tab = 'krw' | 'btc' | 'hold' | 'fav';
|
||||
type SortKey = 'name' | 'price' | 'change';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
export interface MarketPanelProps {
|
||||
open: boolean;
|
||||
tickers: Map<string, TickerData>;
|
||||
loading: boolean;
|
||||
usdRate: number; // KRW per 1 USD (from KRW-USDT)
|
||||
activeSymbol: string;
|
||||
onSymbolSelect: (symbol: string) => void;
|
||||
}
|
||||
|
||||
// ─── 가격 포맷 헬퍼 ────────────────────────────────────────────────────────
|
||||
function fmtKrw(price: number | null | undefined): string {
|
||||
if (price == null || !isFinite(price)) return '-';
|
||||
if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
if (price >= 0.01) return price.toFixed(4);
|
||||
return price.toFixed(8);
|
||||
}
|
||||
|
||||
function fmtUsd(krwPrice: number | null | undefined, rate: number): string {
|
||||
if (krwPrice == null || !isFinite(krwPrice) || !rate) return '-';
|
||||
const usd = krwPrice / rate;
|
||||
if (usd >= 10_000) return '$' + usd.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||
if (usd >= 1) return '$' + usd.toFixed(2);
|
||||
if (usd >= 0.001) return '$' + usd.toFixed(4);
|
||||
return '$' + usd.toFixed(6);
|
||||
}
|
||||
|
||||
function fmtPct(rate: number | null | undefined): string {
|
||||
if (rate == null || !isFinite(rate)) return '-';
|
||||
const sign = rate >= 0 ? '+' : '';
|
||||
return `${sign}${(rate * 100).toFixed(2)}%`;
|
||||
}
|
||||
|
||||
// ─── 방향 지시자 삼각형 ────────────────────────────────────────────────────
|
||||
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {
|
||||
if (change === 'RISE') return <span className="mp-arrow mp-arrow--rise">▲</span>;
|
||||
if (change === 'FALL') return <span className="mp-arrow mp-arrow--fall">▼</span>;
|
||||
return <span className="mp-arrow mp-arrow--even">─</span>;
|
||||
});
|
||||
|
||||
// ─── 개별 행 ───────────────────────────────────────────────────────────────
|
||||
interface RowProps {
|
||||
ticker: TickerData | undefined;
|
||||
market: string;
|
||||
isActive: boolean;
|
||||
isFav: boolean;
|
||||
isHold: boolean;
|
||||
showUsd: boolean;
|
||||
showHoldBtn: boolean;
|
||||
usdRate: number;
|
||||
onSelect: () => void;
|
||||
onToggleFav: (e: React.MouseEvent) => void;
|
||||
onToggleHold: (e: React.MouseEvent) => void;
|
||||
onRemoveHold?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const MarketRow = memo(function MarketRow({
|
||||
ticker, market, isActive, isFav, isHold,
|
||||
showUsd, showHoldBtn, usdRate,
|
||||
onSelect, onToggleFav, onToggleHold, onRemoveHold,
|
||||
}: RowProps) {
|
||||
const change: ChangeDir = ticker?.change ?? 'EVEN';
|
||||
const code = market.replace(/^KRW-/, '');
|
||||
|
||||
// ── 가격 변동 플래시 (frontend_golden TickerItem 로직) ─────────────────
|
||||
const [flashClass, setFlashClass] = useState('');
|
||||
const prevPriceRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
const price = ticker?.tradePrice ?? null;
|
||||
if (price === null) return;
|
||||
const prev = prevPriceRef.current;
|
||||
if (prev !== null && prev !== price) {
|
||||
const cls = price > prev ? 'mp-row--flash-rise' : 'mp-row--flash-fall';
|
||||
setFlashClass(cls);
|
||||
const t = setTimeout(() => setFlashClass(''), 700);
|
||||
prevPriceRef.current = price;
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
prevPriceRef.current = price;
|
||||
}, [ticker?.tradePrice]);
|
||||
|
||||
const colorCls = change === 'RISE' ? 'mp-up' : change === 'FALL' ? 'mp-dn' : 'mp-even';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mp-row ${isActive ? 'mp-row--active' : ''} ${flashClass}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{/* 즐겨찾기 별 */}
|
||||
<button
|
||||
className={`mp-star ${isFav ? 'mp-star--on' : ''}`}
|
||||
onClick={onToggleFav}
|
||||
title={isFav ? '관심 해제' : '관심 등록'}
|
||||
>
|
||||
{isFav ? '★' : '☆'}
|
||||
</button>
|
||||
|
||||
{/* 이름 + 방향 지시자 */}
|
||||
<div className="mp-name">
|
||||
<span className="mp-korean-row">
|
||||
<ChangeArrow change={change} />
|
||||
<span className="mp-korean">{ticker?.koreanName ?? code}</span>
|
||||
</span>
|
||||
<span className="mp-code">{code}/KRW</span>
|
||||
</div>
|
||||
|
||||
{/* 현재가 */}
|
||||
<span className={`mp-price ${colorCls}`}>
|
||||
{ticker
|
||||
? (showUsd ? fmtUsd(ticker.tradePrice, usdRate) : fmtKrw(ticker.tradePrice))
|
||||
: '-'}
|
||||
</span>
|
||||
|
||||
{/* 전일대비 */}
|
||||
<span className={`mp-change ${colorCls}`}>
|
||||
{ticker ? fmtPct(ticker.changeRate) : '-'}
|
||||
</span>
|
||||
|
||||
{/* 보유 토글 / 제거 버튼 */}
|
||||
{onRemoveHold ? (
|
||||
<button className="mp-hold-rm" onClick={onRemoveHold} title="보유 종목 제거">×</button>
|
||||
) : showHoldBtn ? (
|
||||
<button
|
||||
className={`mp-hold-btn ${isHold ? 'mp-hold-btn--on' : ''}`}
|
||||
onClick={onToggleHold}
|
||||
title={isHold ? '보유 해제' : '보유 등록'}
|
||||
>
|
||||
{isHold ? '₩' : '+₩'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// ─── 메인 패널 ─────────────────────────────────────────────────────────────
|
||||
export const MarketPanel: React.FC<MarketPanelProps> = ({
|
||||
open, tickers, loading, usdRate, activeSymbol, onSymbolSelect,
|
||||
}) => {
|
||||
const [tab, setTab] = useState<Tab>('krw');
|
||||
const [search, setSearch] = useState('');
|
||||
const [favorites, setFavs] = useState<string[]>(getFavorites);
|
||||
const [holdings, setHolds] = useState<string[]>(getHoldings);
|
||||
const [holdSearch, setHoldSrch] = useState('');
|
||||
const [showHoldAdd, setShowHoldAdd] = useState(false);
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 패널 열릴 때 검색창 포커스
|
||||
useEffect(() => { if (open) setTimeout(() => searchRef.current?.focus(), 250); }, [open]);
|
||||
|
||||
// 검색 초기화 (탭 전환 시)
|
||||
const handleTab = useCallback((t: Tab) => { setTab(t); setSearch(''); }, []);
|
||||
|
||||
// 컬럼 정렬 토글
|
||||
const handleSort = useCallback((key: SortKey) => {
|
||||
setSortKey(prev => {
|
||||
if (prev === key) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
return key;
|
||||
}
|
||||
setSortDir('desc');
|
||||
return key;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 즐겨찾기 토글
|
||||
const handleToggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const row = tickers.get(market);
|
||||
try {
|
||||
const next = await toggleFavorite(market, { koreanName: row?.koreanName });
|
||||
setFavs(next);
|
||||
} catch (err) {
|
||||
console.warn('[MarketPanel] watchlist toggle failed', err);
|
||||
}
|
||||
}, [tickers]);
|
||||
|
||||
// 보유 토글
|
||||
const handleToggleHold = useCallback((market: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setHolds(prev =>
|
||||
prev.includes(market) ? removeHolding(market) : addHolding(market)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 보유 제거 (보유 탭)
|
||||
const handleRemoveHold = useCallback((market: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setHolds(removeHolding(market));
|
||||
}, []);
|
||||
|
||||
// 보유 추가 검색에서 선택
|
||||
const handleAddHolding = useCallback((market: string) => {
|
||||
setHolds(addHolding(market));
|
||||
setHoldSrch('');
|
||||
setShowHoldAdd(false);
|
||||
}, []);
|
||||
|
||||
// 탭별 마켓 목록 계산
|
||||
const allKrwMarkets = useMemo(() => {
|
||||
const keys = Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
|
||||
// 24h 거래대금 내림차순 정렬
|
||||
return keys.sort((a, b) =>
|
||||
(tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0)
|
||||
);
|
||||
}, [tickers]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let base: string[] = [];
|
||||
switch (tab) {
|
||||
case 'krw': case 'btc': base = allKrwMarkets; break;
|
||||
case 'hold': base = holdings.filter(m => tickers.has(m) || true); break;
|
||||
case 'fav': base = favorites; break;
|
||||
}
|
||||
if (search) {
|
||||
const q = search.toUpperCase();
|
||||
base = base.filter(m => {
|
||||
const code = m.replace(/^KRW-/, '');
|
||||
const name = tickers.get(m)?.koreanName ?? '';
|
||||
return code.includes(q) || name.includes(search);
|
||||
});
|
||||
}
|
||||
if (!sortKey) return base;
|
||||
const mul = sortDir === 'asc' ? 1 : -1;
|
||||
return [...base].sort((a, b) => {
|
||||
const ta = tickers.get(a);
|
||||
const tb = tickers.get(b);
|
||||
switch (sortKey) {
|
||||
case 'name': {
|
||||
const na = ta?.koreanName ?? a.replace(/^KRW-/, '');
|
||||
const nb = tb?.koreanName ?? b.replace(/^KRW-/, '');
|
||||
return mul * na.localeCompare(nb, 'ko');
|
||||
}
|
||||
case 'price':
|
||||
return mul * ((ta?.tradePrice ?? 0) - (tb?.tradePrice ?? 0));
|
||||
case 'change':
|
||||
return mul * ((ta?.changeRate ?? 0) - (tb?.changeRate ?? 0));
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir]);
|
||||
|
||||
// 보유 탭 "추가" 검색 결과
|
||||
const holdAddResults = useMemo(() => {
|
||||
if (!holdSearch) return [];
|
||||
const q = holdSearch.toUpperCase();
|
||||
return allKrwMarkets
|
||||
.filter(m => {
|
||||
const code = m.replace(/^KRW-/, '');
|
||||
const name = tickers.get(m)?.koreanName ?? '';
|
||||
return code.includes(q) || name.includes(holdSearch);
|
||||
})
|
||||
.slice(0, 10);
|
||||
}, [holdSearch, allKrwMarkets, tickers]);
|
||||
|
||||
const tabLabels: Record<Tab, string> = {
|
||||
krw: '원화', btc: 'BTC', hold: '보유', fav: '관심',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`market-panel ${open ? 'market-panel--open' : ''}`}>
|
||||
{/* ── 패널 내용 ─────────────────────────────────────────────── */}
|
||||
<div className="mp-body mp-body--full">
|
||||
{/* 탭 헤더 */}
|
||||
<div className="mp-tabs">
|
||||
{(['krw', 'btc', 'hold', 'fav'] as Tab[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
className={`mp-tab ${tab === t ? 'mp-tab--active' : ''}`}
|
||||
onClick={() => handleTab(t)}
|
||||
>
|
||||
{tabLabels[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="mp-search-row">
|
||||
<div className="mp-search-box">
|
||||
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" opacity={0.5}>
|
||||
<circle cx="5.5" cy="5.5" r="4"/><line x1="9" y1="9" x2="12.5" y2="12.5"/>
|
||||
</svg>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="mp-search-input"
|
||||
type="text"
|
||||
placeholder="코인명/심볼검색"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button className="mp-search-clear" onClick={() => setSearch('')}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 헤더 */}
|
||||
<div className="mp-list-header">
|
||||
{(['name', 'price', 'change'] as SortKey[]).map(key => {
|
||||
const label = key === 'name' ? '한글명' : key === 'price' ? '현재가' : '전일대비';
|
||||
const cls = key === 'name' ? 'mp-h-name' : key === 'price' ? 'mp-h-price' : 'mp-h-change';
|
||||
const active = sortKey === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
className={`${cls} mp-h-sort-btn ${active ? 'mp-h-sort-btn--active' : ''}`}
|
||||
onClick={() => handleSort(key)}
|
||||
title={active ? (sortDir === 'asc' ? '내림차순 정렬' : '오름차순 정렬') : `${label} 정렬`}
|
||||
>
|
||||
{label}
|
||||
<span className="mp-sort-icon">
|
||||
{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 보유 탭: 추가 버튼 */}
|
||||
{tab === 'hold' && (
|
||||
<div className="mp-hold-add-area">
|
||||
{showHoldAdd ? (
|
||||
<div className="mp-hold-add-form">
|
||||
<input
|
||||
className="mp-hold-add-input"
|
||||
placeholder="종목 검색..."
|
||||
value={holdSearch}
|
||||
onChange={e => setHoldSrch(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="mp-hold-add-cancel" onClick={() => { setShowHoldAdd(false); setHoldSrch(''); }}>취소</button>
|
||||
{holdAddResults.length > 0 && (
|
||||
<div className="mp-hold-add-dropdown">
|
||||
{holdAddResults.map(m => (
|
||||
<div key={m} className="mp-hold-add-item" onClick={() => handleAddHolding(m)}>
|
||||
<span>{tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')}</span>
|
||||
<span className="mp-hold-add-code">{m.replace(/^KRW-/, '')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button className="mp-hold-add-btn" onClick={() => setShowHoldAdd(true)}>
|
||||
+ 보유 종목 추가
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마켓 목록 */}
|
||||
<div className="mp-list">
|
||||
{loading && (
|
||||
<div className="mp-loading">
|
||||
<div className="loading-spinner" style={{ width: 18, height: 18 }} />
|
||||
<span>마켓 정보 로딩 중...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.length === 0 && (
|
||||
<div className="mp-empty">
|
||||
{tab === 'hold' ? '보유 종목이 없습니다' :
|
||||
tab === 'fav' ? '관심 종목이 없습니다' :
|
||||
'검색 결과 없음'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filtered.map(market => (
|
||||
<MarketRow
|
||||
key={market}
|
||||
market={market}
|
||||
ticker={tickers.get(market)}
|
||||
isActive={market === activeSymbol}
|
||||
isFav={favorites.includes(market)}
|
||||
isHold={holdings.includes(market)}
|
||||
showUsd={tab === 'btc'}
|
||||
showHoldBtn={tab === 'krw' || tab === 'btc' || tab === 'fav'}
|
||||
usdRate={usdRate}
|
||||
onSelect={() => onSymbolSelect(market)}
|
||||
onToggleFav={e => handleToggleFav(market, e)}
|
||||
onToggleHold={e => handleToggleHold(market, e)}
|
||||
onRemoveHold={tab === 'hold' ? e => handleRemoveHold(market, e) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketPanel;
|
||||
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { setMarketNames } from '../utils/marketNameCache';
|
||||
import {
|
||||
getFavorites,
|
||||
toggleFavorite,
|
||||
FAVORITES_CHANGED_EVENT,
|
||||
} from '../utils/marketStorage';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
interface MarketInfo {
|
||||
market: string; // "KRW-BTC"
|
||||
korean_name: string; // "비트코인"
|
||||
english_name: string; // "Bitcoin"
|
||||
symbol: string; // "BTC"
|
||||
}
|
||||
|
||||
interface TickerInfo {
|
||||
trade_price: number;
|
||||
signed_change_rate: number; // 전일 대비 변화율 (소수, e.g. 0.015 = +1.5%)
|
||||
acc_trade_price_24h: number; // 24h 거래대금 (KRW)
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
const UPBIT_API = import.meta.env.DEV ? '/upbit-api/v1' : 'https://api.upbit.com/v1';
|
||||
const TICKER_TTL = 3000; // 3초마다 시세 갱신
|
||||
|
||||
function formatVol(vol: number): string {
|
||||
if (vol >= 1e12) return `${(vol / 1e12).toFixed(1)}조`;
|
||||
if (vol >= 1e8) return `${(vol / 1e8).toFixed(0)}억`;
|
||||
if (vol >= 1e4) return `${(vol / 1e4).toFixed(0)}만`;
|
||||
return vol.toLocaleString();
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
interface MarketSearchPanelProps {
|
||||
currentMarket: string;
|
||||
onSelect: (market: string) => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* 슬롯 내 심볼 버튼 클릭 시 전달되는 슬롯 컨테이너 rect.
|
||||
* 제공되면 해당 슬롯 영역 안에서 패널이 표시된다.
|
||||
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
|
||||
*/
|
||||
anchorRect?: DOMRect | null;
|
||||
/** 외부 입력과 검색어 동기화 */
|
||||
initialQuery?: string;
|
||||
}
|
||||
|
||||
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
|
||||
}) => {
|
||||
const [markets, setMarkets] = useState<MarketInfo[]>([]);
|
||||
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refreshFavs = useCallback(() => {
|
||||
setFavs(new Set(getFavorites()));
|
||||
}, []);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const tickerTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const marketCodesRef = useRef<string>('');
|
||||
|
||||
// ── 1. 마켓 목록 로드 ─────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`${UPBIT_API}/market/all?isDetails=false`)
|
||||
.then(r => r.json())
|
||||
.then((data: Array<{ market: string; korean_name: string; english_name: string }>) => {
|
||||
const krw = data
|
||||
.filter(m => m.market.startsWith('KRW-'))
|
||||
.map(m => ({ ...m, symbol: m.market.replace('KRW-', '') }));
|
||||
setMarkets(krw);
|
||||
marketCodesRef.current = krw.map(m => m.market).join(',');
|
||||
|
||||
// 한글명 캐시 저장 (Toolbar 등에서 사용)
|
||||
const nameMap: Record<string, string> = {};
|
||||
for (const m of krw) nameMap[m.market] = m.korean_name;
|
||||
setMarketNames(nameMap);
|
||||
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
refreshFavs();
|
||||
}, [refreshFavs]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(initialQuery);
|
||||
}, [initialQuery]);
|
||||
|
||||
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
|
||||
useEffect(() => {
|
||||
const onChanged = () => refreshFavs();
|
||||
window.addEventListener(FAVORITES_CHANGED_EVENT, onChanged);
|
||||
return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChanged);
|
||||
}, [refreshFavs]);
|
||||
|
||||
// ── 2. 시세 데이터 폴링 (100개씩 배치 요청) ─────────────────────────────
|
||||
const fetchTickers = useCallback(async () => {
|
||||
const codes = marketCodesRef.current;
|
||||
if (!codes) return;
|
||||
const allCodes = codes.split(',');
|
||||
const BATCH = 100;
|
||||
const results: Array<{ market: string } & TickerInfo> = [];
|
||||
|
||||
for (let i = 0; i < allCodes.length; i += BATCH) {
|
||||
const batch = allCodes.slice(i, i + BATCH).join(',');
|
||||
try {
|
||||
const data: Array<{ market: string } & TickerInfo> =
|
||||
await fetch(`${UPBIT_API}/ticker?markets=${batch}`).then(r => r.json());
|
||||
results.push(...data);
|
||||
} catch { /* ignore partial failures */ }
|
||||
}
|
||||
|
||||
setTickers(prev => {
|
||||
const map: Record<string, TickerInfo> = { ...prev };
|
||||
for (const t of results) map[t.market] = t;
|
||||
return map;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (markets.length === 0) return;
|
||||
fetchTickers();
|
||||
tickerTimer.current = setInterval(fetchTickers, TICKER_TTL);
|
||||
return () => { if (tickerTimer.current) clearInterval(tickerTimer.current); };
|
||||
}, [markets, fetchTickers]);
|
||||
|
||||
// ── 3. 즐겨찾기 토글 ─────────────────────────────────────────────────────
|
||||
const toggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const row = markets.find(m => m.market === market);
|
||||
try {
|
||||
const next = await toggleFavorite(market, {
|
||||
koreanName: row?.korean_name,
|
||||
englishName: row?.english_name,
|
||||
});
|
||||
setFavs(new Set(next));
|
||||
} catch (err) {
|
||||
console.warn('[MarketSearchPanel] watchlist toggle failed', err);
|
||||
}
|
||||
}, [markets]);
|
||||
|
||||
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
|
||||
const q = query.trim();
|
||||
const filtered = markets.filter(m => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
m.korean_name.includes(q) ||
|
||||
m.symbol.toUpperCase().includes(q.toUpperCase()) ||
|
||||
m.english_name.toLowerCase().includes(q.toLowerCase()) ||
|
||||
m.market.toUpperCase().includes(q.toUpperCase())
|
||||
);
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const fa = favs.has(a.market) ? 1 : 0;
|
||||
const fb = favs.has(b.market) ? 1 : 0;
|
||||
if (fb !== fa) return fb - fa;
|
||||
// 즐겨찾기 동순위: 24h 거래대금 내림차순
|
||||
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
|
||||
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
|
||||
return vb - va;
|
||||
});
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
// anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다.
|
||||
const HEADER_H = 30; // 슬롯 헤더 높이 (약 30px)
|
||||
const MIN_W = 260;
|
||||
|
||||
// LWC v5 canvas 레이어가 내부적으로 높은 z-index를 사용하므로
|
||||
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
|
||||
const PANEL_Z = 99999;
|
||||
|
||||
const panelStyle: React.CSSProperties | undefined = anchorRect
|
||||
? {
|
||||
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) 그대로 사용
|
||||
|
||||
// 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함
|
||||
const backdropStyle: React.CSSProperties | undefined = anchorRect
|
||||
? {
|
||||
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;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 배경 오버레이 (클릭 시 닫기) */}
|
||||
<div
|
||||
className="msp-backdrop"
|
||||
style={backdropStyle}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */}
|
||||
<div
|
||||
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}`}
|
||||
style={panelStyle}
|
||||
>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="msp-search-row">
|
||||
<span className="msp-search-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="msp-input"
|
||||
placeholder="종목명 또는 심볼 검색"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Enter' && sorted.length > 0) {
|
||||
onSelect(sorted[0].market);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button className="msp-btn-icon" title="초기화" onClick={() => setQuery('')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button className="msp-btn-icon" title="닫기" onClick={onClose}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 헤더 */}
|
||||
<div className="msp-header">
|
||||
<span className="msp-col msp-col-fav" />
|
||||
<span className="msp-col msp-col-name">한글명</span>
|
||||
<span className="msp-col msp-col-price">현재가</span>
|
||||
<span className="msp-col msp-col-change">전일대비</span>
|
||||
<span className="msp-col msp-col-vol">거래대금</span>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="msp-list">
|
||||
{loading && (
|
||||
<div className="msp-empty">마켓 목록 로딩 중...</div>
|
||||
)}
|
||||
{!loading && sorted.length === 0 && (
|
||||
<div className="msp-empty">검색 결과가 없습니다</div>
|
||||
)}
|
||||
{sorted.map(m => {
|
||||
const tk = tickers[m.market];
|
||||
const isFav = favs.has(m.market);
|
||||
const isActive = m.market === currentMarket;
|
||||
const rate = tk ? tk.signed_change_rate * 100 : null;
|
||||
const isUp = rate !== null && rate >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.market}
|
||||
className={`msp-item${isActive ? ' active' : ''}`}
|
||||
onClick={() => { onSelect(m.market); onClose(); }}
|
||||
>
|
||||
{/* 즐겨찾기 */}
|
||||
<span
|
||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||
onClick={e => toggleFav(m.market, e)}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
{isFav ? '★' : '☆'}
|
||||
</span>
|
||||
|
||||
{/* 이름 */}
|
||||
<span className="msp-col msp-col-name">
|
||||
<span className="msp-kor">{m.korean_name}</span>
|
||||
<span className="msp-sym">{m.symbol}</span>
|
||||
</span>
|
||||
|
||||
{/* 현재가 */}
|
||||
<span className="msp-col msp-col-price">
|
||||
{tk ? tk.trade_price.toLocaleString() : '-'}
|
||||
</span>
|
||||
|
||||
{/* 전일대비 */}
|
||||
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
|
||||
{rate === null
|
||||
? '-'
|
||||
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
|
||||
</span>
|
||||
|
||||
{/* 거래대금 */}
|
||||
<span className="msp-col msp-col-vol">
|
||||
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketSearchPanel;
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 모바일 차트 화면 하단 고정 도크 — 종목·매매·호가·그리기·차트 도구
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export type MobileDockPanel = 'none' | 'market' | 'trade' | 'orderbook';
|
||||
|
||||
interface Props {
|
||||
marketOpen: boolean;
|
||||
rightOpen: boolean;
|
||||
rightTab: 'trade' | 'orderbook';
|
||||
drawingTool: string;
|
||||
onToggleMarket: () => void;
|
||||
onOpenTrade: () => void;
|
||||
onOpenOrderbook: () => void;
|
||||
onClosePanels: () => void;
|
||||
onTool: (tool: string) => void;
|
||||
}
|
||||
|
||||
const MobileChartDock: React.FC<Props> = ({
|
||||
marketOpen,
|
||||
rightOpen,
|
||||
rightTab,
|
||||
drawingTool,
|
||||
onToggleMarket,
|
||||
onOpenTrade,
|
||||
onOpenOrderbook,
|
||||
onClosePanels,
|
||||
onTool,
|
||||
}) => {
|
||||
const tradeActive = rightOpen && rightTab === 'trade';
|
||||
const obActive = rightOpen && rightTab === 'orderbook';
|
||||
|
||||
return (
|
||||
<nav className="mobile-chart-dock" aria-label="차트 모바일 메뉴">
|
||||
<button
|
||||
type="button"
|
||||
className={`mcd-btn${marketOpen ? ' active' : ''}`}
|
||||
onClick={onToggleMarket}
|
||||
aria-pressed={marketOpen}
|
||||
>
|
||||
<span className="mcd-icon" aria-hidden>📋</span>
|
||||
<span className="mcd-label">종목</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mcd-btn${tradeActive ? ' active' : ''}`}
|
||||
onClick={onOpenTrade}
|
||||
aria-pressed={tradeActive}
|
||||
>
|
||||
<span className="mcd-icon" aria-hidden>💰</span>
|
||||
<span className="mcd-label">매매</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mcd-btn${obActive ? ' active' : ''}`}
|
||||
onClick={onOpenOrderbook}
|
||||
aria-pressed={obActive}
|
||||
>
|
||||
<span className="mcd-icon" aria-hidden>📊</span>
|
||||
<span className="mcd-label">호가</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mcd-btn${drawingTool === 'zoom' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
onClosePanels();
|
||||
onTool(drawingTool === 'zoom' ? 'cursor' : 'zoom');
|
||||
}}
|
||||
aria-pressed={drawingTool === 'zoom'}
|
||||
title="영역 드래그로 차트 확대"
|
||||
>
|
||||
<span className="mcd-icon" aria-hidden>🔍</span>
|
||||
<span className="mcd-label">확대</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`mcd-btn${drawingTool === 'cursor' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
onClosePanels();
|
||||
onTool('cursor');
|
||||
}}
|
||||
aria-pressed={drawingTool === 'cursor'}
|
||||
>
|
||||
<span className="mcd-icon" aria-hidden>👆</span>
|
||||
<span className="mcd-label">이동</span>
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileChartDock;
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 알림 Context 연동 UI (Provider 하위에서만 사용)
|
||||
*/
|
||||
import React from 'react';
|
||||
import TopMenuBar, { type MenuPage } from './TopMenuBar';
|
||||
import type { Theme } from '../types';
|
||||
import type { AuthSession } from '../utils/auth';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
import { useTradeNotification } from '../contexts/TradeNotificationContext';
|
||||
|
||||
interface NotifyTopMenuBarProps {
|
||||
activePage: MenuPage;
|
||||
theme: Theme;
|
||||
onPage: (page: MenuPage) => void;
|
||||
onTheme: () => void;
|
||||
authUser?: AuthSession | null;
|
||||
onLoginClick?: () => void;
|
||||
onLogout?: () => void;
|
||||
menuPermissions?: Record<string, boolean>;
|
||||
guestMode?: boolean;
|
||||
tradeAlertPopupPosition?: string;
|
||||
tradeAlertPopupLayout?: string;
|
||||
tradeAlertPopupGridCols?: number;
|
||||
onTradeAlertPopupPosition?: (v: TradeAlertPopupPosition) => void;
|
||||
onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void;
|
||||
onTradeAlertPopupGridCols?: (v: number) => void;
|
||||
}
|
||||
|
||||
export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
|
||||
const { unreadCount, toastNotifications, dismissAllToasts } = useTradeNotification();
|
||||
return (
|
||||
<TopMenuBar
|
||||
{...props}
|
||||
tradeNotifyUnread={unreadCount}
|
||||
tradeNotifyToastCount={toastNotifications.length}
|
||||
onOpenNotifications={() => props.onPage('notifications')}
|
||||
onDismissAllNotifyPopups={dismissAllToasts}
|
||||
tradeAlertPopupPosition={props.tradeAlertPopupPosition}
|
||||
tradeAlertPopupLayout={props.tradeAlertPopupLayout}
|
||||
tradeAlertPopupGridCols={props.tradeAlertPopupGridCols}
|
||||
onTradeAlertPopupPosition={props.onTradeAlertPopupPosition}
|
||||
onTradeAlertPopupLayout={props.onTradeAlertPopupLayout}
|
||||
onTradeAlertPopupGridCols={props.onTradeAlertPopupGridCols}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
import Toolbar, { type ToolbarProps } from './Toolbar';
|
||||
|
||||
export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: () => void }> = ({
|
||||
onOpenNotifyList,
|
||||
...toolbarProps
|
||||
}) => {
|
||||
const { unreadCount } = useTradeNotification();
|
||||
return (
|
||||
<Toolbar
|
||||
{...toolbarProps}
|
||||
tradeNotifyUnread={unreadCount}
|
||||
onOpenTradeNotifications={onOpenNotifyList}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { NumericParamSpec } from '../utils/indicatorParamSpec';
|
||||
|
||||
interface NumericParamInputProps {
|
||||
value: number;
|
||||
spec: NumericParamSpec;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
function formatOption(v: number, decimal: boolean): string {
|
||||
return decimal
|
||||
? (Number.isInteger(v) ? String(v) : v.toFixed(2).replace(/\.?0+$/, ''))
|
||||
: String(Math.round(v));
|
||||
}
|
||||
|
||||
/** 입력 문자열을 숫자만(선택적 -, 소수점) 허용하도록 필터 */
|
||||
function filterTyping(raw: string, allowNegative: boolean, decimal: boolean): string {
|
||||
let s = raw.replace(/[^\d.\-]/g, '');
|
||||
if (!allowNegative) {
|
||||
s = s.replace(/-/g, '');
|
||||
} else {
|
||||
const first = s.startsWith('-') ? '-' : '';
|
||||
s = first + s.slice(first ? 1 : 0).replace(/-/g, '');
|
||||
}
|
||||
if (!decimal) {
|
||||
s = s.replace(/\./g, '');
|
||||
} else {
|
||||
const parts = s.replace('-', '').split('.');
|
||||
if (parts.length > 2) {
|
||||
s = (s.startsWith('-') ? '-' : '') + parts[0] + '.' + parts.slice(1).join('');
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function isPartialNumber(s: string, allowNegative: boolean, decimal: boolean): boolean {
|
||||
if (s === '' || s === '-') return true;
|
||||
if (decimal && (s === '.' || s === '-.')) return true;
|
||||
if (decimal) {
|
||||
return allowNegative ? /^-?\d*\.?\d*$/.test(s) : /^\d*\.?\d*$/.test(s);
|
||||
}
|
||||
return allowNegative ? /^-?\d*$/.test(s) : /^\d*$/.test(s);
|
||||
}
|
||||
|
||||
const NumericParamInput: React.FC<NumericParamInputProps> = ({
|
||||
value,
|
||||
spec,
|
||||
disabled = false,
|
||||
className = 'ism-input',
|
||||
onChange,
|
||||
}) => {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [str, setStr] = useState(() => formatOption(value, spec.decimal));
|
||||
const [open, setOpen] = useState(false);
|
||||
const prevValueRef = useRef(value);
|
||||
const editingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRef.current) return;
|
||||
if (value !== prevValueRef.current) {
|
||||
prevValueRef.current = value;
|
||||
setStr(formatOption(value, spec.decimal));
|
||||
}
|
||||
}, [value, spec.decimal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (wrapRef.current?.contains(e.target as Node)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const tid = window.setTimeout(() => {
|
||||
document.addEventListener('mousedown', onDoc, true);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(tid);
|
||||
document.removeEventListener('mousedown', onDoc, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const commit = useCallback((raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '' || trimmed === '-' || trimmed === '-.') {
|
||||
setStr(formatOption(prevValueRef.current, spec.decimal));
|
||||
return;
|
||||
}
|
||||
const v = parseFloat(trimmed);
|
||||
if (isNaN(v)) {
|
||||
setStr(formatOption(prevValueRef.current, spec.decimal));
|
||||
return;
|
||||
}
|
||||
let clamped = Math.max(spec.min, Math.min(spec.max, v));
|
||||
if (!spec.decimal) clamped = Math.round(clamped);
|
||||
else clamped = roundTo(clamped, spec.step < 1 ? 2 : 1);
|
||||
prevValueRef.current = clamped;
|
||||
onChange(clamped);
|
||||
setStr(formatOption(clamped, spec.decimal));
|
||||
}, [spec, onChange]);
|
||||
|
||||
const handleChange = (raw: string) => {
|
||||
const filtered = filterTyping(raw, spec.allowNegative, spec.decimal);
|
||||
if (!isPartialNumber(filtered, spec.allowNegative, spec.decimal)) return;
|
||||
setStr(filtered);
|
||||
};
|
||||
|
||||
const selectOption = (opt: number) => {
|
||||
prevValueRef.current = opt;
|
||||
onChange(opt);
|
||||
setStr(formatOption(opt, spec.decimal));
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const toggleList = () => {
|
||||
if (disabled) return;
|
||||
setOpen(prev => !prev);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ism-num-combo"
|
||||
ref={wrapRef}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onPointerDown={e => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode={spec.decimal ? 'decimal' : 'numeric'}
|
||||
className={className}
|
||||
value={str}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
editingRef.current = true;
|
||||
}}
|
||||
onBlur={e => {
|
||||
const related = e.relatedTarget as Node | null;
|
||||
window.setTimeout(() => {
|
||||
if (document.activeElement === inputRef.current) return;
|
||||
if (related && wrapRef.current?.contains(related)) return;
|
||||
editingRef.current = false;
|
||||
commit(e.target.value);
|
||||
setOpen(false);
|
||||
}, 0);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'ArrowDown' && !open) {
|
||||
e.preventDefault();
|
||||
if (!disabled) setOpen(true);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
commit((e.target as HTMLInputElement).value);
|
||||
setOpen(false);
|
||||
}
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="ism-num-combo-toggle"
|
||||
tabIndex={-1}
|
||||
disabled={disabled}
|
||||
aria-label="기간 목록"
|
||||
aria-expanded={open}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
toggleList();
|
||||
}}
|
||||
>
|
||||
▾
|
||||
</button>
|
||||
{open && !disabled && (
|
||||
<ul className="ism-num-combo-list" role="listbox">
|
||||
{spec.options.map(opt => (
|
||||
<li key={opt}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ism-num-combo-opt${opt === value ? ' selected' : ''}`}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
selectOption(opt);
|
||||
}}
|
||||
>
|
||||
{formatOption(opt, spec.decimal)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function roundTo(v: number, decimals: number): number {
|
||||
const f = 10 ** decimals;
|
||||
return Math.round(v * f) / f;
|
||||
}
|
||||
|
||||
export default NumericParamInput;
|
||||
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import type { Drawing, DrawingToolId } from '../types';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
interface ObjectTreeProps {
|
||||
drawings: Drawing[];
|
||||
onToggleVisible: (id: string) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 드로잉 타입 → 한글 이름
|
||||
const TOOL_LABEL: Partial<Record<DrawingToolId, string>> = {
|
||||
cursor: '커서',
|
||||
hline: '수평선',
|
||||
hline_ray: '수평 레이',
|
||||
vline: '수직선',
|
||||
cross_line: '십자선',
|
||||
info_line: '정보선',
|
||||
trendline: '추세선',
|
||||
extended_line: '연장 추세선',
|
||||
ray: '레이',
|
||||
arrow: '화살표 추세선',
|
||||
fib: '피보나치 되돌림',
|
||||
fib_ext: '피보나치 확장',
|
||||
fib_channel: '피보나치 채널',
|
||||
fibtz: '피보나치 타임존',
|
||||
fib_speed_fan: '스피드 리지스턴스 팬',
|
||||
fib_trend_time: '추세기반 피보나치 시간',
|
||||
fib_circles: '피보나치 서클',
|
||||
fib_spiral: '피보나치 스파이럴',
|
||||
fib_speed_arc: '스피드 리지스턴스 아크',
|
||||
fib_wedge: '피보나치 웻지',
|
||||
pitchfork: '앤드류스 피치포크',
|
||||
pitchfork_schiff: '쉬프 피치포크',
|
||||
pitchfork_inside: '인사이드 피치포크',
|
||||
gann_box: '갠 박스',
|
||||
gann_square: '갠 스퀘어',
|
||||
gann_fan: '갠 팬',
|
||||
xabcd: 'XABCD 패턴',
|
||||
cypher: '사이퍼 패턴',
|
||||
abcd: 'ABCD 패턴',
|
||||
head_shoulders: '헤드 앤 숄더',
|
||||
channel: '추세 채널',
|
||||
parallel_channel: '평행 채널',
|
||||
disjoint_channel: '분리 채널',
|
||||
flat_top_bottom: '플랫 탑/바텀 채널',
|
||||
rect: '사각형',
|
||||
rotated_rect: '회전 사각형',
|
||||
circle: '원',
|
||||
ellipse: '타원',
|
||||
triangle: '삼각형',
|
||||
parallelogram: '평행사변형',
|
||||
polyline: '다각형',
|
||||
arc: '호',
|
||||
text: '텍스트',
|
||||
callout: '콜아웃',
|
||||
price_note: '가격 주석',
|
||||
arrow_mark_up: '위쪽 화살표',
|
||||
arrow_mark_down: '아래쪽 화살표',
|
||||
price_label: '가격 레이블',
|
||||
pencil: '자유곡선',
|
||||
highlight: '형광펜',
|
||||
trend_angle: '추세각',
|
||||
long_position: '매수 포지션',
|
||||
short_position: '숏 포지션',
|
||||
forecast: '예측',
|
||||
bar_pattern: '봉패턴',
|
||||
ghost_feed: '고스트피드',
|
||||
projection_tool: '프로젝션',
|
||||
anchored_vwap: '앵커드 VWAP',
|
||||
fixed_volume_profile: '볼륨 프로파일',
|
||||
price_range: '가격범위',
|
||||
wedge_pattern: '세모 패턴',
|
||||
three_drives: '쓰리 드라이브 패턴',
|
||||
elliott_impulse: '엘리엇 임펄스 파동',
|
||||
elliott_correction: '엘리엇 코렉션 파동',
|
||||
elliott_triangle_wave: '엘리엇 트라이앵글 웨이브',
|
||||
elliott_double_combo: '엘리엇 다블콤보 파동',
|
||||
arrow_mark: '화살표',
|
||||
anchored_arrow: '앵커 화살표',
|
||||
arrow_mark_left: '왼화살표',
|
||||
arrow_mark_right: '오른화살표',
|
||||
fixed_text: '고정위치문자',
|
||||
note_text: '노트',
|
||||
pin_mark: '핀',
|
||||
table_tool: '테이블',
|
||||
comment_mark: '코멘트',
|
||||
guide_line: '길잡이',
|
||||
flag_mark: '플래그',
|
||||
measure: '가격 측정',
|
||||
date_range: '날짜 범위',
|
||||
date_price_range: '날짜&가격 범위',
|
||||
data_window: '데이터 창',
|
||||
zoom: '확대/축소',
|
||||
magnifier: '돋보기',
|
||||
};
|
||||
|
||||
// 아이콘 기본값
|
||||
const DefaultIcon = () => <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><circle cx="6" cy="6" r="4"/></svg>;
|
||||
|
||||
// 타입별 아이콘
|
||||
const ToolIcon: Partial<Record<DrawingToolId, React.ReactNode>> = {
|
||||
cursor: <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><path d="M2 1 L2 9 L4 7 L6 10 L7 9.5 L5 6.5 L8 6.5 Z"/></svg>,
|
||||
hline: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="1" y1="6" x2="11" y2="6"/></svg>,
|
||||
vline: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="6" y1="1" x2="6" y2="11"/></svg>,
|
||||
trendline: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="2" y1="10" x2="10" y2="2"/></svg>,
|
||||
ray: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="2" y1="10" x2="11" y2="2"/><circle cx="2" cy="10" r="1.2" fill="currentColor"/></svg>,
|
||||
fib: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1"><line x1="1" y1="2" x2="11" y2="2"/><line x1="1" y1="5" x2="11" y2="5" strokeOpacity="0.6"/><line x1="1" y1="7" x2="11" y2="7"/><line x1="1" y1="10" x2="11" y2="10"/></svg>,
|
||||
fibtz: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1"><line x1="1" y1="1" x2="1" y2="11"/><line x1="3" y1="1" x2="3" y2="11" strokeOpacity="0.7"/><line x1="6" y1="1" x2="6" y2="11" strokeOpacity="0.5"/><line x1="9" y1="1" x2="9" y2="11" strokeOpacity="0.6"/><line x1="11" y1="1" x2="11" y2="11" strokeOpacity="0.4"/></svg>,
|
||||
rect: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><rect x="2" y="3" width="8" height="6"/></svg>,
|
||||
circle: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><circle cx="6" cy="6" r="4.5"/></svg>,
|
||||
triangle: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><path d="M6 2 L11 10 L1 10 Z"/></svg>,
|
||||
channel: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="1" y1="8" x2="11" y2="3"/><line x1="1" y1="11" x2="11" y2="6" strokeDasharray="2 2"/></svg>,
|
||||
measure: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="1" y1="6" x2="11" y2="6"/><line x1="1" y1="4" x2="1" y2="8"/><line x1="11" y1="4" x2="11" y2="8"/></svg>,
|
||||
text: <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><text x="2" y="10" fontSize="10" fontWeight="700" fontFamily="sans-serif">T</text></svg>,
|
||||
pencil: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><path d="M2 10 L2.5 7 L9 1.5 Q10 1 11 2 Q12 3 11 4 L4.5 10 Z"/></svg>,
|
||||
pitchfork: <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2"><line x1="2" y1="6" x2="7" y2="5"/><line x1="7" y1="5" x2="11" y2="3"/><line x1="7" y1="5" x2="11" y2="7"/></svg>,
|
||||
};
|
||||
|
||||
const IcEye = ({ visible }: { visible: boolean }) => visible ? (
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M1 6.5 Q4 2 6.5 2 Q9 2 12 6.5 Q9 11 6.5 11 Q4 11 1 6.5 Z"/>
|
||||
<circle cx="6.5" cy="6.5" r="1.8"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" strokeWidth="1.3">
|
||||
<path d="M1 6.5 Q4 2 6.5 2 Q9 2 12 6.5 Q9 11 6.5 11 Q4 11 1 6.5 Z" strokeOpacity="0.3"/>
|
||||
<line x1="2" y1="2" x2="11" y2="11"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcTrash = () => (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.2">
|
||||
<line x1="2" y1="4" x2="10" y2="4"/>
|
||||
<path d="M4.5 4 V2.5 Q4.5 2 6 2 Q7.5 2 7.5 2.5 V4"/>
|
||||
<path d="M3 4 L3.5 10.5 Q3.5 11 4 11 L8 11 Q8.5 11 8.5 10.5 L9 4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ObjectTree: React.FC<ObjectTreeProps> = ({ drawings, onToggleVisible, onRemove, onClose }) => {
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
<div className="obj-tree-overlay" onMouseDown={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="obj-tree-panel"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 5001,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header obj-tree-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title obj-tree-title">오브젝트 트리</span>
|
||||
<button type="button" className="gc-popup-close obj-tree-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
{drawings.length === 0 ? (
|
||||
<div className="obj-tree-empty">드로잉이 없습니다</div>
|
||||
) : (
|
||||
<div className="obj-tree-list">
|
||||
{[...drawings].reverse().map((d, idx) => {
|
||||
const visible = d.visible !== false;
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className={`obj-tree-item${!visible ? ' hidden' : ''}`}
|
||||
>
|
||||
{/* 색상 스와치 */}
|
||||
<span
|
||||
className="obj-tree-swatch"
|
||||
style={{ background: d.color }}
|
||||
/>
|
||||
|
||||
{/* 타입 아이콘 */}
|
||||
<span className="obj-tree-icon" style={{ color: d.color }}>
|
||||
{ToolIcon[d.type as DrawingToolId] ?? <DefaultIcon />}
|
||||
</span>
|
||||
|
||||
{/* 이름 */}
|
||||
<span className="obj-tree-name">
|
||||
{TOOL_LABEL[d.type as DrawingToolId] ?? d.type}
|
||||
{d.text && <span className="obj-tree-text-preview"> "{d.text}"</span>}
|
||||
</span>
|
||||
|
||||
{/* 번호 (역순) */}
|
||||
<span className="obj-tree-num">#{drawings.length - idx}</span>
|
||||
|
||||
{/* 가시성 토글 */}
|
||||
<button
|
||||
className="obj-tree-btn"
|
||||
title={visible ? '숨기기' : '표시'}
|
||||
onClick={() => onToggleVisible(d.id)}
|
||||
>
|
||||
<IcEye visible={visible} />
|
||||
</button>
|
||||
|
||||
{/* 삭제 */}
|
||||
<button
|
||||
className="obj-tree-btn obj-tree-del"
|
||||
title="삭제"
|
||||
onClick={() => onRemove(d.id)}
|
||||
>
|
||||
<IcTrash />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ObjectTree;
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 실시간 호가 — 증권앱형 통합 레이아웃
|
||||
* 상단 시세 · 서브탭 · 매도/현재가/매수 테이블 · 우측 시세 · 좌측 체결 · 하단 합계
|
||||
*/
|
||||
import React, { memo, useState, useEffect } from 'react';
|
||||
import { formatNowClock, useDisplayTimezone } from '../utils/timezone';
|
||||
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
|
||||
import type { RecentTrade } from '../hooks/useUpbitRecentTrades';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
|
||||
function fmtPrice(p: number): string {
|
||||
if (p == null || !isFinite(p)) return '-';
|
||||
if (p >= 1_000) return p.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (p >= 1) return p.toFixed(2);
|
||||
return p.toFixed(6);
|
||||
}
|
||||
|
||||
function fmtSize(s: number): string {
|
||||
if (s == null || !isFinite(s)) return '-';
|
||||
if (s >= 1_000_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
|
||||
if (s >= 1) return s.toFixed(4);
|
||||
return s.toFixed(6);
|
||||
}
|
||||
|
||||
function fmtTradeVol(v: number): string {
|
||||
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(2)}M`;
|
||||
if (v >= 1_000) return Math.round(v).toLocaleString('ko-KR');
|
||||
return v.toFixed(4);
|
||||
}
|
||||
|
||||
function fmtPct(price: number, prevClose: number): string {
|
||||
if (!prevClose) return '';
|
||||
const rate = ((price - prevClose) / prevClose) * 100;
|
||||
return `${rate >= 0 ? '+' : ''}${rate.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function fmtTotalSize(s: number): string {
|
||||
if (s >= 1_000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
|
||||
if (s >= 1) return s.toFixed(2);
|
||||
return s.toFixed(4);
|
||||
}
|
||||
|
||||
function coinCode(market: string): string {
|
||||
return market.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
function pctClass(pct: string): string {
|
||||
if (pct.startsWith('+')) return 'ob-td-pct--up';
|
||||
if (pct.startsWith('-')) return 'ob-td-pct--dn';
|
||||
return '';
|
||||
}
|
||||
|
||||
function useClock(): string {
|
||||
const tz = useDisplayTimezone();
|
||||
const [t, setT] = useState(() => formatNowClock(tz, true));
|
||||
useEffect(() => {
|
||||
const tick = () => setT(formatNowClock(tz, true));
|
||||
tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [tz]);
|
||||
return t;
|
||||
}
|
||||
|
||||
const COLGROUP = (
|
||||
<colgroup>
|
||||
<col className="ob-col-side" />
|
||||
<col className="ob-col-price" />
|
||||
<col className="ob-col-side" />
|
||||
</colgroup>
|
||||
);
|
||||
|
||||
const AskTableRow = memo(function AskTableRow({
|
||||
unit, prevClose, onClick,
|
||||
}: {
|
||||
unit: OrderbookDisplayUnit;
|
||||
prevClose: number;
|
||||
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
||||
}) {
|
||||
const pct = fmtPct(unit.price, prevClose);
|
||||
return (
|
||||
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'ask') : undefined}>
|
||||
<td className="ob-td ob-td-qty ob-td-qty--ask">
|
||||
<div className="ob-td-bar-wrap">
|
||||
<div className="ob-td-bar ob-td-bar--ask" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
|
||||
<td className={`ob-td ob-td-pct ${pctClass(pct)}`}>{pct}</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
/** 가격은 항상 가운데 열 — 매수: 전일대비(좌) | 가격 | 잔량(우) */
|
||||
const BidTableRow = memo(function BidTableRow({
|
||||
unit, prevClose, onClick,
|
||||
}: {
|
||||
unit: OrderbookDisplayUnit;
|
||||
prevClose: number;
|
||||
onClick?: (price: number, type: 'ask' | 'bid') => void;
|
||||
}) {
|
||||
const pct = fmtPct(unit.price, prevClose);
|
||||
return (
|
||||
<tr className={onClick ? 'ob-tr--clickable' : undefined} onClick={onClick ? () => onClick(unit.price, 'bid') : undefined}>
|
||||
<td className={`ob-td ob-td-pct ob-td-pct--left ${pctClass(pct)}`}>{pct}</td>
|
||||
<td className="ob-td ob-td-price">{fmtPrice(unit.price)}</td>
|
||||
<td className="ob-td ob-td-qty ob-td-qty--bid">
|
||||
<div className="ob-td-bar-wrap ob-td-bar-wrap--right">
|
||||
<div className="ob-td-bar ob-td-bar--bid" style={{ width: `${Math.min(unit.percentage, 100)}%` }} />
|
||||
<span className="ob-td-bar-text">{fmtSize(unit.size)}</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
function QuoteHeader({
|
||||
market, tickerInfo, prevClose, totalAskSize, totalBidSize, wsStatus,
|
||||
}: {
|
||||
market: string;
|
||||
tickerInfo?: OrderbookTickerInfo;
|
||||
prevClose: number;
|
||||
totalAskSize: number;
|
||||
totalBidSize: number;
|
||||
wsStatus: WsStatus;
|
||||
}) {
|
||||
const price = tickerInfo?.tradePrice ?? 0;
|
||||
const change = price > 0 && prevClose > 0 ? price - prevClose : (tickerInfo?.changePrice ?? 0);
|
||||
const rate = tickerInfo?.changeRate != null
|
||||
? tickerInfo.changeRate * 100
|
||||
: (prevClose > 0 && price > 0 ? (change / prevClose) * 100 : 0);
|
||||
const isUp = change >= 0;
|
||||
const vol = tickerInfo?.accTradeVolume24 ?? 0;
|
||||
const bidRatio = totalAskSize + totalBidSize > 0
|
||||
? (totalBidSize / (totalAskSize + totalBidSize)) * 100
|
||||
: 50;
|
||||
const code = coinCode(market);
|
||||
|
||||
return (
|
||||
<div className="ob-quote-header">
|
||||
<div className="ob-quote-left">
|
||||
<span className="ob-quote-name">{getKoreanName(market)}</span>
|
||||
<div className="ob-quote-price-row">
|
||||
<span className={`ob-quote-price ${isUp ? 'ob-quote-price--up' : 'ob-quote-price--dn'}`}>
|
||||
{price > 0 ? fmtPrice(price) : '-'}
|
||||
</span>
|
||||
<span className={`ob-quote-change ${isUp ? 'ob-quote-change--up' : 'ob-quote-change--dn'}`}>
|
||||
{change >= 0 ? '+' : ''}{Math.abs(change) >= 1000
|
||||
? Math.round(change).toLocaleString('ko-KR')
|
||||
: change.toFixed(change >= 1 ? 0 : 2)}
|
||||
</span>
|
||||
<span className={`ob-quote-rate ${isUp ? 'ob-quote-rate--up' : 'ob-quote-rate--dn'}`}>
|
||||
{rate >= 0 ? '+' : ''}{rate.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ob-quote-right">
|
||||
<div className="ob-quote-vol">
|
||||
<span className="ob-quote-vol-label">거래량</span>
|
||||
<span className="ob-quote-vol-val">
|
||||
{vol > 0 ? `${Math.round(vol).toLocaleString('ko-KR')} ${code}` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ob-quote-ratio">
|
||||
<span className="ob-quote-ratio-label">매수비</span>
|
||||
<span className="ob-quote-ratio-val ob-quote-ratio-val--up">{bidRatio.toFixed(2)}%</span>
|
||||
</div>
|
||||
<WsDot status={wsStatus} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WsDot({ status }: { status: WsStatus }) {
|
||||
const cls = status === 'connected' ? 'ob-dot--ok'
|
||||
: status === 'connecting' ? 'ob-dot--wait' : 'ob-dot--err';
|
||||
return <span className={`ob-dot ${cls}`}><span className="ob-dot-circle" /></span>;
|
||||
}
|
||||
|
||||
function StatsRail({ tickerInfo, prevClose }: {
|
||||
tickerInfo: OrderbookTickerInfo;
|
||||
prevClose: number;
|
||||
}) {
|
||||
return (
|
||||
<aside className="ob-stats-rail">
|
||||
<div className="ob-stat-row"><span className="ob-stat-k">전일</span><span className="ob-stat-v">{prevClose > 0 ? fmtPrice(prevClose) : '-'}</span></div>
|
||||
<div className="ob-stat-row"><span className="ob-stat-k">시가</span><span className="ob-stat-v">{tickerInfo.openingPrice != null ? fmtPrice(tickerInfo.openingPrice) : '-'}</span></div>
|
||||
<div className="ob-stat-row"><span className="ob-stat-k">고가</span><span className="ob-stat-v ob-stat-v--up">{tickerInfo.highPrice != null ? fmtPrice(tickerInfo.highPrice) : '-'}</span></div>
|
||||
<div className="ob-stat-row"><span className="ob-stat-k">저가</span><span className="ob-stat-v ob-stat-v--dn">{tickerInfo.lowPrice != null ? fmtPrice(tickerInfo.lowPrice) : '-'}</span></div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function TradesPanel({ trades, strength }: { trades: RecentTrade[]; strength: number | null }) {
|
||||
return (
|
||||
<aside className="ob-trades-panel">
|
||||
<div className="ob-trades-strength">
|
||||
<span className="ob-trades-strength-k">체결강도</span>
|
||||
<span className={`ob-trades-strength-v${strength != null && strength >= 100 ? ' ob-trades-strength-v--up' : ''}`}>
|
||||
{strength != null ? `${strength >= 100 ? '+' : ''}${strength.toFixed(2)}%` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<table className="ob-table ob-table--trades">
|
||||
<thead><tr><th>체결가</th><th>체결량</th></tr></thead>
|
||||
<tbody>
|
||||
{trades.length === 0 ? (
|
||||
<tr><td colSpan={2} className="ob-td-empty">—</td></tr>
|
||||
) : trades.map((t, i) => (
|
||||
<tr key={`${t.time}-${i}`}>
|
||||
<td className="ob-td ob-td-price-sm">{fmtPrice(t.price)}</td>
|
||||
<td className={t.side === 'bid' ? 'ob-td ob-trades-vol--buy' : 'ob-td ob-trades-vol--sell'}>
|
||||
{fmtTradeVol(t.volume)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OrderbookTickerInfo {
|
||||
tradePrice: number | null;
|
||||
changeRate: number | null;
|
||||
changePrice?: number | null;
|
||||
accTradePrice24: number;
|
||||
accTradeVolume24?: number | null;
|
||||
highPrice: number | null;
|
||||
lowPrice: number | null;
|
||||
openingPrice: number | null;
|
||||
}
|
||||
|
||||
export interface OrderbookPanelProps {
|
||||
market: string;
|
||||
asks: OrderbookDisplayUnit[];
|
||||
bids: OrderbookDisplayUnit[];
|
||||
totalAskSize: number;
|
||||
totalBidSize: number;
|
||||
wsStatus: WsStatus;
|
||||
bestAsk: number;
|
||||
bestBid: number;
|
||||
spread: number;
|
||||
spreadPct: number;
|
||||
prevClose: number;
|
||||
tickerInfo?: OrderbookTickerInfo;
|
||||
recentTrades?: RecentTrade[];
|
||||
tradeStrength?: number | null;
|
||||
onRowClick?: (price: number, rowType: 'ask' | 'bid') => void;
|
||||
}
|
||||
|
||||
const SUB_TABS = ['호가', '체결', '일별', '차트'] as const;
|
||||
|
||||
export const OrderbookPanel = memo(function OrderbookPanel({
|
||||
market, asks, bids, totalAskSize, totalBidSize, wsStatus,
|
||||
prevClose, tickerInfo, recentTrades = [], tradeStrength = null, onRowClick,
|
||||
}: OrderbookPanelProps) {
|
||||
const isEmpty = asks.length === 0 && bids.length === 0;
|
||||
const midPrice = tickerInfo?.tradePrice ?? 0;
|
||||
const midPct = midPrice > 0 ? fmtPct(midPrice, prevClose) : '';
|
||||
const clock = useClock();
|
||||
const isUp = (tickerInfo?.changeRate ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<div className="ob-panel ob-panel--classic">
|
||||
<QuoteHeader
|
||||
market={market}
|
||||
tickerInfo={tickerInfo}
|
||||
prevClose={prevClose}
|
||||
totalAskSize={totalAskSize}
|
||||
totalBidSize={totalBidSize}
|
||||
wsStatus={wsStatus}
|
||||
/>
|
||||
|
||||
<nav className="ob-subtabs" aria-label="호가 하위 메뉴">
|
||||
{SUB_TABS.map((label, i) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
className={`ob-subtab${i === 0 ? ' ob-subtab--active' : ''}`}
|
||||
disabled={i !== 0}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="ob-empty">
|
||||
<div className="loading-spinner" style={{ width: 16, height: 16 }} />
|
||||
<span>호가 데이터 로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ob-body">
|
||||
{/* 매도 구간 + 우측 시세 */}
|
||||
<div className="ob-block ob-block--ask">
|
||||
<div className="ob-block-table">
|
||||
<div className="ob-table-scroll ob-table-scroll--ask">
|
||||
<table className="ob-table ob-table--hoga">
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
{asks.map((unit, i) => (
|
||||
<AskTableRow key={`ask-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{tickerInfo && <StatsRail tickerInfo={tickerInfo} prevClose={prevClose} />}
|
||||
</div>
|
||||
|
||||
{/* 현재가 (가운데 열 정렬) */}
|
||||
<table className="ob-table ob-table--hoga ob-table--mid">
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
<tr className="ob-tr-mid">
|
||||
<td className="ob-td ob-td--pad" />
|
||||
<td className={`ob-td ob-td-price ob-td-price--current ${isUp ? 'ob-td-price--up' : 'ob-td-price--dn'}`}>
|
||||
<span className="ob-mid-price">{midPrice > 0 ? fmtPrice(midPrice) : '-'}</span>
|
||||
{midPct && <span className={`ob-mid-pct ${pctClass(midPct)}`}>{midPct}</span>}
|
||||
</td>
|
||||
<td className="ob-td ob-td--pad" />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 매수 구간 + 좌측 체결 */}
|
||||
<div className="ob-block ob-block--bid">
|
||||
<TradesPanel trades={recentTrades} strength={tradeStrength} />
|
||||
<div className="ob-block-table">
|
||||
<div className="ob-table-scroll ob-table-scroll--bid">
|
||||
<table className="ob-table ob-table--hoga">
|
||||
{COLGROUP}
|
||||
<tbody>
|
||||
{bids.map((unit, i) => (
|
||||
<BidTableRow key={`bid-${unit.price}-${i}`} unit={unit} prevClose={prevClose} onClick={onRowClick} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEmpty && (
|
||||
<footer className="ob-summary-bar">
|
||||
<div className="ob-summary ob-summary--ask">
|
||||
<span className="ob-summary-num">{fmtTotalSize(totalAskSize)}</span>
|
||||
</div>
|
||||
<div className="ob-summary ob-summary--time">{clock}</div>
|
||||
<div className="ob-summary ob-summary--bid">
|
||||
<span className="ob-summary-num">{fmtTotalSize(totalBidSize)}</span>
|
||||
</div>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default OrderbookPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
/**
|
||||
* 모의투자 화면 — 계좌 요약, 보유종목, 체결내역
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
loadPaperSummary,
|
||||
loadPaperTrades,
|
||||
resetPaperAccount,
|
||||
type PaperPositionDto,
|
||||
type PaperSummaryDto,
|
||||
type PaperTradeDto,
|
||||
} from '../utils/backendApi';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { fmtKrw } from './TradeOrderPanel';
|
||||
|
||||
type TabId = 'overview' | 'positions' | 'trades';
|
||||
|
||||
interface Props {
|
||||
tickers?: Map<string, { tradePrice: number | null }>;
|
||||
onGoChart?: (market: string) => void;
|
||||
/** 외부 체결(알림 모달 등) 후 목록 갱신 트리거 */
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
function buildMarkPrices(
|
||||
summary: PaperSummaryDto | null,
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): Record<string, number> {
|
||||
const m: Record<string, number> = {};
|
||||
if (summary?.positions) {
|
||||
for (const p of summary.positions) {
|
||||
const t = tickers?.get(p.symbol)?.tradePrice;
|
||||
if (t != null && t > 0) m[p.symbol] = t;
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function positionPriceKey(
|
||||
positions: PaperPositionDto[] | undefined,
|
||||
tickers?: Map<string, { tradePrice: number | null }>,
|
||||
): string {
|
||||
if (!positions?.length) return '';
|
||||
return positions
|
||||
.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`)
|
||||
.join('|');
|
||||
}
|
||||
|
||||
const PaperTradingPage: React.FC<Props> = ({ tickers, onGoChart, refreshKey = 0 }) => {
|
||||
const [tab, setTab] = useState<TabId>('overview');
|
||||
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
|
||||
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const tickersRef = useRef(tickers);
|
||||
tickersRef.current = tickers;
|
||||
|
||||
const summaryRef = useRef(summary);
|
||||
summaryRef.current = summary;
|
||||
|
||||
const loadData = useCallback(async (opts?: { showSpinner?: boolean }) => {
|
||||
const showSpinner = opts?.showSpinner ?? false;
|
||||
if (showSpinner) setRefreshing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const base = await loadPaperSummary();
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
const full = Object.keys(marks).length > 0
|
||||
? await loadPaperSummary(marks)
|
||||
: base;
|
||||
setSummary(full);
|
||||
setTrades(await loadPaperTrades());
|
||||
lastPriceKeyRef.current = positionPriceKey(full?.positions, tickersRef.current);
|
||||
} catch {
|
||||
if (showSpinner) setError('모의투자 데이터를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
if (showSpinner) setRefreshing(false);
|
||||
setInitialLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 최초 진입·수동 새로고침 */
|
||||
const refresh = useCallback(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData({ showSpinner: true });
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshKey <= 0) return;
|
||||
void loadData();
|
||||
}, [refreshKey, loadData]);
|
||||
|
||||
/** 보유 종목 시세만 조용히 갱신 (tickers Map 참조 변경마다 전체 로딩 방지) */
|
||||
const priceKey = positionPriceKey(summary?.positions, tickers);
|
||||
const lastPriceKeyRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!priceKey || initialLoading) return;
|
||||
if (priceKey === lastPriceKeyRef.current) return;
|
||||
lastPriceKeyRef.current = priceKey;
|
||||
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const base = summaryRef.current;
|
||||
if (!base?.positions?.length) return;
|
||||
const marks = buildMarkPrices(base, tickersRef.current);
|
||||
if (Object.keys(marks).length === 0) return;
|
||||
try {
|
||||
const full = await loadPaperSummary(marks);
|
||||
if (!cancelled) setSummary(full);
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [priceKey, initialLoading]);
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
if (!window.confirm('모의투자 계좌를 초기화합니다. 보유 종목과 체결 이력이 모두 삭제됩니다. 계속할까요?')) return;
|
||||
const res = await resetPaperAccount();
|
||||
if (res) {
|
||||
setSummary(res);
|
||||
setTrades([]);
|
||||
lastPriceKeyRef.current = '';
|
||||
alert('모의투자 계좌가 초기화되었습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const s = summary;
|
||||
const retUp = (s?.totalReturnPct ?? 0) >= 0;
|
||||
const showPanelLoading = initialLoading;
|
||||
|
||||
return (
|
||||
<div className="paper-page">
|
||||
<header className="paper-header">
|
||||
<div>
|
||||
<h1 className="paper-title">모의투자</h1>
|
||||
<p className="paper-subtitle">
|
||||
실시간 차트·전략 시그널과 연동된 가상 계좌입니다. 실제 거래소 주문은 발생하지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="paper-header-actions">
|
||||
<button type="button" className="paper-btn" onClick={refresh} disabled={refreshing}>
|
||||
{refreshing ? '새로고침 중…' : '새로고침'}
|
||||
</button>
|
||||
<button type="button" className="paper-btn paper-btn--danger" onClick={handleReset}>
|
||||
계좌 초기화
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!s?.enabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--warn">
|
||||
모의투자가 꺼져 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s?.enabled && s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 ON — 실시간 전략 시그널(BUY/SELL) 발생 시 모의 계좌에 자동 체결됩니다.
|
||||
(매수: 가용현금 {s.autoTradeBudgetPct}% · 매도: 보유 전량)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s?.enabled && !s.autoTradeEnabled && !showPanelLoading && (
|
||||
<div className="paper-banner paper-banner--info">
|
||||
자동매매 OFF — 시그널은 알림만 표시되며, 매매는 차트 우측 매수·매도에서 수동으로 진행합니다.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="paper-banner paper-banner--error">{error}</div>}
|
||||
|
||||
<div className="paper-summary-grid">
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 평가자산</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.totalAsset) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주문가능 현금</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.cashBalance) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">주식 평가금액</span>
|
||||
<span className="paper-card-value">{s ? fmtKrw(s.stockEvalAmount) : '—'} KRW</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">총 수익률</span>
|
||||
<span className={`paper-card-value ${retUp ? 'up' : 'down'}`}>
|
||||
{s ? `${s.totalReturnPct >= 0 ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">평가손익</span>
|
||||
<span className={`paper-card-value ${(s?.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.unrealizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
<div className="paper-card">
|
||||
<span className="paper-card-label">실현손익</span>
|
||||
<span className={`paper-card-value ${(s?.realizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
|
||||
{s ? fmtKrw(s.realizedPnl) : '—'} KRW
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="paper-tabs">
|
||||
{(['overview', 'positions', 'trades'] as TabId[]).map(id => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`paper-tab${tab === id ? ' active' : ''}`}
|
||||
onClick={() => setTab(id)}
|
||||
>
|
||||
{id === 'overview' ? '계좌 개요' : id === 'positions' ? '보유종목' : '체결내역'}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="paper-panel">
|
||||
{showPanelLoading && <p className="paper-muted">로딩 중...</p>}
|
||||
|
||||
{!showPanelLoading && tab === 'overview' && s && (
|
||||
<dl className="paper-dl">
|
||||
<dt>초기 자본</dt><dd>{fmtKrw(s.initialCapital)} KRW</dd>
|
||||
<dt>수수료율</dt><dd>{s.feeRatePct}%</dd>
|
||||
<dt>슬리피지</dt><dd>{s.slippagePct}%</dd>
|
||||
<dt>최소 주문</dt><dd>{fmtKrw(s.minOrderKrw)} KRW</dd>
|
||||
<dt>자동매매</dt>
|
||||
<dd>{s.autoTradeEnabled ? `ON (매수 시 가용현금 ${s.autoTradeBudgetPct}%)` : 'OFF (수동 매매)'}</dd>
|
||||
<dt>보유 종목 수</dt><dd>{s.positions.length}개</dd>
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'positions' && (
|
||||
<table className="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<th>수량</th>
|
||||
<th>평균단가</th>
|
||||
<th>현재가</th>
|
||||
<th>평가금액</th>
|
||||
<th>평가손익</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(s?.positions ?? []).length === 0 ? (
|
||||
<tr><td colSpan={7} className="paper-muted">보유 종목이 없습니다.</td></tr>
|
||||
) : s!.positions.map(p => (
|
||||
<tr key={p.symbol}>
|
||||
<td>
|
||||
<strong>{getKoreanName(p.symbol)}</strong>
|
||||
<span className="paper-sym">{p.symbol}</span>
|
||||
</td>
|
||||
<td>{p.quantity.toFixed(8).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(p.avgPrice)}</td>
|
||||
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
|
||||
<td>{p.evalAmount != null ? fmtKrw(p.evalAmount) : '—'}</td>
|
||||
<td className={(p.profitLoss ?? 0) >= 0 ? 'up' : 'down'}>
|
||||
{p.profitLoss != null ? `${fmtKrw(p.profitLoss)} (${(p.profitLossPct ?? 0).toFixed(2)}%)` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{onGoChart && (
|
||||
<button type="button" className="paper-link" onClick={() => onGoChart(p.symbol)}>
|
||||
차트
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!showPanelLoading && tab === 'trades' && (
|
||||
<table className="paper-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>시간</th>
|
||||
<th>종목</th>
|
||||
<th>구분</th>
|
||||
<th>출처</th>
|
||||
<th>체결가</th>
|
||||
<th>수량</th>
|
||||
<th>수수료</th>
|
||||
<th>체결후 현금</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trades.length === 0 ? (
|
||||
<tr><td colSpan={8} className="paper-muted">체결 내역이 없습니다.</td></tr>
|
||||
) : trades.map(t => (
|
||||
<tr key={t.id}>
|
||||
<td className="paper-time">{t.createdAt?.replace('T', ' ').slice(0, 19) ?? '—'}</td>
|
||||
<td>{getKoreanName(t.symbol)}</td>
|
||||
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side}</td>
|
||||
<td>{t.source === 'STRATEGY' ? '전략' : '수동'}</td>
|
||||
<td>{fmtKrw(t.price)}</td>
|
||||
<td>{t.quantity.toFixed(6).replace(/\.?0+$/, '')}</td>
|
||||
<td>{fmtKrw(t.feeAmount)}</td>
|
||||
<td>{fmtKrw(t.cashAfter)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaperTradingPage;
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import type { HLineStyle } from '../utils/indicatorRegistry';
|
||||
import {
|
||||
parsePlotColor,
|
||||
formatPlotColor,
|
||||
plotColorCss,
|
||||
LINE_WIDTH_OPTIONS,
|
||||
LINE_STYLE_OPTIONS,
|
||||
LINE_STYLE_LABELS,
|
||||
} from '../utils/plotColorUtils';
|
||||
import ColorPickerPanel from './ColorPickerPanel';
|
||||
|
||||
export interface PlotLineStyleValue {
|
||||
color: string;
|
||||
lineWidth?: number;
|
||||
lineStyle?: HLineStyle;
|
||||
}
|
||||
|
||||
interface PlotLineStylePickerProps {
|
||||
value: PlotLineStyleValue;
|
||||
disabled?: boolean;
|
||||
/** 팝업 제목 (예: MA3) */
|
||||
title?: string;
|
||||
/** false면 색상만 (히스토그램 등) */
|
||||
showLineControls?: boolean;
|
||||
onChange: (patch: Partial<PlotLineStyleValue>) => void;
|
||||
}
|
||||
|
||||
interface PlotLineStylePopupProps {
|
||||
title?: string;
|
||||
showLineControls: boolean;
|
||||
hex6: string;
|
||||
alpha: number;
|
||||
lineWidth: number;
|
||||
lineStyle: HLineStyle;
|
||||
onChange: (patch: Partial<PlotLineStyleValue>) => void;
|
||||
onClose: () => void;
|
||||
patchColor: (hex: string, alphaPct: number) => void;
|
||||
}
|
||||
|
||||
const PlotLineStylePopup: React.FC<PlotLineStylePopupProps> = ({
|
||||
title,
|
||||
showLineControls,
|
||||
hex6,
|
||||
alpha,
|
||||
lineWidth,
|
||||
lineStyle,
|
||||
onChange,
|
||||
onClose,
|
||||
patchColor,
|
||||
}) => {
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
<div className="plsp-portal-root">
|
||||
<div
|
||||
className="plsp-overlay"
|
||||
role="presentation"
|
||||
onMouseDown={onClose}
|
||||
/>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="plsp-popup"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title ? `${title} 선 스타일` : '선 스타일'}
|
||||
style={{
|
||||
...panelStyle,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header plsp-popup-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title plsp-popup-title">
|
||||
{title ? `${title} · ` : ''}색상 · 선 스타일
|
||||
</span>
|
||||
<button type="button" className="gc-popup-close plsp-popup-close" onClick={onClose} aria-label="닫기">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ColorPickerPanel
|
||||
hex6={hex6}
|
||||
onHexChange={h => patchColor(h, alpha)}
|
||||
/>
|
||||
|
||||
<div className="plsp-section">
|
||||
<span className="plsp-section-label">불투명성</span>
|
||||
<div className="plsp-opacity-row">
|
||||
<input
|
||||
type="range"
|
||||
className="plsp-opacity-slider"
|
||||
min={0}
|
||||
max={100}
|
||||
value={alpha}
|
||||
onChange={e => patchColor(hex6, Number(e.target.value))}
|
||||
/>
|
||||
<span className="plsp-opacity-pct">{alpha}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLineControls && (
|
||||
<>
|
||||
<div className="plsp-section">
|
||||
<span className="plsp-section-label">두께</span>
|
||||
<div className="plsp-btn-group">
|
||||
{LINE_WIDTH_OPTIONS.map(w => (
|
||||
<button
|
||||
key={w}
|
||||
type="button"
|
||||
className={`plsp-icon-btn${lineWidth === w ? ' active' : ''}`}
|
||||
title={`${w}px`}
|
||||
onClick={() => onChange({ lineWidth: w })}
|
||||
>
|
||||
<svg width="24" height="14" viewBox="0 0 24 14" aria-hidden>
|
||||
<line x1="2" y1="7" x2="22" y2="7" stroke="currentColor" strokeWidth={w} strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="plsp-section">
|
||||
<span className="plsp-section-label">라인 스타일</span>
|
||||
<div className="plsp-btn-group">
|
||||
{LINE_STYLE_OPTIONS.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className={`plsp-icon-btn${lineStyle === s ? ' active' : ''}`}
|
||||
title={LINE_STYLE_LABELS[s]}
|
||||
onClick={() => onChange({ lineStyle: s })}
|
||||
>
|
||||
<LineStyleIcon style={s} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="plsp-popup-footer">
|
||||
<button type="button" className="plsp-popup-ok" onClick={onClose}>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LineStyleIcon: React.FC<{ style: HLineStyle }> = ({ style }) => (
|
||||
<svg width="28" height="14" viewBox="0 0 28 14" aria-hidden>
|
||||
<line
|
||||
x1="2" y1="7" x2="26" y2="7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={
|
||||
style === 'dashed' ? '6 4' : style === 'dotted' ? '2 3' : undefined
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const PlotLineStylePicker: React.FC<PlotLineStylePickerProps> = ({
|
||||
value,
|
||||
disabled = false,
|
||||
title,
|
||||
showLineControls = true,
|
||||
onChange,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const swatchRef = useRef<HTMLButtonElement>(null);
|
||||
const { hex6, alpha } = parsePlotColor(value.color);
|
||||
const lineWidth = value.lineWidth ?? 1;
|
||||
const lineStyle = value.lineStyle ?? 'solid';
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, close]);
|
||||
|
||||
const patchColor = useCallback((hex: string, alphaPct: number) => {
|
||||
onChange({ color: formatPlotColor(hex, alphaPct) });
|
||||
}, [onChange]);
|
||||
|
||||
const popup = open ? (
|
||||
<PlotLineStylePopup
|
||||
title={title}
|
||||
showLineControls={showLineControls}
|
||||
hex6={hex6}
|
||||
alpha={alpha}
|
||||
lineWidth={lineWidth}
|
||||
lineStyle={lineStyle}
|
||||
onChange={onChange}
|
||||
onClose={close}
|
||||
patchColor={patchColor}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={`plsp-inline${disabled ? ' plsp-inline--disabled' : ''}`}>
|
||||
<button
|
||||
ref={swatchRef}
|
||||
type="button"
|
||||
className="plsp-swatch"
|
||||
disabled={disabled}
|
||||
title="색상·선 스타일"
|
||||
onClick={() => !disabled && setOpen(true)}
|
||||
>
|
||||
<span
|
||||
className="plsp-swatch-inner"
|
||||
style={{ background: plotColorCss(value.color) }}
|
||||
/>
|
||||
</button>
|
||||
{showLineControls && (
|
||||
<>
|
||||
<select
|
||||
className="plsp-inline-select"
|
||||
value={lineWidth}
|
||||
disabled={disabled}
|
||||
title="선 굵기"
|
||||
aria-label="선 굵기"
|
||||
onChange={e => onChange({ lineWidth: Number(e.target.value) })}
|
||||
>
|
||||
{LINE_WIDTH_OPTIONS.map(w => (
|
||||
<option key={w} value={w}>{w}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="plsp-inline-select plsp-inline-select--style"
|
||||
value={lineStyle}
|
||||
disabled={disabled}
|
||||
title="선 유형"
|
||||
aria-label="선 유형"
|
||||
onChange={e => onChange({ lineStyle: e.target.value as HLineStyle })}
|
||||
>
|
||||
{LINE_STYLE_OPTIONS.map(s => (
|
||||
<option key={s} value={s}>{LINE_STYLE_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{typeof document !== 'undefined' && popup
|
||||
? createPortal(popup, document.body)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlotLineStylePicker;
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* 공통 팝업 셸 — 그라데이션 타이틀바 + 드래그 이동
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
|
||||
export interface PopupShellProps {
|
||||
onClose: () => void;
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
/** 패널 너비 (px 또는 CSS 값) */
|
||||
width?: number | string;
|
||||
className?: string;
|
||||
dialogClassName?: string;
|
||||
centered?: boolean;
|
||||
zIndex?: number;
|
||||
closeOnBackdrop?: boolean;
|
||||
initialPosition?: { x: number; y: number };
|
||||
}
|
||||
|
||||
export const PopupShell: React.FC<PopupShellProps> = ({
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
width,
|
||||
className,
|
||||
dialogClassName,
|
||||
centered = true,
|
||||
zIndex = 5000,
|
||||
closeOnBackdrop = true,
|
||||
initialPosition,
|
||||
}) => {
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: centered, initialPosition });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`gc-popup-overlay${className ? ` ${className}` : ''}`}
|
||||
style={{ zIndex }}
|
||||
onMouseDown={e => {
|
||||
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={`gc-popup-panel${dialogClassName ? ` ${dialogClassName}` : ''}`}
|
||||
style={{
|
||||
...panelStyle,
|
||||
width,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<span className="gc-popup-title">{title}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="gc-popup-close"
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PopupShell;
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 실시간 차트 우측 패널
|
||||
* - 매매 탭: 상단 매수 / 하단 매도
|
||||
* - 호가 탭: 실시간 호가창
|
||||
*/
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { OrderbookPanel } from './OrderbookPanel';
|
||||
import TradeOrderPanel from './TradeOrderPanel';
|
||||
import type { OrderbookDisplayUnit, WsStatus } from '../hooks/useUpbitOrderbook';
|
||||
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
|
||||
import type { OrderbookTickerInfo } from './OrderbookPanel';
|
||||
import type { TradeOrderFillRequest } from '../types';
|
||||
|
||||
type PanelTab = 'trade' | 'orderbook';
|
||||
|
||||
export interface RightSidePanelProps {
|
||||
/** 모바일 등 외부 제어 시 열림 상태 */
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** 모바일 도크에서 호가/매매 탭 지정 */
|
||||
activeTab?: PanelTab;
|
||||
onTabChange?: (tab: PanelTab) => void;
|
||||
market: string;
|
||||
tradePrice: number | null;
|
||||
asks: OrderbookDisplayUnit[];
|
||||
bids: OrderbookDisplayUnit[];
|
||||
totalAskSize: number;
|
||||
totalBidSize: number;
|
||||
wsStatus: WsStatus;
|
||||
bestAsk: number;
|
||||
bestBid: number;
|
||||
spread: number;
|
||||
spreadPct: number;
|
||||
prevClose: number;
|
||||
tickerInfo?: OrderbookTickerInfo;
|
||||
fillRequest?: TradeOrderFillRequest | null;
|
||||
onOrderbookPick?: (price: number, rowType: 'ask' | 'bid') => void;
|
||||
onMarketSelect?: (market: string) => void;
|
||||
availableKrw?: number;
|
||||
availableCoinQty?: number;
|
||||
paperTradingEnabled?: boolean;
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
onPaperOrderFilled?: () => void;
|
||||
}
|
||||
|
||||
const PANEL_TABS: { id: PanelTab; label: string }[] = [
|
||||
{ id: 'trade', label: '매매' },
|
||||
{ id: 'orderbook', label: '호가' },
|
||||
];
|
||||
|
||||
const RightSidePanel: React.FC<RightSidePanelProps> = ({
|
||||
open: openControlled,
|
||||
onOpenChange,
|
||||
activeTab: activeTabControlled,
|
||||
onTabChange,
|
||||
market,
|
||||
tradePrice,
|
||||
asks,
|
||||
bids,
|
||||
totalAskSize,
|
||||
totalBidSize,
|
||||
wsStatus,
|
||||
bestAsk,
|
||||
bestBid,
|
||||
spread,
|
||||
spreadPct,
|
||||
prevClose,
|
||||
tickerInfo,
|
||||
fillRequest,
|
||||
onOrderbookPick,
|
||||
onMarketSelect,
|
||||
availableKrw = 0,
|
||||
availableCoinQty = 0,
|
||||
paperTradingEnabled = false,
|
||||
paperAutoTradeEnabled = false,
|
||||
onPaperOrderFilled,
|
||||
}) => {
|
||||
const [openInternal, setOpenInternal] = useState(false);
|
||||
const [tabInternal, setTabInternal] = useState<PanelTab>('trade');
|
||||
const tradeSectionRef = useRef<HTMLDivElement>(null);
|
||||
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(market);
|
||||
|
||||
const isOpenControlled = openControlled !== undefined && onOpenChange !== undefined;
|
||||
const open = isOpenControlled ? !!openControlled : openInternal;
|
||||
const setOpen = (next: boolean | ((p: boolean) => boolean)) => {
|
||||
const cur = isOpenControlled ? !!openControlled : openInternal;
|
||||
const value = typeof next === 'function' ? next(cur) : next;
|
||||
if (isOpenControlled && onOpenChange) onOpenChange(value);
|
||||
else setOpenInternal(value);
|
||||
};
|
||||
|
||||
const activeTab = activeTabControlled ?? tabInternal;
|
||||
const setActiveTab = useCallback((tab: PanelTab) => {
|
||||
if (onTabChange) onTabChange(tab);
|
||||
else setTabInternal(tab);
|
||||
}, [onTabChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fillRequest) return;
|
||||
setOpen(true);
|
||||
setActiveTab('trade');
|
||||
}, [fillRequest?.seq, fillRequest?.side, setOpen, setActiveTab]);
|
||||
|
||||
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
|
||||
setActiveTab('trade');
|
||||
onOrderbookPick?.(price, rowType);
|
||||
}, [onOrderbookPick]);
|
||||
|
||||
return (
|
||||
<div className={`rsp-wrap ${open ? 'rsp-wrap--open' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="rsp-handle"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
title={open ? '패널 닫기' : '패널 열기'}
|
||||
>
|
||||
<svg
|
||||
width="8" height="14"
|
||||
viewBox="0 0 8 14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{open
|
||||
? <polyline points="2,1 6,7 2,13" />
|
||||
: <polyline points="6,1 2,7 6,13" />}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className="rsp-panel">
|
||||
<div className="rsp-tabs">
|
||||
{PANEL_TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
className={`rsp-tab ${activeTab === tab.id ? 'rsp-tab--active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rsp-body">
|
||||
{activeTab === 'trade' && (
|
||||
<div className="rsp-trade-stack" ref={tradeSectionRef}>
|
||||
<div className="rsp-trade-card rsp-trade-card--buy">
|
||||
<div className="rsp-trade-card-title">매수</div>
|
||||
<div className="rsp-trade-card-body">
|
||||
<TradeOrderPanel
|
||||
side="buy"
|
||||
market={market}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={availableKrw}
|
||||
fillRequest={fillRequest}
|
||||
searchAnchorRef={tradeSectionRef}
|
||||
onMarketSelect={onMarketSelect}
|
||||
showSymbolField
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rsp-trade-card rsp-trade-card--sell">
|
||||
<div className="rsp-trade-card-title">매도</div>
|
||||
<div className="rsp-trade-card-body">
|
||||
<TradeOrderPanel
|
||||
side="sell"
|
||||
market={market}
|
||||
tradePrice={tradePrice}
|
||||
availableKrw={availableKrw}
|
||||
availableCoinQty={availableCoinQty}
|
||||
fillRequest={fillRequest}
|
||||
searchAnchorRef={tradeSectionRef}
|
||||
onMarketSelect={onMarketSelect}
|
||||
showSymbolField={false}
|
||||
paperTradingEnabled={paperTradingEnabled}
|
||||
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
||||
onPaperOrderFilled={onPaperOrderFilled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'orderbook' && (
|
||||
<div className="rsp-ob-stack">
|
||||
<div className="rsp-trade-card rsp-ob-card">
|
||||
<div className="rsp-trade-card-title rsp-ob-card-title">실시간 호가</div>
|
||||
<div className="rsp-trade-card-body rsp-ob-card-body">
|
||||
<OrderbookPanel
|
||||
market={market}
|
||||
asks={asks}
|
||||
bids={bids}
|
||||
totalAskSize={totalAskSize}
|
||||
totalBidSize={totalBidSize}
|
||||
wsStatus={wsStatus}
|
||||
bestAsk={bestAsk}
|
||||
bestBid={bestBid}
|
||||
spread={spread}
|
||||
spreadPct={spreadPct}
|
||||
prevClose={prevClose}
|
||||
tickerInfo={tickerInfo}
|
||||
recentTrades={recentTrades}
|
||||
tradeStrength={tradeStrength}
|
||||
onRowClick={handleOrderbookRowClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightSidePanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* SMA MA1~11 기간 입력 — 프리셋 드롭다운 없이 직접 숫자 편집 (포커스 유지)
|
||||
*/
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const MIN = 1;
|
||||
const MAX = 500;
|
||||
|
||||
interface SmaPeriodInputProps {
|
||||
value: number;
|
||||
disabled?: boolean;
|
||||
onCommit: (v: number) => void;
|
||||
}
|
||||
|
||||
const SmaPeriodInput: React.FC<SmaPeriodInputProps> = ({
|
||||
value,
|
||||
disabled = false,
|
||||
onCommit,
|
||||
}) => {
|
||||
const [text, setText] = useState(() => String(value));
|
||||
const editingRef = useRef(false);
|
||||
const committedRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingRef.current) return;
|
||||
if (value !== committedRef.current) {
|
||||
committedRef.current = value;
|
||||
setText(String(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const commit = useCallback((raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
setText(String(committedRef.current));
|
||||
return;
|
||||
}
|
||||
const n = parseInt(trimmed, 10);
|
||||
if (isNaN(n)) {
|
||||
setText(String(committedRef.current));
|
||||
return;
|
||||
}
|
||||
const clamped = Math.max(MIN, Math.min(MAX, n));
|
||||
committedRef.current = clamped;
|
||||
setText(String(clamped));
|
||||
onCommit(clamped);
|
||||
}, [onCommit]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
className="ism-input ism-narrow ism-sma-period-input"
|
||||
value={text}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onMouseDown={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onPointerDown={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onFocus={e => {
|
||||
e.stopPropagation();
|
||||
editingRef.current = true;
|
||||
}}
|
||||
onChange={e => {
|
||||
const s = e.target.value.replace(/[^\d]/g, '');
|
||||
setText(s);
|
||||
}}
|
||||
onBlur={e => {
|
||||
editingRef.current = false;
|
||||
commit(e.target.value);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SmaPeriodInput;
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 서비스 진입 스플래시 — 로그인 또는 게스트 모드로 메인 화면 진입
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { loginUser, type LoginResponse } from '../utils/backendApi';
|
||||
|
||||
const DEFAULT_USERNAME = 'admin';
|
||||
const DEFAULT_PASSWORD = 'admin';
|
||||
|
||||
interface Props {
|
||||
onLoginSuccess: (res: LoginResponse) => void;
|
||||
onGuest: () => void;
|
||||
}
|
||||
|
||||
const SplashScreen: React.FC<Props> = ({ onLoginSuccess, onGuest }) => {
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
const [password, setPassword] = useState(DEFAULT_PASSWORD);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const submitLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await loginUser(username.trim(), password);
|
||||
onLoginSuccess(res);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '로그인에 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="splash">
|
||||
<div className="splash-bg" aria-hidden />
|
||||
<div className="splash-chart-deco" aria-hidden>
|
||||
<svg className="splash-chart-svg" viewBox="0 0 400 120" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="splashLineGrad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="#2196f3" stopOpacity="0.2" />
|
||||
<stop offset="50%" stopColor="#42a5f5" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="#ffd54f" stopOpacity="0.8" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
className="splash-line"
|
||||
d="M0,90 L40,70 L80,75 L120,45 L160,55 L200,25 L240,40 L280,15 L320,30 L360,10 L400,20"
|
||||
fill="none"
|
||||
stroke="url(#splashLineGrad)"
|
||||
strokeWidth="2.5"
|
||||
/>
|
||||
<path
|
||||
className="splash-area"
|
||||
d="M0,90 L40,70 L80,75 L120,45 L160,55 L200,25 L240,40 L280,15 L320,30 L360,10 L400,20 L400,120 L0,120 Z"
|
||||
fill="url(#splashLineGrad)"
|
||||
opacity="0.12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="splash-inner">
|
||||
<header className="splash-brand">
|
||||
<div className="splash-logo">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
|
||||
<rect width="48" height="48" rx="10" fill="#2196f3" />
|
||||
<polyline
|
||||
points="8,32 16,22 24,26 32,14 40,18"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="splash-title">GoldenChart</h1>
|
||||
<p className="splash-tagline">
|
||||
암호화폐 실시간 차트 · 투자전략 · 백테스팅 · 모의투자
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ul className="splash-features">
|
||||
<li><span className="splash-feat-icon">📈</span> 업비트 연동 실시간 캔들·호가</li>
|
||||
<li><span className="splash-feat-icon">⚙️</span> 커스텀 전략 & 백테스트</li>
|
||||
<li><span className="splash-feat-icon">🔔</span> 매매 시그널 알림</li>
|
||||
<li><span className="splash-feat-icon">🛡️</span> 역할별 메뉴·기능 권한 (관리자 설정)</li>
|
||||
</ul>
|
||||
|
||||
<div className="splash-card">
|
||||
<h2 className="splash-card-title">시작하기</h2>
|
||||
<p className="splash-card-desc">
|
||||
로그인하면 계정 설정이 기기 간 공유됩니다. 게스트 모드는 제한된 메뉴로 바로 체험할 수 있습니다.
|
||||
</p>
|
||||
<form className="splash-form" onSubmit={submitLogin}>
|
||||
<div className="splash-form-row">
|
||||
<label className="splash-field">
|
||||
<span>아이디</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="admin"
|
||||
/>
|
||||
</label>
|
||||
<label className="splash-field">
|
||||
<span>비밀번호</span>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
className="splash-field--visible"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
placeholder="••••••"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="splash-error">{error}</p>}
|
||||
<div className="splash-actions">
|
||||
<button
|
||||
type="submit"
|
||||
className="splash-btn splash-btn--primary"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? '로그인 중…' : '로그인'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="splash-btn splash-btn--guest"
|
||||
onClick={onGuest}
|
||||
disabled={loading}
|
||||
>
|
||||
게스트 모드
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="splash-footnote">
|
||||
기본 관리자: <code>admin</code> / <code>admin</code> · 권한은 설정 → 관리자 설정에서 변경
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="splash-footer">
|
||||
<span>GoldenChart Trading Platform</span>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplashScreen;
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import type { PriceStats } from '../utils/calculations';
|
||||
|
||||
interface StatsPanelProps {
|
||||
stats: PriceStats | null;
|
||||
symbol: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function fmt(n: number, digits = 2): string {
|
||||
if (!isFinite(n)) return '—';
|
||||
return n.toLocaleString('en-US', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||||
}
|
||||
|
||||
function fmtVol(n: number): string {
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(2) + 'K';
|
||||
return n.toFixed(0);
|
||||
}
|
||||
|
||||
const StatRow: React.FC<{ label: string; value: string; highlight?: 'up' | 'down' | 'neutral' }> = ({ label, value, highlight }) => (
|
||||
<div className="stat-row">
|
||||
<span className="stat-label">{label}</span>
|
||||
<span className={`stat-value ${highlight ?? ''}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StatsPanel: React.FC<StatsPanelProps> = ({ stats, symbol, onClose }) => {
|
||||
if (!stats) return null;
|
||||
const trendColor = stats.trend === 'up' ? 'up' : stats.trend === 'down' ? 'down' : 'neutral';
|
||||
const changeColor = stats.change >= 0 ? 'up' : 'down';
|
||||
|
||||
return (
|
||||
<div className="stats-panel">
|
||||
<div className="stats-header">
|
||||
<span className="stats-title">📊 {symbol} 통계</span>
|
||||
<button className="stats-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="stats-body">
|
||||
<div className="stats-section">
|
||||
<div className="stats-section-title">가격</div>
|
||||
<StatRow label="현재가" value={fmt(stats.current)} />
|
||||
<StatRow label="변동" value={`${stats.change >= 0 ? '+' : ''}${fmt(stats.change)} (${fmt(stats.changePct)}%)`} highlight={changeColor} />
|
||||
<StatRow label="52주 고점" value={fmt(stats.high52w)} highlight="up" />
|
||||
<StatRow label="52주 저점" value={fmt(stats.low52w)} highlight="down" />
|
||||
<StatRow label="평균 일일 범위" value={fmt(stats.avgRange)} />
|
||||
</div>
|
||||
<div className="stats-section">
|
||||
<div className="stats-section-title">추세 & 강도</div>
|
||||
<StatRow label="추세" value={stats.trend === 'up' ? '상승 ▲' : stats.trend === 'down' ? '하락 ▼' : '횡보 ▶'} highlight={trendColor} />
|
||||
<StatRow label="RSI(14)" value={fmt(stats.rrsi14, 1)} highlight={stats.rrsi14 > 70 ? 'down' : stats.rrsi14 < 30 ? 'up' : 'neutral'} />
|
||||
<StatRow label="변동성(연)" value={`${fmt(stats.volatility, 1)}%`} />
|
||||
</div>
|
||||
<div className="stats-section">
|
||||
<div className="stats-section-title">피벗 포인트</div>
|
||||
<StatRow label="저항2" value={fmt(stats.resistance2)} highlight="down" />
|
||||
<StatRow label="저항1" value={fmt(stats.resistance1)} highlight="down" />
|
||||
<StatRow label="피벗(P)" value={fmt(stats.pivotP)} highlight="neutral" />
|
||||
<StatRow label="지지1" value={fmt(stats.support1)} highlight="up" />
|
||||
<StatRow label="지지2" value={fmt(stats.support2)} highlight="up" />
|
||||
</div>
|
||||
<div className="stats-section">
|
||||
<div className="stats-section-title">거래량</div>
|
||||
<StatRow label="평균 거래량" value={fmtVol(stats.avgVol)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsPanel;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 시간대 선택 — 하단 바 클릭·설정 화면 공용
|
||||
*/
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { TIMEZONE_OPTIONS, formatUnixClock, getTimezoneAbbr, normalizeTimezone } from '../utils/timezone';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (tz: string) => void;
|
||||
/** compact: 하단 바용 버튼 스타일 */
|
||||
variant?: 'bar' | 'select';
|
||||
/** 하단 바: 마지막 봉 시각 표시 (unix 초) */
|
||||
displayUnix?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TimezonePicker: React.FC<Props> = ({ value, onChange, variant = 'bar', displayUnix, className = '' }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [menuPos, setMenuPos] = useState<{ bottom: number; right: number } | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLUListElement>(null);
|
||||
const tz = normalizeTimezone(value);
|
||||
|
||||
const updateMenuPos = useCallback(() => {
|
||||
const btn = triggerRef.current;
|
||||
if (!btn) return;
|
||||
const r = btn.getBoundingClientRect();
|
||||
setMenuPos({
|
||||
bottom: window.innerHeight - r.top + 4,
|
||||
right: Math.max(8, window.innerWidth - r.right),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || variant !== 'bar') return;
|
||||
updateMenuPos();
|
||||
const onResize = () => updateMenuPos();
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('scroll', onResize, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('scroll', onResize, true);
|
||||
};
|
||||
}, [open, variant, updateMenuPos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (rootRef.current?.contains(t) || menuRef.current?.contains(t)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
const id = window.setTimeout(() => {
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
}, 0);
|
||||
return () => {
|
||||
window.clearTimeout(id);
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (variant === 'select') {
|
||||
return (
|
||||
<select
|
||||
className={`stg-select ${className}`}
|
||||
value={tz}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
>
|
||||
{TIMEZONE_OPTIONS.map(o => (
|
||||
<option key={o.id} value={o.id}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
const menu = open && variant === 'bar' && menuPos && (
|
||||
<ul
|
||||
ref={menuRef}
|
||||
className="tz-picker-menu tz-picker-menu--portal"
|
||||
role="listbox"
|
||||
style={{ bottom: menuPos.bottom, right: menuPos.right }}
|
||||
>
|
||||
{TIMEZONE_OPTIONS.map(o => (
|
||||
<li key={o.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={o.id === tz}
|
||||
className={`tz-picker-item${o.id === tz ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
onChange(o.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<span className="tz-picker-item-label">{o.label}</span>
|
||||
<span className="tz-picker-item-abbr">{o.abbr}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`tz-picker ${className}`} ref={rootRef}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="tv-time-display tz-picker-trigger"
|
||||
onClick={() => {
|
||||
if (open) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
updateMenuPos();
|
||||
setOpen(true);
|
||||
}}
|
||||
title="시간대 변경"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
{displayUnix != null && displayUnix > 0 && (
|
||||
<span className="tz-picker-clock">{formatUnixClock(displayUnix, tz, true)}</span>
|
||||
)}
|
||||
<span className="tz-picker-abbr">{getTimezoneAbbr(tz)}</span>
|
||||
<span className="tz-picker-caret">▾</span>
|
||||
</button>
|
||||
{menu && createPortal(menu, document.body)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimezonePicker;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 최상단 글로벌 메뉴바
|
||||
*
|
||||
* 레이아웃:
|
||||
* [로고] | [대시보드] [실시간차트] [투자전략] | [테마토글] [로그인]
|
||||
*/
|
||||
import React, { memo } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import type { AuthSession } from '../utils/auth';
|
||||
import TradeAlertPopupMenubarSelect from './TradeAlertPopupMenubarSelect';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
|
||||
export type MenuPage = 'dashboard' | 'chart' | 'paper' | 'strategy' | 'backtest' | 'notifications' | 'settings';
|
||||
|
||||
interface TopMenuBarProps {
|
||||
activePage: MenuPage;
|
||||
theme: Theme;
|
||||
onPage: (page: MenuPage) => void;
|
||||
onTheme: () => void;
|
||||
/** 미확인 매매 시그널 알림 수 */
|
||||
tradeNotifyUnread?: number;
|
||||
onOpenNotifications?: () => void;
|
||||
/** 우측에 떠 있는 알림 팝업(토스트) 개수 */
|
||||
tradeNotifyToastCount?: number;
|
||||
/** 모든 알림 팝업 닫기 */
|
||||
onDismissAllNotifyPopups?: () => void;
|
||||
/** 알림 팝업 표시 위치·방식 */
|
||||
tradeAlertPopupPosition?: string;
|
||||
tradeAlertPopupLayout?: string;
|
||||
tradeAlertPopupGridCols?: number;
|
||||
onTradeAlertPopupPosition?: (v: TradeAlertPopupPosition) => void;
|
||||
onTradeAlertPopupLayout?: (v: TradeAlertPopupLayout) => void;
|
||||
onTradeAlertPopupGridCols?: (v: number) => void;
|
||||
/** 로그인 사용자 (null = 비로그인·기기별 설정) */
|
||||
authUser?: AuthSession | null;
|
||||
/** 게스트 모드(스플래시에서 게스트 진입) */
|
||||
guestMode?: boolean;
|
||||
onLoginClick?: () => void;
|
||||
onLogout?: () => void;
|
||||
/** 메뉴별 접근 허용 (없으면 전체 표시) */
|
||||
menuPermissions?: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// ── SVG 아이콘 ────────────────────────────────────────────────────────────
|
||||
const IcDashboard = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1" y="1" width="5.5" height="5.5" rx="1"/>
|
||||
<rect x="9.5" y="1" width="5.5" height="5.5" rx="1"/>
|
||||
<rect x="1" y="9.5" width="5.5" height="5.5" rx="1"/>
|
||||
<rect x="9.5" y="9.5" width="5.5" height="5.5" rx="1"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcChart = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="1,12 5,7 8,9 11,4 15,4"/>
|
||||
<line x1="3" y1="14" x2="3" y2="10"/>
|
||||
<line x1="6.5" y1="14" x2="6.5" y2="11.5"/>
|
||||
<line x1="10" y1="14" x2="10" y2="7"/>
|
||||
<line x1="13.5" y1="14" x2="13.5" y2="4"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcPaper = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
|
||||
<line x1="5" y1="6" x2="11" y2="6"/>
|
||||
<line x1="5" y1="9" x2="9" y2="9"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcStrategy = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="8" cy="8" r="6.5"/>
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<line x1="8" y1="1.5" x2="8" y2="5.5"/>
|
||||
<line x1="8" y1="10.5" x2="8" y2="14.5"/>
|
||||
<line x1="1.5" y1="8" x2="5.5" y2="8"/>
|
||||
<line x1="10.5" y1="8" x2="14.5" y2="8"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcBacktest = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="1" y="9" width="2.5" height="5" rx="0.5"/>
|
||||
<rect x="4.5" y="6" width="2.5" height="8" rx="0.5"/>
|
||||
<rect x="8" y="3.5" width="2.5" height="10.5" rx="0.5"/>
|
||||
<rect x="11.5" y="1" width="2.5" height="13" rx="0.5"/>
|
||||
<polyline points="2.25,8 5.75,5 9.25,3 12.75,0.5" strokeDasharray="2 1.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcSettings = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="8" cy="8" r="2.5"/>
|
||||
<path d="M8 1.5v1.3M8 13.2v1.3M1.5 8h1.3M13.2 8h1.3M3.4 3.4l.9.9M11.7 11.7l.9.9M3.4 12.6l.9-.9M11.7 4.3l.9-.9"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcNotify = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M8 1.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1 4.5-1.5 4.5h12c-.5 0-1.5-1-1.5-4.5A4.5 4.5 0 0 0 8 1.5z"/>
|
||||
<path d="M6.5 13.5a1.5 1.5 0 0 0 3 0"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
/** 알림 팝업 전체 닫기 */
|
||||
const IcDismissPopups = () => (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="9" height="10" rx="1"/>
|
||||
<path d="M11 5.5 14 2.5M14 5.5 11 2.5"/>
|
||||
<line x1="4.5" y1="6.5" x2="8.5" y2="10.5"/>
|
||||
<line x1="8.5" y1="6.5" x2="4.5" y2="10.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcLogin = () => (
|
||||
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M9 2h3a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H9"/>
|
||||
<polyline points="6,10.5 9,7.5 6,4.5"/>
|
||||
<line x1="1.5" y1="7.5" x2="9" y2="7.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
// ── 테마 아이콘 ───────────────────────────────────────────────────────────
|
||||
const THEME_CONFIG: Record<Theme, { icon: string; label: string; next: Theme }> = {
|
||||
dark: { icon: '🌙', label: '다크', next: 'blue' },
|
||||
blue: { icon: '🌊', label: '블루', next: 'light' },
|
||||
light: { icon: '☀️', label: '라이트', next: 'dark' },
|
||||
};
|
||||
|
||||
const MENU_ITEMS: { page: MenuPage; label: string; icon: React.ReactNode }[] = [
|
||||
{ page: 'dashboard', label: '대시보드', icon: <IcDashboard /> },
|
||||
{ page: 'chart', label: '실시간차트', icon: <IcChart /> },
|
||||
{ page: 'paper', label: '모의투자', icon: <IcPaper /> },
|
||||
{ page: 'strategy', label: '투자전략', icon: <IcStrategy /> },
|
||||
{ page: 'backtest', label: '백테스팅', icon: <IcBacktest /> },
|
||||
{ page: 'settings', label: '설정', icon: <IcSettings /> },
|
||||
];
|
||||
|
||||
export const TopMenuBar = memo(function TopMenuBar({
|
||||
activePage, theme, onPage, onTheme,
|
||||
tradeNotifyUnread = 0,
|
||||
onOpenNotifications,
|
||||
tradeNotifyToastCount = 0,
|
||||
onDismissAllNotifyPopups,
|
||||
tradeAlertPopupPosition = 'right',
|
||||
tradeAlertPopupLayout = 'stack',
|
||||
tradeAlertPopupGridCols = 2,
|
||||
onTradeAlertPopupPosition,
|
||||
onTradeAlertPopupLayout,
|
||||
onTradeAlertPopupGridCols,
|
||||
authUser = null,
|
||||
guestMode = false,
|
||||
onLoginClick,
|
||||
onLogout,
|
||||
menuPermissions,
|
||||
}: TopMenuBarProps) {
|
||||
const tc = THEME_CONFIG[theme];
|
||||
const visibleItems = menuPermissions
|
||||
? MENU_ITEMS.filter(({ page }) => menuPermissions[page] === true)
|
||||
: MENU_ITEMS;
|
||||
const showNotifications = menuPermissions == null || menuPermissions.notifications === true;
|
||||
|
||||
return (
|
||||
<header className="top-menubar">
|
||||
{/* 로고 */}
|
||||
<div className="tmb-logo">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||
<rect width="20" height="20" rx="4" fill="#2196f3"/>
|
||||
<polyline points="3,14 7,9 10,12 14,6 17,6" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
</svg>
|
||||
<span className="tmb-logo-text">GoldenChart</span>
|
||||
</div>
|
||||
|
||||
<div className="tmb-divider" />
|
||||
|
||||
{/* 내비게이션 메뉴 */}
|
||||
<nav className="tmb-nav">
|
||||
{visibleItems.map(({ page, label, icon }) => (
|
||||
<button
|
||||
key={page}
|
||||
className={`tmb-nav-btn ${activePage === page ? 'tmb-nav-btn--active' : ''}`}
|
||||
onClick={() => onPage(page)}
|
||||
>
|
||||
<span className="tmb-nav-icon">{icon}</span>
|
||||
<span className="tmb-nav-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 우측 영역 */}
|
||||
<div className="tmb-right">
|
||||
{onOpenNotifications && showNotifications && (
|
||||
<div className="tmb-notify-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`tmb-notify-btn ${activePage === 'notifications' ? 'tmb-nav-btn--active' : ''}`}
|
||||
onClick={onOpenNotifications}
|
||||
title="매매 시그널 알림"
|
||||
aria-label="매매 시그널 알림"
|
||||
>
|
||||
<IcNotify />
|
||||
{tradeNotifyUnread > 0 && (
|
||||
<span className="tmb-notify-badge">
|
||||
{tradeNotifyUnread > 99 ? '99+' : tradeNotifyUnread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<TradeAlertPopupMenubarSelect
|
||||
position={tradeAlertPopupPosition}
|
||||
layout={tradeAlertPopupLayout}
|
||||
gridCols={tradeAlertPopupGridCols}
|
||||
onPositionChange={onTradeAlertPopupPosition}
|
||||
onLayoutChange={onTradeAlertPopupLayout}
|
||||
onGridColsChange={onTradeAlertPopupGridCols}
|
||||
/>
|
||||
{onDismissAllNotifyPopups && (
|
||||
<button
|
||||
type="button"
|
||||
className="tmb-notify-dismiss-btn"
|
||||
onClick={onDismissAllNotifyPopups}
|
||||
disabled={tradeNotifyToastCount <= 0}
|
||||
title={
|
||||
tradeNotifyToastCount > 0
|
||||
? `알림 팝업 ${tradeNotifyToastCount}건 전체 닫기`
|
||||
: '닫을 알림 팝업 없음'
|
||||
}
|
||||
aria-label="알림 팝업 전체 닫기"
|
||||
>
|
||||
<IcDismissPopups />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테마 토글 버튼 */}
|
||||
<button
|
||||
className={`tmb-theme-btn tmb-theme-btn--${theme}`}
|
||||
onClick={onTheme}
|
||||
title={`테마 전환 (현재: ${tc.label}) → ${THEME_CONFIG[tc.next].label}`}
|
||||
>
|
||||
<span className="tmb-theme-icon">{tc.icon}</span>
|
||||
<span className="tmb-theme-label">{tc.label}</span>
|
||||
</button>
|
||||
|
||||
<div className="tmb-sep" />
|
||||
|
||||
{authUser ? (
|
||||
<>
|
||||
<span className="tmb-user-badge" title={`${authUser.displayName} (${authUser.username}) · ${authUser.role}`}>
|
||||
{authUser.displayName}
|
||||
<span className="tmb-user-role">{authUser.role}</span>
|
||||
</span>
|
||||
<button type="button" className="tmb-login-btn tmb-login-btn--out" onClick={onLogout}>
|
||||
로그아웃
|
||||
</button>
|
||||
</>
|
||||
) : guestMode ? (
|
||||
<>
|
||||
<span className="tmb-guest-badge" title="게스트 권한으로 실행 중">게스트</span>
|
||||
<button type="button" className="tmb-login-btn" onClick={onLoginClick}>
|
||||
<IcLogin />
|
||||
<span>로그인</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="tmb-login-btn" onClick={onLoginClick}>
|
||||
<IcLogin />
|
||||
<span>로그인</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
|
||||
export default TopMenuBar;
|
||||
@@ -0,0 +1,294 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import { placePaperOrder, placeLiveOrder, loadLiveSummary } from '../utils/backendApi';
|
||||
import { formatUnixDateTime } from '../utils/timezone';
|
||||
|
||||
export interface TradeSignalInfo {
|
||||
market: string;
|
||||
signalType: 'BUY' | 'SELL';
|
||||
price: number;
|
||||
candleTime: number;
|
||||
strategyName?: string | null;
|
||||
strategyId?: number | null;
|
||||
executionType?: string;
|
||||
candleType?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
signal: TradeSignalInfo;
|
||||
onClose: () => void;
|
||||
centered?: boolean;
|
||||
tradingMode?: string;
|
||||
hasUpbitKeys?: boolean;
|
||||
paperTradingEnabled?: boolean;
|
||||
liveAutoTradeBudgetPct?: number;
|
||||
paperAutoTradeBudgetPct?: number;
|
||||
onOrderDone?: () => void;
|
||||
}
|
||||
|
||||
const fmt = (n: number): string =>
|
||||
n > 0 ? n.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : '0';
|
||||
|
||||
function fmtTime(unix: number): string {
|
||||
return formatUnixDateTime(unix);
|
||||
}
|
||||
|
||||
function execLabel(type?: string, candleType?: string): string {
|
||||
const base = type === 'REALTIME_TICK' ? '실시간 틱' : '봉 마감';
|
||||
return candleType ? `${base}(${candleType})` : base;
|
||||
}
|
||||
|
||||
const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="6" y="9" width="5" height="12" rx="1" fill={color} opacity="0.9"/>
|
||||
<line x1="8.5" y1="5" x2="8.5" y2="9" stroke={color} strokeWidth="1.5"/>
|
||||
<line x1="8.5" y1="21" x2="8.5" y2="25" stroke={color} strokeWidth="1.5"/>
|
||||
<rect x="17" y="7" width="5" height="9" rx="1" fill="var(--down,#4dabf7)" opacity="0.8"/>
|
||||
<line x1="19.5" y1="4" x2="19.5" y2="7" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
|
||||
<line x1="19.5" y1="16" x2="19.5" y2="20" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TradeAlertModal: React.FC<Props> = ({
|
||||
signal,
|
||||
onClose,
|
||||
centered = false,
|
||||
tradingMode = 'PAPER',
|
||||
hasUpbitKeys = false,
|
||||
paperTradingEnabled = true,
|
||||
liveAutoTradeBudgetPct = 95,
|
||||
paperAutoTradeBudgetPct = 95,
|
||||
onOrderDone,
|
||||
}) => {
|
||||
const isBuy = signal.signalType === 'BUY';
|
||||
const accentColor = isBuy ? '#3f7ef5' : '#ef5350';
|
||||
const accentGlow = isBuy ? 'rgba(63,126,245,0.22)' : 'rgba(239,83,80,0.22)';
|
||||
const signalLabel = isBuy ? 'BUY' : 'SELL';
|
||||
const signalKo = isBuy ? '매수 알림' : '매도 알림';
|
||||
|
||||
const coinSymbol = signal.market.replace(/^[A-Z]+-/, '');
|
||||
const fiatSymbol = signal.market.split('-')[0] ?? 'KRW';
|
||||
|
||||
const useLive = (tradingMode === 'LIVE' || tradingMode === 'BOTH') && hasUpbitKeys;
|
||||
const usePaper = (tradingMode === 'PAPER' || tradingMode === 'BOTH') && paperTradingEnabled;
|
||||
|
||||
const [limitPrice, setLimitPrice] = useState(fmt(signal.price));
|
||||
const [memo, setMemo] = useState('');
|
||||
const [activePct, setActivePct] = useState<string | null>(null);
|
||||
const [ordering, setOrdering] = useState(false);
|
||||
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({
|
||||
centerOnMount: centered,
|
||||
initialPosition: { x: 40, y: 40 },
|
||||
});
|
||||
|
||||
const handlePct = (pct: string) => {
|
||||
setActivePct(pct);
|
||||
};
|
||||
|
||||
const resolveBudgetPct = (): number => {
|
||||
if (activePct === '25%') return 25;
|
||||
if (activePct === '50%') return 50;
|
||||
if (activePct === '100%') return 100;
|
||||
return paperAutoTradeBudgetPct;
|
||||
};
|
||||
|
||||
const handleOrder = async (type: 'limit' | 'market') => {
|
||||
if (ordering) return;
|
||||
setOrdering(true);
|
||||
try {
|
||||
const side = signal.signalType;
|
||||
const budgetPct = resolveBudgetPct();
|
||||
const orderPrice = type === 'limit'
|
||||
? Number(limitPrice.replace(/,/g, ''))
|
||||
: signal.price;
|
||||
let executed = false;
|
||||
|
||||
if (usePaper) {
|
||||
await placePaperOrder({
|
||||
market: signal.market,
|
||||
side,
|
||||
orderKind: type === 'market' ? 'market' : 'limit',
|
||||
price: orderPrice,
|
||||
quantity: 0,
|
||||
budgetPct,
|
||||
source: 'MANUAL',
|
||||
strategyId: signal.strategyId ?? undefined,
|
||||
});
|
||||
executed = true;
|
||||
}
|
||||
|
||||
if (useLive && type === 'market') {
|
||||
const livePct = activePct === '25%' ? 25 : activePct === '50%' ? 50 : activePct === '100%' ? 100 : liveAutoTradeBudgetPct;
|
||||
const summary = isBuy ? await loadLiveSummary() : null;
|
||||
const krwAmount = isBuy && summary
|
||||
? summary.krwBalance * livePct / 100
|
||||
: undefined;
|
||||
await placeLiveOrder({
|
||||
market: signal.market,
|
||||
side,
|
||||
orderKind: 'market',
|
||||
krwAmount,
|
||||
strategyId: signal.strategyId ?? undefined,
|
||||
});
|
||||
executed = true;
|
||||
}
|
||||
|
||||
if (!executed) {
|
||||
window.alert('주문 가능한 채널이 없습니다. 설정에서 모의투자 또는 실거래 API를 확인하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
onOrderDone?.();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
window.alert((e as Error).message);
|
||||
} finally {
|
||||
setOrdering(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tam-overlay">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="tam-modal"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 10000,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
'--tam-accent': accentColor,
|
||||
'--tam-accent-glow': accentGlow,
|
||||
} as React.CSSProperties}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header tam-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<div className="tam-header-left">
|
||||
<span className="tam-badge" style={{ background: accentColor }}>
|
||||
{signalLabel}
|
||||
</span>
|
||||
<span className="tam-header-title">
|
||||
ALERT
|
||||
<span className="tam-header-ko"> ({signalKo})</span>
|
||||
</span>
|
||||
</div>
|
||||
<button className="tam-close" onClick={onClose} title="닫기">✕</button>
|
||||
</div>
|
||||
|
||||
<div className="tam-price-section">
|
||||
<div className="tam-price-row">
|
||||
<span className="tam-market-name">{signal.market}</span>
|
||||
<span className="tam-price-arrow" style={{ color: accentColor }}>
|
||||
{isBuy ? '↑' : '↓'}
|
||||
</span>
|
||||
<span className="tam-price-value" style={{ color: accentColor }}>
|
||||
₩{fmt(signal.price)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tam-price-time">
|
||||
발생 시각: {fmtTime(signal.candleTime)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tam-strategy-card">
|
||||
<div className="tam-strategy-icon">
|
||||
<CandleIcon color={accentColor} />
|
||||
</div>
|
||||
<div className="tam-strategy-info">
|
||||
<div className="tam-strategy-name">
|
||||
<span className="tam-strategy-label">전략명</span>
|
||||
<span className="tam-strategy-val">
|
||||
{signal.strategyName ?? '(전략 미지정)'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="tam-strategy-exec">
|
||||
<span className="tam-strategy-label">실행 방식</span>
|
||||
<span className="tam-strategy-val tam-strategy-exec-val">
|
||||
{execLabel(signal.executionType, signal.candleType)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tam-order-section">
|
||||
<div className="tam-field-row">
|
||||
<span className="tam-field-label">자산 비율</span>
|
||||
<div className="tam-pct-group">
|
||||
{['25%', '50%', '100%'].map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={`tam-pct-btn2 ${activePct === p ? 'tam-pct-btn2--active' : ''}`}
|
||||
style={activePct === p ? { borderColor: accentColor, color: accentColor } : undefined}
|
||||
onClick={() => handlePct(p)}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tam-field-row">
|
||||
<span className="tam-field-label">지정가</span>
|
||||
<div className="tam-price-input-wrap">
|
||||
<input
|
||||
className="tam-price-input"
|
||||
value={limitPrice}
|
||||
onChange={e => setLimitPrice(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="tam-market-btn"
|
||||
disabled={ordering}
|
||||
onClick={() => handleOrder('market')}
|
||||
>
|
||||
시장가 주문
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tam-field-row tam-field-row--memo">
|
||||
<span className="tam-field-label">메모</span>
|
||||
<input
|
||||
className="tam-memo-input"
|
||||
value={memo}
|
||||
onChange={e => setMemo(e.target.value)}
|
||||
placeholder="메모 입력 (선택)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="tam-cta-btn"
|
||||
style={{ background: accentColor }}
|
||||
disabled={ordering}
|
||||
onClick={() => handleOrder('limit')}
|
||||
>
|
||||
{ordering ? '주문 중…' : `${fiatSymbol} ${coinSymbol} ${isBuy ? '매수' : '매도'} (${usePaper ? '모의' : '실거래'})`}
|
||||
</button>
|
||||
|
||||
<p className="tam-disclaimer">
|
||||
{useLive && usePaper && '※ 모의·실거래 병행: 시장가는 모의·실거래 모두 체결, 지정가는 모의 계좌 체결'}
|
||||
{useLive && !usePaper && '※ 업비트 실거래 시장가 주문'}
|
||||
{!useLive && usePaper && '※ 모의투자 계좌 체결 (자동매매와 동일 내역 반영)'}
|
||||
{!useLive && !usePaper && '※ 설정에서 모의투자 또는 API 키를 활성화하세요'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeAlertModal;
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 상단 메뉴바 — 알림 팝업 표시 위치·방식 (아이콘 드롭다운)
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import TradeAlertPopupModeIcon from './TradeAlertPopupModeIcon';
|
||||
import {
|
||||
TRADE_ALERT_GRID_COL_OPTIONS,
|
||||
buildTradeAlertPopupModeOptions,
|
||||
formatTradeAlertPopupMode,
|
||||
normalizeTradeAlertGridCols,
|
||||
normalizeTradeAlertPopupLayout,
|
||||
normalizeTradeAlertPopupPosition,
|
||||
tradeAlertPopupModeKey,
|
||||
type TradeAlertPopupLayout,
|
||||
type TradeAlertPopupPosition,
|
||||
} from '../utils/tradeAlertPopupLayout';
|
||||
|
||||
interface Props {
|
||||
position?: string;
|
||||
layout?: string;
|
||||
gridCols?: number;
|
||||
onPositionChange?: (v: TradeAlertPopupPosition) => void;
|
||||
onLayoutChange?: (v: TradeAlertPopupLayout) => void;
|
||||
onGridColsChange?: (v: number) => void;
|
||||
}
|
||||
|
||||
export const TradeAlertPopupMenubarSelect: React.FC<Props> = ({
|
||||
position: positionRaw = 'right',
|
||||
layout: layoutRaw = 'stack',
|
||||
gridCols: gridColsRaw = 2,
|
||||
onPositionChange,
|
||||
onLayoutChange,
|
||||
onGridColsChange,
|
||||
}) => {
|
||||
const position = normalizeTradeAlertPopupPosition(positionRaw);
|
||||
const layout = normalizeTradeAlertPopupLayout(layoutRaw);
|
||||
const gridCols = normalizeTradeAlertGridCols(gridColsRaw);
|
||||
const modeKey = tradeAlertPopupModeKey(position, layout);
|
||||
const modeOptions = useMemo(() => buildTradeAlertPopupModeOptions(), []);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const selectMode = useCallback((
|
||||
nextPos: TradeAlertPopupPosition,
|
||||
nextLay: TradeAlertPopupLayout,
|
||||
) => {
|
||||
if (nextPos !== position) onPositionChange?.(nextPos);
|
||||
if (nextLay !== layout) onLayoutChange?.(nextLay);
|
||||
setOpen(false);
|
||||
}, [position, layout, onPositionChange, onLayoutChange]);
|
||||
|
||||
const currentLabel = formatTradeAlertPopupMode(position, layout);
|
||||
|
||||
return (
|
||||
<div className="tmb-notify-layout" ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`tmb-notify-layout-trigger${open ? ' tmb-notify-layout-trigger--open' : ''}`}
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-label={`알림 표시 방식: ${currentLabel}`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
title={`알림 표시: ${currentLabel}`}
|
||||
>
|
||||
<TradeAlertPopupModeIcon position={position} layout={layout} size={20} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="tmb-notify-layout-menu" role="listbox" aria-label="알림 표시 방식">
|
||||
{modeOptions.map(opt => {
|
||||
const active = opt.key === modeKey;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={`tmb-notify-layout-option${active ? ' tmb-notify-layout-option--active' : ''}`}
|
||||
onClick={() => selectMode(opt.position, opt.layout)}
|
||||
>
|
||||
<span className="tmb-notify-layout-option-icon">
|
||||
<TradeAlertPopupModeIcon position={opt.position} layout={opt.layout} size={22} />
|
||||
</span>
|
||||
<span className="tmb-notify-layout-option-label">{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{layout === 'grid' && (
|
||||
<div className="tmb-notify-layout-cols-row">
|
||||
<span className="tmb-notify-layout-cols-label">그리드 열</span>
|
||||
<div className="tmb-notify-layout-cols-btns">
|
||||
{TRADE_ALERT_GRID_COL_OPTIONS.map(n => (
|
||||
<button
|
||||
key={n}
|
||||
type="button"
|
||||
className={`tmb-notify-layout-col-btn${gridCols === n ? ' tmb-notify-layout-col-btn--active' : ''}`}
|
||||
onClick={() => onGridColsChange?.(n)}
|
||||
>
|
||||
{n}열
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeAlertPopupMenubarSelect;
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 알림 팝업 표시 방식 미니 아이콘 (위치 + 배치)
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { TradeAlertPopupLayout, TradeAlertPopupPosition } from '../utils/tradeAlertPopupLayout';
|
||||
|
||||
interface Props {
|
||||
position: TradeAlertPopupPosition;
|
||||
layout: TradeAlertPopupLayout;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 화면 프레임 안 카드 배치를 그린 24×18 미니 프리뷰 */
|
||||
const ICON_H = 18;
|
||||
|
||||
function slotOrigin(
|
||||
position: TradeAlertPopupPosition,
|
||||
cols: number,
|
||||
rows: number,
|
||||
): { x: number; y: number } {
|
||||
const blockW = cols === 1 ? 5 : cols === 3 ? 9.4 : 6.2;
|
||||
const blockH = rows === 1 ? 3.2 : rows === 3 ? 10.2 : 6.2;
|
||||
|
||||
switch (position) {
|
||||
case 'left':
|
||||
return { x: 2, y: (ICON_H - blockH) / 2 };
|
||||
case 'bottom':
|
||||
return { x: (24 - blockW) / 2, y: ICON_H - blockH - 1.5 };
|
||||
case 'right':
|
||||
default:
|
||||
return { x: 24 - blockW - 2, y: (ICON_H - blockH) / 2 };
|
||||
}
|
||||
}
|
||||
|
||||
export const TradeAlertPopupModeIcon: React.FC<Props> = ({
|
||||
position,
|
||||
layout,
|
||||
size = 22,
|
||||
className,
|
||||
}) => {
|
||||
const w = 24;
|
||||
const h = ICON_H;
|
||||
const cards: React.ReactNode[] = [];
|
||||
|
||||
const card = (x: number, y: number, cw: number, ch: number, key: string) => (
|
||||
<rect
|
||||
key={key}
|
||||
x={x}
|
||||
y={y}
|
||||
width={cw}
|
||||
height={ch}
|
||||
rx={1}
|
||||
fill="currentColor"
|
||||
opacity={0.85}
|
||||
/>
|
||||
);
|
||||
|
||||
if (layout === 'single') {
|
||||
const slots = slotOrigin(position, 1, 1);
|
||||
cards.push(card(slots.x, slots.y, 5, 4, 's'));
|
||||
} else if (layout === 'strip') {
|
||||
const o = slotOrigin(position, 3, 1);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
cards.push(card(o.x + i * 3.2, o.y, 2.6, 3.2, `h${i}`));
|
||||
}
|
||||
} else if (layout === 'grid') {
|
||||
const o = slotOrigin(position, 2, 2);
|
||||
for (let r = 0; r < 2; r++) {
|
||||
for (let c = 0; c < 2; c++) {
|
||||
cards.push(card(o.x + c * 3.2, o.y + r * 3.4, 2.6, 2.8, `g${r}${c}`));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// stack
|
||||
const o = slotOrigin(position, 1, 3);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
cards.push(card(o.x, o.y + i * 3.4, 5, 2.8, `v${i}`));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={Math.round(size * (h / w))}
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
className={className}
|
||||
aria-hidden
|
||||
>
|
||||
<rect
|
||||
x={0.5}
|
||||
y={0.5}
|
||||
width={w - 1}
|
||||
height={h - 1}
|
||||
rx={2}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1}
|
||||
opacity={0.45}
|
||||
/>
|
||||
{cards}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeAlertPopupModeIcon;
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 설정 화면 — 매매 알림 팝업 배치 미리보기
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
type TradeAlertPopupLayout,
|
||||
type TradeAlertPopupPosition,
|
||||
tradeAlertPopupClassNames,
|
||||
} from '../utils/tradeAlertPopupLayout';
|
||||
|
||||
interface Props {
|
||||
position: TradeAlertPopupPosition;
|
||||
layout: TradeAlertPopupLayout;
|
||||
gridCols: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const MOCK_ITEMS = [
|
||||
{ id: '1', side: 'BUY' as const, title: 'KRW-BTC 매수', price: '98,500,000' },
|
||||
{ id: '2', side: 'SELL' as const, title: 'KRW-ETH 매도', price: '4,620,000' },
|
||||
{ id: '3', side: 'BUY' as const, title: 'KRW-SOL 매수', price: '245,000' },
|
||||
{ id: '4', side: 'SELL' as const, title: 'KRW-XRP 매도', price: '890' },
|
||||
];
|
||||
|
||||
const PreviewCard: React.FC<{ side: 'BUY' | 'SELL'; title: string; price: string; compact?: boolean }> = ({
|
||||
side, title, price, compact,
|
||||
}) => (
|
||||
<div className={`tsn-card tsn-card--preview ${side === 'BUY' ? 'tsn-card--buy' : 'tsn-card--sell'}${compact ? ' tsn-card--compact' : ''}`}>
|
||||
<div className="tsn-card-header tsn-card-header--preview">
|
||||
<span className={`tsn-badge ${side === 'BUY' ? 'tsn-badge--buy' : 'tsn-badge--sell'}`}>{side}</span>
|
||||
<span className="tsn-card-title">{title}</span>
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="tsn-preview-body">
|
||||
<span>₩{price}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TradeAlertPopupPreview: React.FC<Props> = ({
|
||||
position,
|
||||
layout,
|
||||
gridCols,
|
||||
enabled = true,
|
||||
}) => {
|
||||
const { stack, cards } = tradeAlertPopupClassNames(position, layout);
|
||||
const visible = layout === 'single' ? MOCK_ITEMS.slice(0, 1) : MOCK_ITEMS.slice(0, layout === 'grid' ? gridCols * 2 : 3);
|
||||
const compact = layout === 'grid' || layout === 'strip';
|
||||
|
||||
return (
|
||||
<div className={`stg-alert-preview${enabled ? '' : ' stg-alert-preview--off'}`}>
|
||||
<div className="stg-alert-preview-frame">
|
||||
<div className="stg-alert-preview-screen">
|
||||
<span className="stg-alert-preview-label">화면</span>
|
||||
<div
|
||||
className={`${stack} tsn-stack--preview`}
|
||||
style={layout === 'grid' ? { ['--tsn-grid-cols' as string]: gridCols } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cards}
|
||||
style={layout === 'grid' ? { ['--tsn-grid-cols' as string]: gridCols } : undefined}
|
||||
>
|
||||
{visible.map(item => (
|
||||
<PreviewCard
|
||||
key={item.id}
|
||||
side={item.side}
|
||||
title={item.title}
|
||||
price={item.price}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{layout === 'single' && MOCK_ITEMS.length > 1 && (
|
||||
<span className="tsn-single-more">+{MOCK_ITEMS.length - 1}건</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeAlertPopupPreview;
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* 매매 시그널 알림 목록 화면
|
||||
*/
|
||||
import React, { useCallback } from 'react';
|
||||
import type { Theme } from '../types';
|
||||
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
||||
import { buildSignalDetailRows, formatSignalPrice, getSignalHeadline } from '../utils/tradeSignalDisplay';
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
onGoToChart: (market: string) => void;
|
||||
}
|
||||
|
||||
|
||||
export const TradeNotificationListPage: React.FC<Props> = ({ theme, onGoToChart }) => {
|
||||
const {
|
||||
allNotifications,
|
||||
unreadCount,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
openDetail,
|
||||
refreshHistory,
|
||||
} = useTradeNotification();
|
||||
|
||||
const [filter, setFilter] = React.useState<'all' | 'unread'>('all');
|
||||
|
||||
const filtered = filter === 'unread'
|
||||
? allNotifications.filter(n => !n.isRead)
|
||||
: allNotifications;
|
||||
|
||||
const handleRow = useCallback((item: TradeNotificationItem) => {
|
||||
if (!item.isRead) markAsRead(item.id);
|
||||
openDetail(item, { centered: true });
|
||||
}, [markAsRead, openDetail]);
|
||||
|
||||
return (
|
||||
<div className={`tnl-page app ${theme}`}>
|
||||
<div className="tnl-header">
|
||||
<h1 className="tnl-title">매매 시그널 알림</h1>
|
||||
<p className="tnl-sub">
|
||||
실시간 전략 실행 시 발생한 BUY/SELL 신호입니다. 화면·종목과 무관하게 수신됩니다.
|
||||
</p>
|
||||
<div className="tnl-toolbar">
|
||||
<span className="tnl-chip tnl-chip--warn">미확인 {unreadCount}건</span>
|
||||
<div className="tnl-filter">
|
||||
<button
|
||||
type="button"
|
||||
className={filter === 'all' ? 'tnl-filter-btn--active' : ''}
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={filter === 'unread' ? 'tnl-filter-btn--active' : ''}
|
||||
onClick={() => setFilter('unread')}
|
||||
>
|
||||
미확인
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="tnl-btn" onClick={() => void refreshHistory()}>
|
||||
새로고침
|
||||
</button>
|
||||
{unreadCount > 0 && (
|
||||
<button type="button" className="tnl-btn tnl-btn--primary" onClick={markAllAsRead}>
|
||||
모두 읽음
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tnl-list-wrap">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="tnl-empty">표시할 알림이 없습니다.</div>
|
||||
) : (
|
||||
<ul className="tnl-list">
|
||||
{filtered.map(item => {
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
const detailRows = buildSignalDetailRows(item);
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
className={`tnl-row ${!item.isRead ? 'tnl-row--unread' : ''}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-main"
|
||||
onClick={() => handleRow(item)}
|
||||
>
|
||||
<span className={`tnl-signal ${isBuy ? 'tnl-signal--buy' : 'tnl-signal--sell'}`}>
|
||||
{item.signalType}
|
||||
</span>
|
||||
<span className="tnl-row-body">
|
||||
<span className="tnl-row-title">{getSignalHeadline(item)}</span>
|
||||
<span className="tnl-row-price">{formatSignalPrice(item.price)}</span>
|
||||
<span className="tnl-row-detail-grid">
|
||||
{detailRows.map(row => (
|
||||
<span key={row.label} className="tnl-row-detail-item">
|
||||
<span className="tnl-row-detail-lbl">{row.label}</span>
|
||||
<span className="tnl-row-detail-val">{row.value}</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
{!item.isRead && <span className="tnl-dot" aria-hidden />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tnl-row-chart"
|
||||
title="차트로 이동"
|
||||
onClick={() => {
|
||||
markAsRead(item.id);
|
||||
onGoToChart(item.market);
|
||||
}}
|
||||
>
|
||||
차트
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeNotificationListPage;
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* 매수 / 매도 주문 폼 (업비트 스타일)
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { MarketSearchPanel } from './MarketSearchPanel';
|
||||
import type { TradeOrderFillRequest } from '../types';
|
||||
import { placePaperOrder } from '../utils/backendApi';
|
||||
|
||||
export type OrderKind = 'limit' | 'market' | 'stop_limit';
|
||||
|
||||
export interface TradeOrderPanelProps {
|
||||
side: 'buy' | 'sell';
|
||||
market: string;
|
||||
/** 현재가 (KRW) — 종목 변경 시 초기값 */
|
||||
tradePrice: number | null;
|
||||
availableKrw?: number;
|
||||
/** 매도 시 보유 수량 (모의투자) */
|
||||
availableCoinQty?: number;
|
||||
/** 호가·차트에서 자동 입력 */
|
||||
fillRequest?: TradeOrderFillRequest | null;
|
||||
/** 종목 검색 패널 앵커 (우측 매수·매도 영역) */
|
||||
searchAnchorRef?: React.RefObject<HTMLElement | null>;
|
||||
/** 종목 변경 시 차트·호가 연동 */
|
||||
onMarketSelect?: (market: string) => void;
|
||||
/** 매매 통합 탭에서 매도 블록은 종목 필드 숨김 (매수 블록에만 표시) */
|
||||
showSymbolField?: boolean;
|
||||
/** 모의투자 활성화 시 주문이 가상 계좌로 체결됨 */
|
||||
paperTradingEnabled?: boolean;
|
||||
/** 모의투자 자동매매 (OFF면 수동 주문만) */
|
||||
paperAutoTradeEnabled?: boolean;
|
||||
/** 모의 체결 후 잔고 갱신 */
|
||||
onPaperOrderFilled?: () => void;
|
||||
}
|
||||
|
||||
export function fmtKrw(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0';
|
||||
return Math.round(n).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
function parseNum(s: string): number {
|
||||
const v = Number(String(s).replace(/,/g, '').trim());
|
||||
return Number.isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
/** KRW 가격: 숫자만 허용, 천 단위 쉼표 표시 */
|
||||
function sanitizePriceInput(raw: string): string {
|
||||
const digits = raw.replace(/\D/g, '');
|
||||
if (!digits) return '';
|
||||
return Number(digits).toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
/** 주문 수량: 숫자와 소수점(1개)만 허용 */
|
||||
function sanitizeQtyInput(raw: string): string {
|
||||
let s = raw.replace(/[^\d.]/g, '');
|
||||
const dot = s.indexOf('.');
|
||||
if (dot !== -1) {
|
||||
s = s.slice(0, dot + 1) + s.slice(dot + 1).replace(/\./g, '');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function coinCode(market: string): string {
|
||||
return market.replace(/^KRW-/, '');
|
||||
}
|
||||
|
||||
function priceStep(price: number): number {
|
||||
if (price >= 2_000_000) return 1000;
|
||||
if (price >= 200_000) return 100;
|
||||
if (price >= 20_000) return 10;
|
||||
if (price >= 2_000) return 1;
|
||||
if (price >= 200) return 0.1;
|
||||
if (price >= 20) return 0.01;
|
||||
return 0.0001;
|
||||
}
|
||||
|
||||
const ORDER_KINDS: { id: OrderKind; label: string }[] = [
|
||||
{ id: 'limit', label: '지정가' },
|
||||
{ id: 'market', label: '시장가' },
|
||||
{ id: 'stop_limit', label: '예약-지정가' },
|
||||
];
|
||||
|
||||
const PCT_BTNS = [10, 25, 50, 100] as const;
|
||||
|
||||
const TradeOrderPanel: React.FC<TradeOrderPanelProps> = ({
|
||||
side,
|
||||
market,
|
||||
tradePrice,
|
||||
availableKrw = 0,
|
||||
availableCoinQty = 0,
|
||||
fillRequest,
|
||||
searchAnchorRef,
|
||||
onMarketSelect,
|
||||
showSymbolField = true,
|
||||
paperTradingEnabled = false,
|
||||
paperAutoTradeEnabled = false,
|
||||
onPaperOrderFilled,
|
||||
}) => {
|
||||
const isBuy = side === 'buy';
|
||||
const displayMarket = (fillRequest?.side === side ? fillRequest.market : null) ?? market;
|
||||
const code = coinCode(displayMarket);
|
||||
const koreanName = getKoreanName(displayMarket);
|
||||
|
||||
const [orderKind, setOrderKind] = useState<OrderKind>('limit');
|
||||
const [priceStr, setPriceStr] = useState('');
|
||||
const [qtyStr, setQtyStr] = useState('');
|
||||
const [totalStr, setTotalStr] = useState('0');
|
||||
const [pctMode, setPctMode] = useState<number | null>(null);
|
||||
const [showSymbolSearch, setShowSymbolSearch] = useState(false);
|
||||
const [symbolQuery, setSymbolQuery] = useState('');
|
||||
const symbolInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const openSymbolSearch = useCallback((q = '') => {
|
||||
setSymbolQuery(q);
|
||||
setShowSymbolSearch(true);
|
||||
}, []);
|
||||
|
||||
const handleSymbolSelect = useCallback((m: string) => {
|
||||
onMarketSelect?.(m);
|
||||
setShowSymbolSearch(false);
|
||||
setSymbolQuery('');
|
||||
symbolInputRef.current?.blur();
|
||||
}, [onMarketSelect]);
|
||||
|
||||
const symbolDisplay = koreanName && koreanName !== displayMarket
|
||||
? `${koreanName} (${code})`
|
||||
: (code || displayMarket);
|
||||
|
||||
useEffect(() => {
|
||||
if (fillRequest && fillRequest.side === side) {
|
||||
setPriceStr(fmtKrw(fillRequest.price));
|
||||
setPctMode(null);
|
||||
return;
|
||||
}
|
||||
if (tradePrice != null && tradePrice > 0) {
|
||||
setPriceStr(fmtKrw(tradePrice));
|
||||
}
|
||||
}, [market, tradePrice, fillRequest?.seq, fillRequest?.side, fillRequest?.market, side]);
|
||||
|
||||
const price = parseNum(priceStr);
|
||||
const qty = parseNum(qtyStr);
|
||||
const step = priceStep(price > 0 ? price : (tradePrice ?? 0));
|
||||
|
||||
useEffect(() => {
|
||||
if (orderKind === 'market') {
|
||||
setTotalStr(availableKrw > 0 ? fmtKrw(availableKrw) : '0');
|
||||
return;
|
||||
}
|
||||
const total = price * qty;
|
||||
setTotalStr(total > 0 ? fmtKrw(total) : '0');
|
||||
}, [price, qty, orderKind, availableKrw]);
|
||||
|
||||
const applyPct = useCallback((pct: number) => {
|
||||
setPctMode(pct);
|
||||
if (isBuy) {
|
||||
if (orderKind === 'market' || availableKrw <= 0) return;
|
||||
const budget = (availableKrw * pct) / 100;
|
||||
const p = orderKind === 'limit' && price > 0 ? price : (tradePrice ?? 0);
|
||||
if (p > 0) {
|
||||
const q = budget / p;
|
||||
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (availableCoinQty <= 0) return;
|
||||
const q = (availableCoinQty * pct) / 100;
|
||||
setQtyStr(q >= 1 ? q.toFixed(8).replace(/\.?0+$/, '') : q.toFixed(8));
|
||||
}, [isBuy, availableKrw, availableCoinQty, orderKind, price, tradePrice]);
|
||||
|
||||
const bumpPrice = useCallback((delta: number) => {
|
||||
const base = price > 0 ? price : (tradePrice ?? 0);
|
||||
const next = Math.max(0, base + delta);
|
||||
setPriceStr(fmtKrw(next));
|
||||
}, [price, tradePrice]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setOrderKind('limit');
|
||||
setQtyStr('');
|
||||
setTotalStr('0');
|
||||
setPctMode(null);
|
||||
if (tradePrice != null && tradePrice > 0) {
|
||||
setPriceStr(fmtKrw(tradePrice));
|
||||
} else {
|
||||
setPriceStr('');
|
||||
}
|
||||
}, [tradePrice]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
const label = isBuy ? '매수' : '매도';
|
||||
const execPrice = orderKind === 'market' && tradePrice != null && tradePrice > 0
|
||||
? tradePrice
|
||||
: price;
|
||||
if (!paperTradingEnabled) {
|
||||
window.alert('모의투자가 비활성화되어 있습니다. 설정 → 모의투자에서 사용을 켜 주세요.');
|
||||
return;
|
||||
}
|
||||
if (execPrice <= 0 || qty <= 0) {
|
||||
window.alert('가격과 수량을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const trade = await placePaperOrder({
|
||||
market: displayMarket,
|
||||
side: isBuy ? 'BUY' : 'SELL',
|
||||
orderKind: orderKind === 'market' ? 'market' : 'limit',
|
||||
price: execPrice,
|
||||
quantity: qty,
|
||||
source: 'MANUAL',
|
||||
});
|
||||
if (!trade) {
|
||||
window.alert('모의 주문에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
onPaperOrderFilled?.();
|
||||
window.alert(
|
||||
`[모의투자] ${koreanName || code} ${label} 체결\n` +
|
||||
`가격: ${fmtKrw(trade.price)} KRW\n` +
|
||||
`수량: ${trade.quantity} ${code}\n` +
|
||||
`수수료: ${fmtKrw(trade.feeAmount)} KRW\n` +
|
||||
`체결 후 현금: ${fmtKrw(trade.cashAfter)} KRW`,
|
||||
);
|
||||
setQtyStr('');
|
||||
setPctMode(null);
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '모의 주문 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
}, [
|
||||
isBuy, koreanName, code, orderKind, priceStr, qtyStr, totalStr, paperTradingEnabled,
|
||||
displayMarket, orderKind, price, qty, tradePrice, onPaperOrderFilled,
|
||||
]);
|
||||
|
||||
const handlePriceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPriceStr(sanitizePriceInput(e.target.value));
|
||||
}, []);
|
||||
|
||||
const handleQtyChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPctMode(null);
|
||||
setQtyStr(sanitizeQtyInput(e.target.value));
|
||||
}, []);
|
||||
|
||||
const priceLabel = isBuy ? '매수가격' : '매도가격';
|
||||
const minOrder = 5000;
|
||||
const feeRate = 0.05;
|
||||
|
||||
return (
|
||||
<div className={`top-panel top-panel--${side}`}>
|
||||
{paperTradingEnabled && !paperAutoTradeEnabled && showSymbolField && (
|
||||
<p className="top-paper-hint">자동매매 OFF · 수동 주문만 모의 계좌에 체결됩니다.</p>
|
||||
)}
|
||||
{showSymbolField && (
|
||||
<div className="top-field">
|
||||
<label className="top-label">종목</label>
|
||||
<div className="top-symbol-field">
|
||||
<input
|
||||
ref={symbolInputRef}
|
||||
type="text"
|
||||
className="top-symbol-input"
|
||||
value={showSymbolSearch ? symbolQuery : symbolDisplay}
|
||||
placeholder="종목명/심볼 검색"
|
||||
onFocus={() => openSymbolSearch('')}
|
||||
onChange={e => openSymbolSearch(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowSymbolSearch(false);
|
||||
setSymbolQuery('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="top-symbol-search-btn"
|
||||
title="종목 검색"
|
||||
onClick={() => {
|
||||
openSymbolSearch(symbolQuery);
|
||||
symbolInputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{showSymbolSearch && ReactDOM.createPortal(
|
||||
<MarketSearchPanel
|
||||
currentMarket={displayMarket}
|
||||
initialQuery={symbolQuery}
|
||||
onSelect={handleSymbolSelect}
|
||||
onClose={() => { setShowSymbolSearch(false); setSymbolQuery(''); }}
|
||||
anchorRect={searchAnchorRef?.current?.getBoundingClientRect() ?? null}
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="top-field">
|
||||
<label className="top-label">
|
||||
주문유형
|
||||
<span className="top-label-help" title="주문 유형 안내">?</span>
|
||||
</label>
|
||||
<div className="top-order-kind">
|
||||
{ORDER_KINDS.map(k => (
|
||||
<button
|
||||
key={k.id}
|
||||
type="button"
|
||||
className={`top-kind-btn${orderKind === k.id ? ' top-kind-btn--active' : ''}${k.id === 'stop_limit' ? ' top-kind-btn--wide' : ''}`}
|
||||
onClick={() => setOrderKind(k.id)}
|
||||
>
|
||||
{k.label}
|
||||
{k.id === 'stop_limit' && <span className="top-kind-arrow">▾</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-row top-row--balance">
|
||||
<span className="top-label">{isBuy ? '주문가능' : '보유수량'}</span>
|
||||
<span className="top-balance">
|
||||
{isBuy
|
||||
? `${fmtKrw(availableKrw)} KRW`
|
||||
: `${availableCoinQty > 0 ? availableCoinQty.toFixed(8).replace(/\.?0+$/, '') : '0'} ${code}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="top-field">
|
||||
<label className="top-label">{priceLabel} (KRW)</label>
|
||||
<div className="top-input-group">
|
||||
<input
|
||||
type="text"
|
||||
className="top-input"
|
||||
value={priceStr}
|
||||
disabled={orderKind === 'market'}
|
||||
onChange={handlePriceChange}
|
||||
inputMode="numeric"
|
||||
pattern="[0-9,]*"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button type="button" className="top-step" onClick={() => bumpPrice(-step)} disabled={orderKind === 'market'}>−</button>
|
||||
<button type="button" className="top-step" onClick={() => bumpPrice(step)} disabled={orderKind === 'market'}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-field">
|
||||
<label className="top-label">주문수량 ({code})</label>
|
||||
<input
|
||||
type="text"
|
||||
className="top-input top-input--full"
|
||||
value={qtyStr}
|
||||
disabled={orderKind === 'market'}
|
||||
onChange={handleQtyChange}
|
||||
inputMode="decimal"
|
||||
pattern="[0-9.]*"
|
||||
autoComplete="off"
|
||||
placeholder="0"
|
||||
/>
|
||||
<div className="top-pct-row">
|
||||
{PCT_BTNS.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={`top-pct-btn${pctMode === p ? ' top-pct-btn--active' : ''}`}
|
||||
onClick={() => applyPct(p)}
|
||||
>
|
||||
{p === 100 ? '최대' : `${p}%`}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={`top-pct-btn top-pct-btn--direct${pctMode === -1 ? ' top-pct-btn--active' : ''}`}
|
||||
onClick={() => setPctMode(-1)}
|
||||
>
|
||||
직접입력
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="top-field">
|
||||
<label className="top-label">주문총액 (KRW)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="top-input top-input--full top-input--readonly"
|
||||
value={totalStr}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="top-meta">
|
||||
<span className="top-meta-line">
|
||||
<span>조건: 보통 ▾</span>
|
||||
<span className="top-meta-sep">·</span>
|
||||
<span>최소 {fmtKrw(minOrder)}</span>
|
||||
<span className="top-meta-sep">·</span>
|
||||
<span>수수료 {feeRate}%</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="top-actions">
|
||||
<button type="button" className="top-reset" onClick={handleReset}>
|
||||
<span className="top-reset-icon">↺</span>
|
||||
초기화
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`top-submit top-submit--${side}`}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{isBuy ? '매수' : '매도'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeOrderPanel;
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 매매 시그널 알림 상세 본문 — 종목·시간·매수/매도·금액 등
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
buildSignalDetailRows,
|
||||
formatSignalPrice,
|
||||
getSignalHeadline,
|
||||
getSignalTypeKo,
|
||||
type TradeSignalDisplaySource,
|
||||
} from '../utils/tradeSignalDisplay';
|
||||
|
||||
interface Props {
|
||||
item: TradeSignalDisplaySource;
|
||||
/** compact: 스택 팝업, full: 목록/모달 요약 */
|
||||
variant?: 'compact' | 'full';
|
||||
showHeadline?: boolean;
|
||||
}
|
||||
|
||||
export const TradeSignalDetailBody: React.FC<Props> = ({
|
||||
item,
|
||||
variant = 'compact',
|
||||
showHeadline = true,
|
||||
}) => {
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
const rows = buildSignalDetailRows(item);
|
||||
|
||||
return (
|
||||
<div className={`tsd-body tsd-body--${variant}`}>
|
||||
{showHeadline && (
|
||||
<div className="tsd-headline">
|
||||
<span className={`tsd-direction ${isBuy ? 'tsd-direction--buy' : 'tsd-direction--sell'}`}>
|
||||
{isBuy ? '↑' : '↓'} {getSignalTypeKo(item.signalType)}
|
||||
</span>
|
||||
<span className="tsd-headline-price">{formatSignalPrice(item.price)}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="tsd-summary">{getSignalHeadline(item)}</p>
|
||||
<dl className="tsd-detail-grid">
|
||||
{rows.map(row => (
|
||||
<React.Fragment key={row.label}>
|
||||
<dt className="tsd-dt">{row.label}</dt>
|
||||
<dd className={`tsd-dd${row.highlight ? ' tsd-dd--highlight' : ''}`}>{row.value}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeSignalDetailBody;
|
||||
@@ -0,0 +1,349 @@
|
||||
/**
|
||||
* 매매 시그널 알림 팝업 — 화면 크기 기반 페이지·좌우 슬라이드
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { bindWindowDrag, clientXYFromReact } from '../utils/pointerDrag';
|
||||
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
|
||||
import TradeSignalDetailBody from './TradeSignalDetailBody';
|
||||
import { getSignalHeadline } from '../utils/tradeSignalDisplay';
|
||||
import {
|
||||
type TradeAlertPopupLayout,
|
||||
type TradeAlertPopupPosition,
|
||||
normalizeTradeAlertGridCols,
|
||||
tradeAlertPopupClassNames,
|
||||
} from '../utils/tradeAlertPopupLayout';
|
||||
import { useToastPageCapacity } from '../hooks/useToastPageCapacity';
|
||||
|
||||
interface Props {
|
||||
onGoToChart: (market: string) => void;
|
||||
onOpenList: () => void;
|
||||
popupPosition?: TradeAlertPopupPosition;
|
||||
popupLayout?: TradeAlertPopupLayout;
|
||||
gridCols?: number;
|
||||
}
|
||||
|
||||
const IcSlidePrev = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="15 6 9 12 15 18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IcSlideNext = () => (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const TradeSignalSnackbar: React.FC<Props> = ({
|
||||
onGoToChart,
|
||||
onOpenList,
|
||||
popupPosition = 'right',
|
||||
popupLayout = 'stack',
|
||||
gridCols = 2,
|
||||
}) => {
|
||||
const {
|
||||
toastNotifications,
|
||||
dismissToast,
|
||||
dismissToasts,
|
||||
dismissAllToasts,
|
||||
openDetail,
|
||||
} = useTradeNotification();
|
||||
|
||||
const cols = normalizeTradeAlertGridCols(gridCols);
|
||||
const { pageSize, effectiveCols } = useToastPageCapacity(popupLayout, popupPosition, cols);
|
||||
const { stack, cards } = tradeAlertPopupClassNames(popupPosition, popupLayout);
|
||||
const layoutKey = `${popupPosition}-${popupLayout}-${cols}`;
|
||||
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const swipeRef = useRef<{ startX: number; startY: number } | null>(null);
|
||||
|
||||
const positionsRef = useRef<Record<string, { x: number; y: number }>>({});
|
||||
const dragRef = useRef<{
|
||||
key: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startLeft: number;
|
||||
startTop: number;
|
||||
} | null>(null);
|
||||
const [, forceDragRender] = React.useReducer((x: number) => x + 1, 0);
|
||||
const unbindDragRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(toastNotifications.length / pageSize));
|
||||
const safePage = Math.min(pageIndex, totalPages - 1);
|
||||
|
||||
const pageItems = useMemo(() => {
|
||||
const start = safePage * pageSize;
|
||||
return toastNotifications.slice(start, start + pageSize);
|
||||
}, [toastNotifications, safePage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
positionsRef.current = {};
|
||||
setPageIndex(0);
|
||||
}, [layoutKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setPageIndex(p => Math.min(p, Math.max(0, totalPages - 1)));
|
||||
}, [totalPages, pageSize]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setPageIndex(p => Math.max(0, p - 1));
|
||||
}, []);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setPageIndex(p => Math.min(totalPages - 1, p + 1));
|
||||
}, [totalPages]);
|
||||
|
||||
const dismissCurrentPage = useCallback(() => {
|
||||
dismissToasts(pageItems.map(i => i.id));
|
||||
}, [dismissToasts, pageItems]);
|
||||
|
||||
const handleDragStart = useCallback((e: React.PointerEvent, key: string) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
e.preventDefault();
|
||||
const el = (e.currentTarget as HTMLElement).closest('[data-tsn-card]') as HTMLElement;
|
||||
if (!el) return;
|
||||
try {
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
const pos = positionsRef.current[key];
|
||||
const { x, y } = clientXYFromReact(e);
|
||||
dragRef.current = {
|
||||
key,
|
||||
startX: x,
|
||||
startY: y,
|
||||
startLeft: pos?.x ?? rect.left,
|
||||
startTop: pos?.y ?? rect.top,
|
||||
};
|
||||
unbindDragRef.current?.();
|
||||
unbindDragRef.current = bindWindowDrag(
|
||||
({ x: cx, y: cy }) => {
|
||||
const s = dragRef.current;
|
||||
if (!s) return;
|
||||
positionsRef.current = {
|
||||
...positionsRef.current,
|
||||
[s.key]: {
|
||||
x: s.startLeft + (cx - s.startX),
|
||||
y: s.startTop + (cy - s.startY),
|
||||
},
|
||||
};
|
||||
forceDragRender();
|
||||
},
|
||||
() => {
|
||||
dragRef.current = null;
|
||||
unbindDragRef.current = null;
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleSwipeStart = useCallback((e: React.PointerEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return;
|
||||
swipeRef.current = { startX: e.clientX, startY: e.clientY };
|
||||
}, []);
|
||||
|
||||
const handleSwipeEnd = useCallback((e: React.PointerEvent) => {
|
||||
const s = swipeRef.current;
|
||||
swipeRef.current = null;
|
||||
if (!s || totalPages <= 1) return;
|
||||
const dx = e.clientX - s.startX;
|
||||
const dy = e.clientY - s.startY;
|
||||
if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy)) return;
|
||||
if (dx < 0) goNext();
|
||||
else goPrev();
|
||||
}, [goNext, goPrev, totalPages]);
|
||||
|
||||
if (toastNotifications.length === 0) return null;
|
||||
|
||||
const compactCard = popupLayout === 'grid' || popupLayout === 'strip';
|
||||
const gridColsEffective = popupLayout === 'grid' ? effectiveCols : cols;
|
||||
const containerStyle = popupLayout === 'grid'
|
||||
? ({ ['--tsn-grid-cols' as string]: gridColsEffective } as React.CSSProperties)
|
||||
: undefined;
|
||||
const showSlideNav = totalPages > 1;
|
||||
|
||||
const cardList = (
|
||||
<>
|
||||
{pageItems.map((item, index) => (
|
||||
<ToastCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
stackIndex={index}
|
||||
compact={compactCard}
|
||||
position={positionsRef.current[item.id]}
|
||||
onDragStart={handleDragStart}
|
||||
onClose={() => {
|
||||
delete positionsRef.current[item.id];
|
||||
dismissToast(item.id);
|
||||
}}
|
||||
onDetail={() => openDetail(item)}
|
||||
onChart={() => {
|
||||
delete positionsRef.current[item.id];
|
||||
dismissToast(item.id);
|
||||
onGoToChart(item.market);
|
||||
}}
|
||||
onList={onOpenList}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const cardsProps = {
|
||||
className: `${cards} tsn-cards--paged`,
|
||||
style: containerStyle,
|
||||
onPointerDown: handleSwipeStart,
|
||||
onPointerUp: handleSwipeEnd,
|
||||
onPointerCancel: () => { swipeRef.current = null; },
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={layoutKey}
|
||||
className={`${stack}${showSlideNav ? ' tsn-stack--has-nav' : ''}`}
|
||||
style={containerStyle}
|
||||
aria-live="polite"
|
||||
aria-label="매매 시그널 알림"
|
||||
>
|
||||
<div className="tsn-stack-toolbar">
|
||||
<div className="tsn-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="tsn-dismiss-page"
|
||||
onClick={dismissCurrentPage}
|
||||
title={`현재 화면 알림 ${pageItems.length}건 닫기`}
|
||||
>
|
||||
알림창닫기 ({pageItems.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tsn-dismiss-all"
|
||||
onClick={dismissAllToasts}
|
||||
title={`대기 중인 알림 ${toastNotifications.length}건 전체 닫기`}
|
||||
>
|
||||
전체닫기 ({toastNotifications.length})
|
||||
</button>
|
||||
</div>
|
||||
{showSlideNav && (
|
||||
<span className="tsn-pager-label">
|
||||
{safePage + 1} / {totalPages}
|
||||
<span className="tsn-pager-sub"> · 화면당 {pageSize}건</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`tsn-slide-row${showSlideNav ? ' tsn-slide-row--nav' : ''}`}>
|
||||
{showSlideNav && (
|
||||
<button
|
||||
type="button"
|
||||
className="tsn-slide-nav tsn-slide-nav--prev"
|
||||
onClick={goPrev}
|
||||
disabled={safePage <= 0}
|
||||
aria-label="이전 알림 목록"
|
||||
title="이전 알림"
|
||||
>
|
||||
<IcSlidePrev />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showSlideNav ? (
|
||||
<div className="tsn-slide-body">
|
||||
<div {...cardsProps}>{cardList}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div {...cardsProps}>{cardList}</div>
|
||||
)}
|
||||
|
||||
{showSlideNav && (
|
||||
<button
|
||||
type="button"
|
||||
className="tsn-slide-nav tsn-slide-nav--next"
|
||||
onClick={goNext}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
aria-label="다음 알림 목록"
|
||||
title="다음 알림"
|
||||
>
|
||||
<IcSlideNext />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ToastCard: React.FC<{
|
||||
item: TradeNotificationItem;
|
||||
stackIndex: number;
|
||||
compact?: boolean;
|
||||
position?: { x: number; y: number };
|
||||
onDragStart: (e: React.PointerEvent, key: string) => void;
|
||||
onClose: () => void;
|
||||
onDetail: () => void;
|
||||
onChart: () => void;
|
||||
onList: () => void;
|
||||
}> = ({ item, stackIndex, compact, position, onDragStart, onClose, onDetail, onChart, onList }) => {
|
||||
const isBuy = item.signalType === 'BUY';
|
||||
const isDragged = Boolean(position);
|
||||
const headline = getSignalHeadline(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tsn-card
|
||||
className={`tsn-card ${isBuy ? 'tsn-card--buy' : 'tsn-card--sell'}${stackIndex === 0 ? ' tsn-card--latest' : ''}${compact ? ' tsn-card--compact' : ''}${isDragged ? ' tsn-card--dragging' : ''}`}
|
||||
style={
|
||||
isDragged
|
||||
? {
|
||||
position: 'fixed',
|
||||
left: position!.x,
|
||||
top: position!.y,
|
||||
right: 'auto',
|
||||
width: compact ? 260 : 380,
|
||||
zIndex: 10050 + stackIndex,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header tsn-card-header"
|
||||
onPointerDown={e => onDragStart(e, item.id)}
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<span className={`tsn-badge ${isBuy ? 'tsn-badge--buy' : 'tsn-badge--sell'}`}>
|
||||
{item.signalType}
|
||||
</span>
|
||||
<span className="gc-popup-title tsn-card-title" title={headline}>
|
||||
{headline}
|
||||
</span>
|
||||
<button type="button" className="gc-popup-close tsn-icon-btn" onClick={onClose} title="닫기" aria-label="닫기">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!compact && <TradeSignalDetailBody item={item} variant="compact" />}
|
||||
|
||||
<div className={`tsn-card-actions${compact ? ' tsn-card-actions--compact' : ''}`}>
|
||||
<button type="button" className="tsn-action-btn tsn-action-btn--primary" onClick={onChart}>
|
||||
차트
|
||||
</button>
|
||||
{!compact && (
|
||||
<>
|
||||
<button type="button" className="tsn-action-btn" onClick={onDetail}>
|
||||
상세
|
||||
</button>
|
||||
<button type="button" className="tsn-action-btn" onClick={onList}>
|
||||
목록
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{compact && (
|
||||
<button type="button" className="tsn-action-btn" onClick={onDetail}>
|
||||
상세
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TradeSignalSnackbar;
|
||||
@@ -0,0 +1,951 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import type { MouseEventParams, Time } from 'lightweight-charts';
|
||||
import type { OHLCVBar, ChartType, Theme, IndicatorConfig, LegendData, Drawing, ChartMode, Timeframe } from '../types';
|
||||
import { ChartManager } from '../utils/ChartManager';
|
||||
import { setIndicatorChartContext } from '../utils/indicatorRegistry';
|
||||
import { DISPLAY_COUNT } from '../hooks/useUpbitData';
|
||||
import DrawingCanvas, { hitTestDrawing } from './DrawingCanvas';
|
||||
import PaneLegend, { type PaneLegendProps } from './PaneLegend';
|
||||
import ChartHoverToolbar from './ChartHoverToolbar';
|
||||
import ChartMagnifier from './ChartMagnifier';
|
||||
import ChartContextMenu from './ChartContextMenu';
|
||||
import CandlePaneControls from './CandlePaneControls';
|
||||
import { getKoreanName } from '../utils/marketNameCache';
|
||||
import { DEFAULT_DISPLAY_TIMEZONE } from '../utils/timezone';
|
||||
|
||||
interface TradingChartProps {
|
||||
bars: OHLCVBar[];
|
||||
/** BB 등 다른 심볼 계산용 */
|
||||
market?: string;
|
||||
timeframe?: Timeframe;
|
||||
chartType: ChartType;
|
||||
theme: Theme;
|
||||
mode: ChartMode;
|
||||
indicators: IndicatorConfig[];
|
||||
drawingTool: string;
|
||||
drawings: Drawing[];
|
||||
logScale: boolean;
|
||||
drawingsLocked?: boolean;
|
||||
drawingsVisible?: boolean;
|
||||
onCrosshair: (data: LegendData | null) => void;
|
||||
onManagerReady: (mgr: ChartManager) => void;
|
||||
onAddDrawing: (d: Drawing) => void;
|
||||
/** 시리즈 단일 클릭 → (indicatorId | '__main__' | null, 패인 좌측상단 screen 좌표) */
|
||||
onSeriesClick?: (entryId: string | null, point: { x: number; y: number }) => void;
|
||||
/** 시리즈 더블 클릭 → 설정 모달 직접 오픈용 (패인 좌측상단 screen 좌표 포함) */
|
||||
onSeriesDoubleClick?: (entryId: string | null, point: { x: number; y: number }) => void;
|
||||
/** pane 레전드 레이블 호버 */
|
||||
onHoverPaneLegend?: (id: string, sx: number, sy: number) => void;
|
||||
/** pane 레전드 레이블 이탈 */
|
||||
onLeavePaneLegend?: () => void;
|
||||
/** 멀티차트 전체 보기: 이 차트를 단일 모드로 확장 (있으면 hover toolbar 버튼에 연결) */
|
||||
onFullView?: () => void;
|
||||
/** 현재 선택된 드로잉 ID (핸들 렌더용) */
|
||||
selectedDrawingId?: string | null;
|
||||
/** cursor 모드에서 드로잉 단일 클릭 */
|
||||
onDrawingClick?: (id: string | null, screenX: number, screenY: number) => void;
|
||||
/** cursor 모드에서 드로잉 더블 클릭 */
|
||||
onDrawingDoubleClick?: (id: string, screenX: number, screenY: number) => void;
|
||||
/** 돋보기 활성 여부 */
|
||||
magnifierEnabled?: boolean;
|
||||
/** 돋보기 닫기 콜백 */
|
||||
onMagnifierClose?: () => void;
|
||||
/**
|
||||
* 데이터 로드 완료 콜백 — reloadAll 이 setInitialVisibleRange 까지 마친 뒤 호출.
|
||||
* ChartSlot 에서 멀티차트 첫 마운트 시 sync range 를 재적용하는 데 사용.
|
||||
*/
|
||||
onDataLoaded?: () => void;
|
||||
/** 보조지표 pane 순서 변경 (드래그 핸들): fromId 를 insertBeforeId 앞으로 이동 (null = 맨 뒤) */
|
||||
onReorderIndicators?: (fromId: string, insertBeforeId: string | null) => void;
|
||||
/** 보조지표 pane 병합 */
|
||||
onMergeIndicators?: (fromId: string, intoId: string) => void;
|
||||
/** 병합 pane 분리 */
|
||||
onSplitIndicatorPane?: (hostId: string) => void;
|
||||
/** 지표 단독 전체화면 확장 */
|
||||
onExpandIndicator?: (id: string) => void;
|
||||
/** 지표 제거 (X 버튼) */
|
||||
onRemoveIndicator?: (id: string) => void;
|
||||
/** 보조지표 pane 복사 */
|
||||
onDuplicateIndicator?: (id: string) => void;
|
||||
/** 현재 단독 전체화면 중인 지표 id */
|
||||
focusedIndicatorId?: string | null;
|
||||
/** 전체화면 → 전체 보기 복원 */
|
||||
onRestoreIndicators?: () => void;
|
||||
/** 우클릭 메뉴에서 매수·매도 선택 시 */
|
||||
onTradeOrderRequest?: (req: { market: string; price: number; side: 'buy' | 'sell' }) => void;
|
||||
/** 표시 시간대 (IANA) */
|
||||
displayTimezone?: string;
|
||||
}
|
||||
|
||||
const TradingChart: React.FC<TradingChartProps> = ({
|
||||
bars, market = '', timeframe = '1D', chartType, theme, mode, indicators, drawingTool, drawings,
|
||||
logScale, drawingsLocked = false, drawingsVisible = true,
|
||||
onCrosshair, onManagerReady, onAddDrawing,
|
||||
onSeriesClick, onSeriesDoubleClick,
|
||||
onHoverPaneLegend, onLeavePaneLegend,
|
||||
onFullView,
|
||||
selectedDrawingId,
|
||||
onDrawingClick,
|
||||
onDrawingDoubleClick,
|
||||
magnifierEnabled = false,
|
||||
onMagnifierClose,
|
||||
onDataLoaded,
|
||||
onReorderIndicators,
|
||||
onMergeIndicators,
|
||||
onSplitIndicatorPane,
|
||||
onExpandIndicator,
|
||||
onRemoveIndicator,
|
||||
onDuplicateIndicator,
|
||||
focusedIndicatorId,
|
||||
onRestoreIndicators,
|
||||
onTradeOrderRequest,
|
||||
displayTimezone = DEFAULT_DISPLAY_TIMEZONE,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
|
||||
const managerRef = useRef<ChartManager | null>(null);
|
||||
const barsRef = useRef<OHLCVBar[]>([]);
|
||||
// 드로잉 최신 참조 (캡처 리스너에서 closure 없이 접근)
|
||||
const drawingsRef = useRef<Drawing[]>(drawings);
|
||||
const drawingToolRef = useRef<string>(drawingTool);
|
||||
const drawingsVisibleRef = useRef<boolean>(drawingsVisible);
|
||||
const drawingsLockedRef = useRef<boolean>(drawingsLocked);
|
||||
const modeRef = useRef<ChartMode>(mode);
|
||||
const onDrawingClickRef = useRef(onDrawingClick);
|
||||
const onDrawingDoubleClickRef = useRef(onDrawingDoubleClick);
|
||||
|
||||
const canPanRef = useRef(false);
|
||||
const panStateRef = useRef({
|
||||
active: false,
|
||||
lastX: 0,
|
||||
lastY: 0,
|
||||
moved: false,
|
||||
/** 0=캔들 pane — 세로 패닝 허용 */
|
||||
originPaneIndex: 0,
|
||||
});
|
||||
const suppressClickRef = useRef(false);
|
||||
const seriesDblSuppressRef = useRef(false);
|
||||
const toggleCandleOnlyRef = useRef<() => void>(() => {});
|
||||
const candleOnlyModeRef = useRef(false);
|
||||
const lastCrosshairPriceRef = useRef<number | null>(null);
|
||||
const onTradeOrderRequestRef = useRef(onTradeOrderRequest);
|
||||
onTradeOrderRequestRef.current = onTradeOrderRequest;
|
||||
const onSeriesDoubleClickRef = useRef(onSeriesDoubleClick);
|
||||
onSeriesDoubleClickRef.current = onSeriesDoubleClick;
|
||||
const marketRef = useRef(market);
|
||||
marketRef.current = market;
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{
|
||||
x: number; y: number; price: number;
|
||||
} | null>(null);
|
||||
|
||||
// 드로잉 더블클릭 감지용
|
||||
const lastDrawingClickRef = useRef({ id: '', time: 0 });
|
||||
const drawingSingleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// 직전 값 추적 (불필요한 재실행 방지)
|
||||
const prevBarsKey = useRef<string>('');
|
||||
const prevIndKey = useRef<string>('');
|
||||
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
|
||||
const prevChartType = useRef<ChartType>(chartType);
|
||||
const prevTheme = useRef<Theme>(theme);
|
||||
const prevLogScale = useRef<boolean>(logScale);
|
||||
|
||||
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
|
||||
/** 캔들 pane 전체보기 (오버레이 지표 유지, 하단 보조지표 pane 숨김) */
|
||||
const [candleOnlyMode, setCandleOnlyMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
barsRef.current = bars;
|
||||
}, [bars]);
|
||||
|
||||
const toggleCandleOnly = useCallback(() => {
|
||||
setCandleOnlyMode(v => !v);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { toggleCandleOnlyRef.current = toggleCandleOnly; }, [toggleCandleOnly]);
|
||||
useEffect(() => { candleOnlyModeRef.current = candleOnlyMode; }, [candleOnlyMode]);
|
||||
const prevCandleOnlyRef = useRef(false);
|
||||
|
||||
// 최신 값을 ref에 동기화 (캡처 리스너 closure 탈출)
|
||||
useEffect(() => {
|
||||
setIndicatorChartContext(market, timeframe);
|
||||
}, [market, timeframe]);
|
||||
|
||||
useEffect(() => { drawingsRef.current = drawings; }, [drawings]);
|
||||
useEffect(() => { drawingToolRef.current = drawingTool; }, [drawingTool]);
|
||||
useEffect(() => { drawingsVisibleRef.current = drawingsVisible; }, [drawingsVisible]);
|
||||
useEffect(() => { drawingsLockedRef.current = drawingsLocked; }, [drawingsLocked]);
|
||||
useEffect(() => { modeRef.current = mode; }, [mode]);
|
||||
useEffect(() => { onDrawingClickRef.current = onDrawingClick; }, [onDrawingClick]);
|
||||
useEffect(() => { onDrawingDoubleClickRef.current = onDrawingDoubleClick; }, [onDrawingDoubleClick]);
|
||||
useEffect(() => {
|
||||
canPanRef.current = drawingTool === 'cursor';
|
||||
const el = containerRef.current;
|
||||
if (el && !panStateRef.current.active) {
|
||||
el.style.cursor = canPanRef.current ? 'grab' : '';
|
||||
el.style.touchAction = canPanRef.current ? 'none' : '';
|
||||
}
|
||||
}, [mode, drawingTool]);
|
||||
|
||||
/**
|
||||
* pane 높이 재배분 + 스크롤 컨테이너 확장
|
||||
*
|
||||
* wrapper.clientHeight 를 정확한 가용 높이로 ChartManager 에 전달합니다.
|
||||
* CSS Grid 레이아웃 초기화 지연으로 wrapperH=0 이 될 수 있으므로,
|
||||
* retryCount 를 통해 최대 6회 자동 재시도합니다 (100→200→300ms…)
|
||||
*/
|
||||
const applyPaneLayout = useCallback((mgr: ChartManager, retryCount = 0) => {
|
||||
const wrapper = wrapperRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!wrapper || !container) return;
|
||||
|
||||
const wrapperH = wrapper.clientHeight;
|
||||
if (wrapperH <= 0) {
|
||||
// CSS Grid 높이가 아직 계산되지 않은 경우: 지연 후 재시도 (최대 6회)
|
||||
if (retryCount < 6) {
|
||||
setTimeout(() => applyPaneLayout(mgr, retryCount + 1), 100 * (retryCount + 1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let required: number;
|
||||
if (candleOnlyModeRef.current) {
|
||||
mgr.applyCandleOnlyLayout(true, wrapperH);
|
||||
required = wrapperH;
|
||||
} else {
|
||||
if (mgr.isCandleOnlyLayout()) {
|
||||
mgr.restoreFromCandleFullscreen(wrapperH);
|
||||
}
|
||||
required = mgr.resetPaneHeights(wrapperH);
|
||||
}
|
||||
|
||||
if (required > wrapperH + 4) {
|
||||
container.style.height = `${required}px`;
|
||||
} else {
|
||||
container.style.height = '';
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || !chartMgr) return;
|
||||
const wasOnly = prevCandleOnlyRef.current;
|
||||
prevCandleOnlyRef.current = candleOnlyMode;
|
||||
if (!wasOnly && candleOnlyMode) {
|
||||
applyPaneLayout(mgr);
|
||||
} else if (wasOnly && !candleOnlyMode && mgr.hasMainSeries()) {
|
||||
const runRestore = () => {
|
||||
const m = managerRef.current;
|
||||
if (!m?.hasMainSeries()) return;
|
||||
const wrapperH = wrapperRef.current?.clientHeight ?? 0;
|
||||
m.restoreFromCandleFullscreen(wrapperH);
|
||||
applyPaneLayout(m);
|
||||
const futureBars = m.hasIchimoku() ? 28 : 0;
|
||||
m.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
|
||||
};
|
||||
requestAnimationFrame(() => requestAnimationFrame(runRestore));
|
||||
[200, 500, 1000].forEach(delay => setTimeout(runRestore, delay));
|
||||
}
|
||||
}, [candleOnlyMode, chartMgr, applyPaneLayout]);
|
||||
|
||||
// ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
|
||||
const reloadAll = useCallback(async (
|
||||
mgr: ChartManager,
|
||||
newBars: OHLCVBar[],
|
||||
ct: ChartType,
|
||||
th: Theme,
|
||||
ls: boolean,
|
||||
inds: IndicatorConfig[],
|
||||
) => {
|
||||
if (newBars.length === 0) return;
|
||||
|
||||
barsRef.current = newBars;
|
||||
prevChartType.current = ct;
|
||||
prevTheme.current = th;
|
||||
prevLogScale.current = ls;
|
||||
prevBarsKey.current = barsKey(newBars);
|
||||
prevIndKey.current = indKey(inds);
|
||||
prevSortedPKRef.current = sortedParamKey(inds);
|
||||
|
||||
mgr.setData(newBars, ct, th);
|
||||
mgr.setTheme(th);
|
||||
mgr.setLogScale(ls);
|
||||
mgr.setDisplayTimezone(displayTimezone, timeframe);
|
||||
|
||||
// 인디케이터를 순차적으로 등록 (async/await 보장)
|
||||
for (const ind of inds) {
|
||||
await mgr.addIndicator(ind);
|
||||
}
|
||||
|
||||
// 인디케이터 추가 완료 후 RAF × 2 대기
|
||||
// → LWC 가 모든 pane 을 DOM 에 확정한 뒤 높이·X축 재설정
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
applyPaneLayout(mgr); // ① pane 높이 재배분 (wrapper 높이 기준, 0이면 자동 재시도)
|
||||
requestAnimationFrame(() => {
|
||||
const futureBars = mgr.hasIchimoku() ? 28 : 0;
|
||||
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
|
||||
resolve();
|
||||
});
|
||||
})));
|
||||
|
||||
// 데이터 로드 완료 알림: 멀티차트 sync range 재적용 등 외부 콜백 처리용
|
||||
onDataLoaded?.();
|
||||
|
||||
// ── 안전망: 멀티레이아웃에서 CSS Grid 높이 확정이 늦어진 경우를 위한 지연 재적용
|
||||
// applyPaneLayout 이 이미 재시도 중이지만, setInitialVisibleRange 도 재실행 필요할 수 있음
|
||||
const safetyTimers = [300, 700, 1400].map(delay =>
|
||||
setTimeout(() => {
|
||||
const m = managerRef.current;
|
||||
if (!m || !m.hasMainSeries()) return;
|
||||
const w = wrapperRef.current;
|
||||
if (!w || w.clientHeight <= 0) return;
|
||||
m.resetPaneHeights(w.clientHeight);
|
||||
const fb = m.hasIchimoku() ? 28 : 0;
|
||||
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
||||
}, delay)
|
||||
);
|
||||
// cleanup 시 타이머 해제 (컴포넌트 언마운트 대응)
|
||||
// 반환값이 없으므로 managerRef 체크로 충분
|
||||
void safetyTimers; // 타이머는 managerRef null 체크로 자동 무효화됨
|
||||
}, [applyPaneLayout, onDataLoaded]);
|
||||
|
||||
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const mgr = new ChartManager(containerRef.current, theme);
|
||||
managerRef.current = mgr;
|
||||
setChartMgr(mgr);
|
||||
onManagerReady(mgr);
|
||||
|
||||
const unsub = mgr.subscribeCrosshair((p: MouseEventParams<Time>) => {
|
||||
if (p.point?.y != null) {
|
||||
const yPrice = mgr.yToPrice(p.point.y);
|
||||
if (yPrice != null && yPrice > 0) lastCrosshairPriceRef.current = yPrice;
|
||||
}
|
||||
if (!p.time) { onCrosshair(null); return; }
|
||||
const bar = mgr.getBarAtTime(p.time as number)
|
||||
?? barsRef.current.find(b => b.time === p.time);
|
||||
if (bar) {
|
||||
const crosshairPrice = p.point?.y != null ? mgr.yToPrice(p.point.y) ?? undefined : undefined;
|
||||
const indicatorValues = mgr.getIndicatorValuesFromParams(p);
|
||||
onCrosshair({
|
||||
time: p.time as number,
|
||||
open: bar.open, high: bar.high, low: bar.low,
|
||||
close: bar.close, volume: bar.volume,
|
||||
crosshairPrice: crosshairPrice ?? undefined,
|
||||
indicatorValues,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const container = containerRef.current!;
|
||||
|
||||
// ── 드로잉 클릭/더블클릭 감지 (capture phase → 인디케이터 클릭보다 우선) ──────
|
||||
// cursor 모드 + trading + visible + !locked 일 때만 처리
|
||||
const handleDrawingCapture = (e: MouseEvent) => {
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
e.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
modeRef.current !== 'trading' ||
|
||||
drawingToolRef.current !== 'cursor'||
|
||||
!drawingsVisibleRef.current ||
|
||||
drawingsLockedRef.current
|
||||
) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const cssW = container.clientWidth;
|
||||
const cssH = container.clientHeight;
|
||||
const m = managerRef.current;
|
||||
if (!m) return;
|
||||
|
||||
// 역순(나중에 그린 것 우선)으로 hit-test
|
||||
const ds = drawingsRef.current;
|
||||
for (let i = ds.length - 1; i >= 0; i--) {
|
||||
if (hitTestDrawing(cx, cy, ds[i], m, cssW, cssH)) {
|
||||
const hitId = ds[i].id;
|
||||
// 인디케이터 컨텍스트 툴바가 열리지 않도록 이벤트 전파 차단
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
const now = Date.now();
|
||||
const isDouble =
|
||||
now - lastDrawingClickRef.current.time < 400 &&
|
||||
lastDrawingClickRef.current.id === hitId;
|
||||
lastDrawingClickRef.current = { id: hitId, time: now };
|
||||
|
||||
if (isDouble) {
|
||||
if (drawingSingleTimerRef.current) {
|
||||
clearTimeout(drawingSingleTimerRef.current);
|
||||
drawingSingleTimerRef.current = null;
|
||||
}
|
||||
onDrawingDoubleClickRef.current?.(hitId, e.clientX, e.clientY);
|
||||
} else {
|
||||
if (drawingSingleTimerRef.current) clearTimeout(drawingSingleTimerRef.current);
|
||||
drawingSingleTimerRef.current = setTimeout(() => {
|
||||
drawingSingleTimerRef.current = null;
|
||||
onDrawingClickRef.current?.(hitId, e.clientX, e.clientY);
|
||||
}, 220);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 빈 공간 클릭 → 선택 해제
|
||||
if (
|
||||
modeRef.current === 'trading' &&
|
||||
drawingToolRef.current === 'cursor'
|
||||
) {
|
||||
onDrawingClickRef.current?.(null, e.clientX, e.clientY);
|
||||
}
|
||||
};
|
||||
|
||||
// capture:true → subscribeSeriesClick(bubble) 보다 먼저 실행
|
||||
container.addEventListener('click', handleDrawingCapture, { capture: true });
|
||||
|
||||
const onContextMenuCapture = (e: MouseEvent) => {
|
||||
if (!onTradeOrderRequestRef.current || !marketRef.current) return;
|
||||
const m = managerRef.current;
|
||||
if (!m) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const chartX = e.clientX - rect.left;
|
||||
const chartY = e.clientY - rect.top;
|
||||
const price = m.getTradePriceAtChartPoint(chartX, chartY);
|
||||
if (price == null) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, price });
|
||||
};
|
||||
container.addEventListener('contextmenu', onContextMenuCapture, { capture: true });
|
||||
|
||||
const onPanPointerDown = (e: PointerEvent) => {
|
||||
if (!canPanRef.current || e.button !== 0) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const chartX = e.clientX - rect.left;
|
||||
const chartY = e.clientY - rect.top;
|
||||
if (mgr.isOnPriceAxis(chartX, chartY) || mgr.isOnTimeAxis(chartY)) return;
|
||||
const originPaneIndex = mgr.getPaneIndexAtChartY(chartY);
|
||||
try {
|
||||
container.setPointerCapture(e.pointerId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
panStateRef.current = {
|
||||
active: true,
|
||||
lastX: e.clientX,
|
||||
lastY: e.clientY,
|
||||
moved: false,
|
||||
originPaneIndex,
|
||||
};
|
||||
container.style.cursor = 'grabbing';
|
||||
};
|
||||
|
||||
const onPanPointerMove = (e: PointerEvent) => {
|
||||
if (!panStateRef.current.active) return;
|
||||
const dx = e.clientX - panStateRef.current.lastX;
|
||||
const dy = e.clientY - panStateRef.current.lastY;
|
||||
panStateRef.current.lastX = e.clientX;
|
||||
panStateRef.current.lastY = e.clientY;
|
||||
const allowVertical = panStateRef.current.originPaneIndex === 0;
|
||||
if (!panStateRef.current.moved) {
|
||||
const hitY = allowVertical && Math.abs(dy) > 2;
|
||||
if (Math.abs(dx) > 2 || hitY) panStateRef.current.moved = true;
|
||||
}
|
||||
if (!panStateRef.current.moved) return;
|
||||
if (e.cancelable) e.preventDefault();
|
||||
managerRef.current?.panByPixelDelta(dx, dy, {
|
||||
allowVerticalPan: allowVertical,
|
||||
});
|
||||
};
|
||||
|
||||
const onPanPointerUp = () => {
|
||||
if (!panStateRef.current.active) return;
|
||||
if (panStateRef.current.moved) suppressClickRef.current = true;
|
||||
panStateRef.current.active = false;
|
||||
container.style.cursor = canPanRef.current ? 'grab' : '';
|
||||
};
|
||||
|
||||
container.addEventListener('pointerdown', onPanPointerDown);
|
||||
window.addEventListener('pointermove', onPanPointerMove);
|
||||
window.addEventListener('pointerup', onPanPointerUp);
|
||||
window.addEventListener('pointercancel', onPanPointerUp);
|
||||
|
||||
const onWheelCapture = (e: WheelEvent) => {
|
||||
const m = managerRef.current;
|
||||
if (!m) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const chartX = e.clientX - rect.left;
|
||||
const chartY = e.clientY - rect.top;
|
||||
if (!m.isCandlePanePriceAxis(chartX, chartY)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
m.zoomMainPriceByWheel(e.deltaY, chartY);
|
||||
};
|
||||
container.addEventListener('wheel', onWheelCapture, { capture: true, passive: false });
|
||||
|
||||
// subscribeSeriesClick 은 이제 native DOM 이벤트 기반.
|
||||
// point 는 ChartManager 내부에서 chart-container 좌표로 계산된 screen 좌표.
|
||||
/** 캔들 pane: 시리즈 위 → 설정 팝업, 빈 공간 → 전체보기/원복 토글 */
|
||||
const onCandlePaneDblClick = (e: MouseEvent) => {
|
||||
const m = managerRef.current;
|
||||
if (!m) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const chartX = e.clientX - rect.left;
|
||||
const chartY = e.clientY - rect.top;
|
||||
|
||||
const entryId = m.resolveEntryAtChartPoint(chartX, chartY);
|
||||
if (entryId && entryId !== '__main__') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
suppressClickRef.current = true;
|
||||
seriesDblSuppressRef.current = true;
|
||||
onSeriesDoubleClickRef.current?.(entryId, { x: e.clientX, y: e.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m.isInCandlePlotArea(chartX, chartY)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
suppressClickRef.current = true;
|
||||
seriesDblSuppressRef.current = true;
|
||||
|
||||
if (m.isClickOnMainSeries(chartX, chartY)) {
|
||||
onSeriesDoubleClickRef.current?.('__main__', { x: e.clientX, y: e.clientY });
|
||||
} else {
|
||||
toggleCandleOnlyRef.current();
|
||||
}
|
||||
};
|
||||
container.addEventListener('dblclick', onCandlePaneDblClick, { capture: true });
|
||||
|
||||
const unsubClick = mgr.subscribeSeriesClick(
|
||||
(entryId, point) => { onSeriesClick?.(entryId, point); },
|
||||
(entryId, point) => {
|
||||
if (seriesDblSuppressRef.current) {
|
||||
seriesDblSuppressRef.current = false;
|
||||
return;
|
||||
}
|
||||
// 캔들 pane 더블클릭은 dblclick 캡처에서 처리 (시리즈/빈공간 구분)
|
||||
if (entryId === '__main__') return;
|
||||
onSeriesDoubleClick?.(entryId, point);
|
||||
},
|
||||
);
|
||||
|
||||
// autoSize:true 를 쓰면 LWC 가 내부 ResizeObserver 로 자동 크기 조절.
|
||||
// 이 외부 ResizeObserver 는 applyPaneLayout 재트리거 + 데이터 재로드 보장용.
|
||||
let prevW = 0, prevH = 0;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width === prevW && height === prevH) return;
|
||||
// 이전 크기가 0이었다가 유효해진 경우 (= display:none → visible 전환)
|
||||
const wasHidden = prevW <= 0 || prevH <= 0;
|
||||
prevW = width; prevH = height;
|
||||
if (width <= 0 || height <= 0) return;
|
||||
|
||||
const m = managerRef.current;
|
||||
if (!m) return;
|
||||
|
||||
// 컨테이너가 처음으로 유효한 크기를 가질 때:
|
||||
// 데이터가 이미 barsRef에 있지만 차트에 아직 세팅되지 않은 경우 (예: 멀티레이아웃 초기 렌더) 재로드
|
||||
if (barsRef.current.length > 0 && !m.hasMainSeries()) {
|
||||
reloadAll(
|
||||
m,
|
||||
barsRef.current,
|
||||
prevChartType.current,
|
||||
prevTheme.current,
|
||||
prevLogScale.current,
|
||||
[] // indicators는 별도 useEffect에서 처리
|
||||
);
|
||||
} else {
|
||||
applyPaneLayout(m);
|
||||
// 멀티→단일 레이아웃 전환처럼 숨겨졌다가 다시 표시될 때
|
||||
// setInitialVisibleRange 를 재실행해 가시 범위(캔들 분포)를 정상화
|
||||
if (wasHidden && m.hasMainSeries()) {
|
||||
requestAnimationFrame(() => {
|
||||
const fb = m.hasIchimoku() ? 28 : 0;
|
||||
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
unsubClick();
|
||||
container.removeEventListener('click', handleDrawingCapture, { capture: true });
|
||||
container.removeEventListener('contextmenu', onContextMenuCapture, { capture: true });
|
||||
container.removeEventListener('dblclick', onCandlePaneDblClick, { capture: true });
|
||||
container.removeEventListener('pointerdown', onPanPointerDown);
|
||||
container.removeEventListener('wheel', onWheelCapture, { capture: true });
|
||||
window.removeEventListener('pointermove', onPanPointerMove);
|
||||
window.removeEventListener('pointerup', onPanPointerUp);
|
||||
window.removeEventListener('pointercancel', onPanPointerUp);
|
||||
if (drawingSingleTimerRef.current) {
|
||||
clearTimeout(drawingSingleTimerRef.current);
|
||||
drawingSingleTimerRef.current = null;
|
||||
}
|
||||
ro.disconnect();
|
||||
managerRef.current?.destroy();
|
||||
managerRef.current = null;
|
||||
setChartMgr(null);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
|
||||
}, [displayTimezone, timeframe]);
|
||||
|
||||
// ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const mgr = managerRef.current;
|
||||
if (!mgr || !chartMgr || bars.length === 0) return;
|
||||
|
||||
// manager 준비 전 bars 가 먼저 도착한 경우(로그인 직후 등) 메인 시리즈 누락 방지
|
||||
if (!mgr.hasMainSeries()) {
|
||||
void reloadAll(mgr, bars, chartType, theme, logScale, indicators);
|
||||
return;
|
||||
}
|
||||
|
||||
const bk = barsKey(bars);
|
||||
const ik = indKey(indicators);
|
||||
const barsChanged = bk !== prevBarsKey.current;
|
||||
const indChanged = ik !== prevIndKey.current;
|
||||
const ctChanged = chartType !== prevChartType.current;
|
||||
const thChanged = theme !== prevTheme.current;
|
||||
const lsChanged = logScale !== prevLogScale.current;
|
||||
|
||||
if (!barsChanged && !indChanged && !ctChanged && !thChanged && !lsChanged) return;
|
||||
|
||||
if (barsChanged || ctChanged || thChanged) {
|
||||
// 데이터 또는 차트 형식 변경 → 전체 재로드
|
||||
reloadAll(mgr, bars, chartType, theme, logScale, indicators);
|
||||
} else if (lsChanged) {
|
||||
prevLogScale.current = logScale;
|
||||
mgr.setLogScale(logScale);
|
||||
} else if (indChanged) {
|
||||
const prevPK = prevIndKey.current.split('@@')[0] ?? '';
|
||||
const currPK = paramKey(indicators);
|
||||
const prevSK = prevIndKey.current.split('@@')[1] ?? '';
|
||||
const currSK = styleKey(indicators);
|
||||
|
||||
const needsRecalc = prevPK !== currPK;
|
||||
// 같은 지표 집합인데 순서만 바뀐 경우 (reorder-only)
|
||||
const isReorderOnly = needsRecalc
|
||||
&& sortedParamKey(indicators) === prevSortedPKRef.current
|
||||
&& currSK === prevSK;
|
||||
|
||||
if (isReorderOnly) {
|
||||
// ── pane 순서 변경만: 메인 차트를 건드리지 않고 지표만 재배치 ──────────
|
||||
// 1) 지표 영역만 커버 (순간 숨김) → 2) 지표만 재추가 → 3) 서서히 공개
|
||||
prevSortedPKRef.current = sortedParamKey(indicators);
|
||||
prevIndKey.current = ik;
|
||||
|
||||
const containerEl = containerRef.current;
|
||||
// 지표 영역 위에 불투명 커버 div 삽입
|
||||
let cover: HTMLDivElement | null = null;
|
||||
if (containerEl) {
|
||||
cover = document.createElement('div');
|
||||
cover.style.cssText = [
|
||||
'position:absolute', 'inset:0', 'z-index:900',
|
||||
'pointer-events:none', 'opacity:1',
|
||||
'background:var(--bg,#131722)',
|
||||
].join(';');
|
||||
containerEl.parentElement?.appendChild(cover);
|
||||
}
|
||||
|
||||
mgr.reloadIndicatorsOnly(indicators).then(() => {
|
||||
if (candleOnlyModeRef.current) {
|
||||
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
||||
}
|
||||
applyPaneLayout(mgr);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (cover) {
|
||||
cover.style.transition = 'opacity 0.25s ease';
|
||||
cover.style.opacity = '0';
|
||||
setTimeout(() => { cover?.remove(); }, 300);
|
||||
}
|
||||
}));
|
||||
});
|
||||
} else if (needsRecalc) {
|
||||
// ── 지표 추가·제거·파라미터 변경 → 지표만 재로드 (메인 캔들 유지) ──
|
||||
// reloadIndicatorsOnly 는 메인/볼륨 시리즈를 건드리지 않으므로
|
||||
// 차트 위치 초기화·깜빡임 없이 지표만 갱신된다.
|
||||
prevSortedPKRef.current = sortedParamKey(indicators);
|
||||
prevIndKey.current = ik;
|
||||
|
||||
// 현재 scroll/zoom 위치를 저장해 reload 후 복원
|
||||
const savedLR = mgr.getVisibleLogicalRange();
|
||||
|
||||
mgr.reloadIndicatorsOnly(indicators).then(() => {
|
||||
if (candleOnlyModeRef.current) {
|
||||
mgr.applyCandleOnlyLayout(true, wrapperRef.current?.clientHeight);
|
||||
} else if (mgr.isCandleOnlyLayout()) {
|
||||
mgr.restoreFromCandleFullscreen(wrapperRef.current?.clientHeight);
|
||||
}
|
||||
applyPaneLayout(mgr);
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
if (savedLR) mgr.applyVisibleLogicalRange(savedLR.from, savedLR.to);
|
||||
}));
|
||||
});
|
||||
} else if (prevSK !== currSK) {
|
||||
// ── 스타일만 변경 (색상·선폭·plotVisibility) → in-place 업데이트 ────
|
||||
// 시리즈를 제거·재생성하지 않으므로 pane 재번호 없음
|
||||
for (const ind of indicators) {
|
||||
mgr.applyIndicatorStyle(ind);
|
||||
}
|
||||
prevIndKey.current = ik;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartMgr, bars, chartType, theme, logScale, indicators]);
|
||||
|
||||
// cursor 모드에서 드로잉 위에 있으면 pointer 커서로 변경
|
||||
const handleWrapperMouseMove = useCallback((e: React.PointerEvent) => {
|
||||
if (panStateRef.current.active) return;
|
||||
if (canPanRef.current) {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
if (
|
||||
modeRef.current === 'trading' &&
|
||||
drawingToolRef.current === 'cursor' &&
|
||||
drawingsVisibleRef.current &&
|
||||
!drawingsLockedRef.current
|
||||
) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const m = managerRef.current;
|
||||
if (m) {
|
||||
const isHit = drawingsRef.current.some(d =>
|
||||
hitTestDrawing(cx, cy, d, m, container.clientWidth, container.clientHeight),
|
||||
);
|
||||
container.style.cursor = isHit ? 'pointer' : 'grab';
|
||||
return;
|
||||
}
|
||||
}
|
||||
container.style.cursor = 'grab';
|
||||
return;
|
||||
}
|
||||
if (
|
||||
modeRef.current !== 'trading' ||
|
||||
drawingToolRef.current !== 'cursor' ||
|
||||
!drawingsVisibleRef.current ||
|
||||
drawingsLockedRef.current
|
||||
) {
|
||||
if (containerRef.current) containerRef.current.style.cursor = '';
|
||||
return;
|
||||
}
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const cssW = container.clientWidth;
|
||||
const cssH = container.clientHeight;
|
||||
const m = managerRef.current;
|
||||
if (!m) return;
|
||||
|
||||
const ds = drawingsRef.current;
|
||||
const isHit = ds.some(d => hitTestDrawing(cx, cy, d, m, cssW, cssH));
|
||||
container.style.cursor = isHit ? 'pointer' : '';
|
||||
}, []);
|
||||
|
||||
const fmtCtxPrice = (p: number) =>
|
||||
p >= 1000 ? Math.round(p).toLocaleString('ko-KR') : p.toFixed(p >= 1 ? 2 : 6);
|
||||
|
||||
const closeCtxAndRequest = useCallback((side: 'buy' | 'sell') => {
|
||||
if (!ctxMenu || !market) return;
|
||||
onTradeOrderRequestRef.current?.({ market, price: ctxMenu.price, side });
|
||||
setCtxMenu(null);
|
||||
}, [ctxMenu, market]);
|
||||
|
||||
return (
|
||||
// wrapperRef: 스크롤 영역 (overflow-y: auto → App.css .tv-chart-wrap)
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="tv-chart-wrap"
|
||||
style={{ flex: 1, position: 'relative', minHeight: 0 }}
|
||||
onPointerMove={handleWrapperMouseMove}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="chart-container"
|
||||
/>
|
||||
{ctxMenu && (
|
||||
<ChartContextMenu
|
||||
x={ctxMenu.x}
|
||||
y={ctxMenu.y}
|
||||
marketLabel={getKoreanName(market) || market.replace(/^KRW-/, '')}
|
||||
priceLabel={`${fmtCtxPrice(ctxMenu.price)} KRW`}
|
||||
onBuy={() => closeCtxAndRequest('buy')}
|
||||
onSell={() => closeCtxAndRequest('sell')}
|
||||
onClose={() => setCtxMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && (
|
||||
<DrawingCanvas
|
||||
manager={chartMgr}
|
||||
activeTool={drawingTool}
|
||||
drawings={drawings}
|
||||
onAddDrawing={onAddDrawing}
|
||||
onZoom={(x0, x1) => chartMgr.zoomToXRange(x0, x1)}
|
||||
theme={theme}
|
||||
enabled={mode === 'trading' || drawingTool === 'zoom' || drawingTool === 'magnifier'}
|
||||
visible={drawingsVisible}
|
||||
locked={drawingsLocked}
|
||||
selectedDrawingId={selectedDrawingId}
|
||||
/>
|
||||
)}
|
||||
{/* PaneLegend: containerRef 를 상태에 저장해 React 렌더와 동기화 */}
|
||||
{chartMgr && (
|
||||
<CandlePaneControlsPortal
|
||||
manager={chartMgr}
|
||||
getContainer={() => containerRef.current}
|
||||
candleOnly={candleOnlyMode}
|
||||
onExpand={() => setCandleOnlyMode(true)}
|
||||
onRestore={() => setCandleOnlyMode(false)}
|
||||
/>
|
||||
)}
|
||||
{chartMgr && !candleOnlyMode && (
|
||||
<PaneLegendPortal
|
||||
manager={chartMgr}
|
||||
indicators={indicators}
|
||||
getContainer={() => containerRef.current}
|
||||
onHoverName={(id, sx, sy) => onHoverPaneLegend?.(id, sx, sy)}
|
||||
onLeaveName={() => onLeavePaneLegend?.()}
|
||||
onDoubleClick={(id) => onSeriesDoubleClick?.(id, { x: 0, y: 0 })}
|
||||
onReorder={onReorderIndicators}
|
||||
onMerge={onMergeIndicators}
|
||||
onSplit={onSplitIndicatorPane}
|
||||
onExpand={onExpandIndicator}
|
||||
onRemove={onRemoveIndicator}
|
||||
onDuplicate={onDuplicateIndicator}
|
||||
focusedId={focusedIndicatorId}
|
||||
onRestore={onRestoreIndicators}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 마우스 오버 시 표시되는 플로팅 줌/스크롤 툴바 */}
|
||||
{chartMgr && (
|
||||
<ChartHoverToolbar
|
||||
onZoomOut={() => managerRef.current?.zoomOut()}
|
||||
onZoomIn={() => managerRef.current?.zoomIn()}
|
||||
onFit={() => managerRef.current?.fitContent()}
|
||||
onScrollLeft={() => managerRef.current?.scrollLeft()}
|
||||
onScrollRight={() => managerRef.current?.scrollRight()}
|
||||
onFullView={onFullView}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 돋보기 오버레이 */}
|
||||
<ChartMagnifier
|
||||
containerRef={containerRef}
|
||||
wrapperRef={wrapperRef}
|
||||
enabled={magnifierEnabled}
|
||||
onClose={onMagnifierClose ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CandlePaneControlsPortal: React.FC<{
|
||||
manager: ChartManager;
|
||||
getContainer: () => HTMLElement | null;
|
||||
candleOnly: boolean;
|
||||
onExpand: () => void;
|
||||
onRestore: () => void;
|
||||
}> = ({ manager, getContainer, candleOnly, onExpand, onRestore }) => {
|
||||
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||
useEffect(() => {
|
||||
const c = getContainer();
|
||||
if (c) { setEl(c); return; }
|
||||
const tid = setTimeout(() => setEl(getContainer()), 100);
|
||||
return () => clearTimeout(tid);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
if (!el) return null;
|
||||
return (
|
||||
<CandlePaneControls
|
||||
manager={manager}
|
||||
containerEl={el}
|
||||
candleOnly={candleOnly}
|
||||
onExpand={onExpand}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// ── containerEl 을 안전하게 주입하는 래퍼 ───────────────────────────────────
|
||||
const PaneLegendPortal: React.FC<
|
||||
Omit<PaneLegendProps, 'containerEl'> & { getContainer: () => HTMLElement | null }
|
||||
> = ({ getContainer, ...rest }) => {
|
||||
const [el, setEl] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// 마운트 직후 containerEl 확보
|
||||
const container = getContainer();
|
||||
if (container) { setEl(container); return; }
|
||||
// 혹시 null 이면 짧게 재시도
|
||||
const tid = setTimeout(() => setEl(getContainer()), 100);
|
||||
return () => clearTimeout(tid);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
if (!el) return null;
|
||||
return <PaneLegend {...rest} containerEl={el} />;
|
||||
};
|
||||
|
||||
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
|
||||
function barsKey(bars: OHLCVBar[]): string {
|
||||
if (bars.length === 0) return '';
|
||||
const last = bars[bars.length - 1];
|
||||
// 종목이 달라도 시간 범위·개수가 같을 수 있으므로 마지막 close 가격도 포함
|
||||
return `${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재계산이 필요한 변경 키 (params·플롯 구성·추가·제거) — 배열 순서 기준
|
||||
* 이 키가 바뀌면 시리즈를 재생성해야 한다.
|
||||
*/
|
||||
function paramKey(inds: IndicatorConfig[]): string {
|
||||
return inds.map(i =>
|
||||
`${i.id}|${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`
|
||||
).join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* 순서 무관 파라미터 키 — id 기준으로 정렬
|
||||
* paramKey 값은 같지만 배열 순서만 다른 경우를 감지하는 데 사용.
|
||||
*/
|
||||
function sortedParamKey(inds: IndicatorConfig[]): string {
|
||||
return [...inds]
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
.map(i => `${i.id}|${JSON.stringify(i.params)}|${(i.plots ?? []).map(p => p.id).sort().join(',')}|${i.mergedWith ?? ''}`)
|
||||
.join(';');
|
||||
}
|
||||
|
||||
/**
|
||||
* 스타일만 포함하는 키 (색상·선폭·plotVisibility)
|
||||
* 이 키만 바뀌면 시리즈를 재생성하지 않고 in-place 업데이트.
|
||||
*/
|
||||
function styleKey(inds: IndicatorConfig[]): string {
|
||||
return inds.map(i =>
|
||||
`${i.id}|${i.hidden ? '1' : '0'}|${i.lastValueVisible === false ? '0' : '1'}|${(i.plots ?? []).map(p => `${p.color ?? ''}:${p.lineWidth ?? 1}:${p.lineStyle ?? 'solid'}`).join(',')}|${JSON.stringify(i.plotVisibility ?? {})}|${JSON.stringify((i.hlines ?? []).map(h => `${h.price}:${h.color}:${h.visible ?? true}:${h.lineStyle ?? 'dashed'}:${h.lineWidth ?? 1}`))}|${JSON.stringify(i.cloudColors ?? {})}`
|
||||
).join(';');
|
||||
}
|
||||
|
||||
/** 전체 변경 감지 키 (두 키를 합침) */
|
||||
function indKey(inds: IndicatorConfig[]): string {
|
||||
return paramKey(inds) + '@@' + styleKey(inds);
|
||||
}
|
||||
|
||||
export default TradingChart;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface WatchListProps {
|
||||
watchlist: string[];
|
||||
currentSymbol: string;
|
||||
onSelect: (symbol: string) => void;
|
||||
onAdd: (symbol: string) => void;
|
||||
onRemove: (symbol: string) => void;
|
||||
onClose: () => void;
|
||||
priceMap: Record<string, { price: number; changePct: number }>;
|
||||
}
|
||||
|
||||
const WatchList: React.FC<WatchListProps> = ({
|
||||
watchlist, currentSymbol, onSelect, onAdd, onRemove, onClose, priceMap,
|
||||
}) => {
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleAdd = () => {
|
||||
const sym = input.trim().toUpperCase();
|
||||
if (sym && !watchlist.includes(sym)) {
|
||||
onAdd(sym);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="watchlist-panel">
|
||||
<div className="watchlist-header">
|
||||
<span className="watchlist-title">⭐ 관심 종목</span>
|
||||
<button className="watchlist-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className="watchlist-add">
|
||||
<input
|
||||
className="watchlist-input"
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleAdd()}
|
||||
placeholder="심볼 추가..."
|
||||
/>
|
||||
<button className="watchlist-add-btn" onClick={handleAdd}>+</button>
|
||||
</div>
|
||||
<div className="watchlist-body">
|
||||
{watchlist.map(sym => {
|
||||
const info = priceMap[sym];
|
||||
const pct = info?.changePct ?? 0;
|
||||
const isActive = sym === currentSymbol;
|
||||
return (
|
||||
<div
|
||||
key={sym}
|
||||
className={`watchlist-item ${isActive ? 'active' : ''}`}
|
||||
onClick={() => onSelect(sym)}
|
||||
>
|
||||
<span className="watchlist-sym">{sym}</span>
|
||||
<div className="watchlist-right">
|
||||
{info && (
|
||||
<>
|
||||
<span className="watchlist-price">{info.price.toFixed(2)}</span>
|
||||
<span className={`watchlist-change ${pct >= 0 ? 'up' : 'down'}`}>
|
||||
{pct >= 0 ? '+' : ''}{pct.toFixed(2)}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="watchlist-remove"
|
||||
onClick={e => { e.stopPropagation(); onRemove(sym); }}
|
||||
>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatchList;
|
||||
Reference in New Issue
Block a user