diff --git a/web/app/[code]/page.tsx b/web/app/[code]/page.tsx index bd3a95b..9275b70 100644 --- a/web/app/[code]/page.tsx +++ b/web/app/[code]/page.tsx @@ -96,6 +96,10 @@ export default function CodePage({ params }: { params: { code: string } }) { const onKey = (e: KeyboardEvent) => { if (e.ctrlKey || e.metaKey || e.altKey) return; if (isEditableTarget(e.target)) return; + // 모달/대화상자가 열려 있으면 뒤의 페이지 단축키를 가로채지 않음. + // ShortcutsHelp 처럼 close 버튼이 포커스 잡혀 있어도 editable 가드는 통과되므로, + // 페이지 단축키가 뒤에서 작동하는 것을 막기 위해 aria-modal 으로 추가 확인. + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; if (e.key === "ArrowLeft") { e.preventDefault(); setPeriod((cur) => periodNeighbor(cur, -1)); diff --git a/web/components/ShortcutsHelp.tsx b/web/components/ShortcutsHelp.tsx index 4cccfed..35c1f44 100644 --- a/web/components/ShortcutsHelp.tsx +++ b/web/components/ShortcutsHelp.tsx @@ -22,6 +22,7 @@ const SHORTCUTS: Shortcut[] = [ { scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" }, { scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" }, { scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" }, + { scope: "종목 페이지", keys: ["H"], label: "현재 가격에 수평선 추가" }, { scope: "종목 페이지", keys: ["←", "→"], label: "기간 탭 이전 / 다음" }, { scope: "종목 페이지", keys: ["S"], label: "관심종목 추가 / 제거" }, { scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" }, diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index c141465..61bda21 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -15,6 +15,7 @@ import { } from "lightweight-charts"; import type { ChartPayload, LatestPredictionResponse } from "../lib/api"; import { csvFilename, downloadCsv, ohlcvToCsv } from "../lib/csv"; +import { addLine, clearLines, type HLine, readLines, removeLine } from "../lib/lines"; import type { Targets } from "../lib/targets"; type Props = { @@ -223,6 +224,8 @@ export function StockChart({ chart, prediction, targets }: Props) { // 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착. const targetLineRef = useRef(null); const stopLineRef = useRef(null); + // 사용자 수평선 — id → IPriceLine. localStorage 에 영속. + const hlineRefs = useRef>(new Map()); // 보조 차트 crosshair 동기화용 time→value lookup. // 메인 차트에서 hover 한 시점의 RSI/MACD 값을 O(1) 로 찾아 setCrosshairPosition 에 @@ -240,6 +243,8 @@ export function StockChart({ chart, prediction, targets }: Props) { // 두 경로 모두 같은 state 로 통일 — UI(버튼 라벨/컨테이너 클래스) 분기 단순. const wrapperRef = useRef(null); const [fullscreen, setFullscreen] = useState(false); + // 사용자 수평선 — localStorage 동기. 종목 전환 시 reload. + const [hlines, setHlines] = useState([]); const isIntraday = chart.interval === "10m"; const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김. @@ -479,6 +484,33 @@ export function StockChart({ chart, prediction, targets }: Props) { } }, [targets, isIntraday]); + // 사용자 수평선 로드 — 종목 전환 또는 캔들 시리즈 재생성 시. + useEffect(() => { + setHlines(readLines(chart.code)); + }, [chart.code, isIntraday]); + + // 수평선 렌더링 — id 단위 diff 로 변경된 것만 add/remove. 단순화를 위해 + // 매번 전부 지우고 다시 그리는 방식 채택 (라인 수가 10 개 cap 이라 비용 무시). + useEffect(() => { + const candle = candleRef.current; + if (!candle) return; + for (const line of hlineRefs.current.values()) { + try { candle.removePriceLine(line); } catch { /* 이미 해제됨 */ } + } + hlineRefs.current.clear(); + for (const hl of hlines) { + const line = candle.createPriceLine({ + price: hl.price, + color: "#a3a3a3", // zinc-400 — 중립 (목표/손절 의도 색과 구분). + lineWidth: 1, + lineStyle: 0, // solid — 점선인 목표/손절과 시각적으로 분리. + axisLabelVisible: true, + title: "", + }); + hlineRefs.current.set(hl.id, line); + } + }, [hlines, isIntraday]); + // RSI 보조 차트 생성/제거 — toggle 또는 interval 변경 시. 메인 chart 와 별 인스턴스. // priceScale fix (0-100) + 30/70 가이드 라인. useEffect(() => { @@ -672,6 +704,14 @@ export function StockChart({ chart, prediction, targets }: Props) { }; }, [chart, isIntraday]); + // H 키로 수평선 추가 시 사용할 "현재 가격" ref — hover 있으면 hover.close, 없으면 + // lastSummary.close. ref 로 들고 있어 keydown effect 가 매번 재구독되지 않음. + const currentPriceRef = useRef(null); + useEffect(() => { + const p = hover?.close ?? lastSummary?.close ?? null; + currentPriceRef.current = p; + }, [hover, lastSummary]); + useEffect(() => { const c = chartRef.current; if (!c) return; @@ -793,24 +833,29 @@ export function StockChart({ chart, prediction, targets }: Props) { ) { return; } + // 모달/대화상자가 열려 있으면 차트 단축키도 양보 — ShortcutsHelp 등이 열린 상태에서 + // 뒤의 차트가 전체화면으로 토글되는 것을 막음. + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; // Ctrl+F / Cmd+F (브라우저 찾기) / Alt+F (브라우저 메뉴) / Shift+F 는 가로채지 않음. // 단순 F 만 차트 전체화면 토글로 사용. - if ( - !e.ctrlKey && - !e.metaKey && - !e.altKey && - !e.shiftKey && - e.key.toLowerCase() === "f" - ) { + const noMod = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey; + if (noMod && e.key.toLowerCase() === "f") { e.preventDefault(); void toggleFullscreen(); + } else if (noMod && e.key.toLowerCase() === "h") { + // H 키 — 현재 가격(hover 또는 마지막 봉 종가)에 수평선 추가. + const price = currentPriceRef.current; + if (price != null && Number.isFinite(price) && price > 0) { + e.preventDefault(); + setHlines(addLine(chart.code, price)); + } } else if (e.key === "Escape" && fullscreen && !document.fullscreenElement) { setFullscreen(false); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [toggleFullscreen, fullscreen]); + }, [toggleFullscreen, fullscreen, chart.code]); const toggle = (w: SmaWindow) => { setActive((cur) => { @@ -895,6 +940,20 @@ export function StockChart({ chart, prediction, targets }: Props) { )} + + {hlines.length > 0 && ( +
+ 수평선: + {hlines.map((hl) => ( + + {fmtKPrice(hl.price)} + + + ))} + +
+ )}
diff --git a/web/lib/lines.ts b/web/lib/lines.ts new file mode 100644 index 0000000..789019c --- /dev/null +++ b/web/lib/lines.ts @@ -0,0 +1,104 @@ +// 종목별 사용자 수평선(horizontal price lines) 저장소. +// TradingView/Toss 식 그리기 도구의 가장 가벼운 형태 — 가격 라인만, per-code, localStorage. +// 차트 자체는 라인 ID 를 모르고 좌표만 받음. 추가/삭제는 이 모듈을 통해서만. + +const KEY = (code: string) => `hlines:${code}`; +export const HLINE_MAX = 10; + +export type HLine = { + id: string; + price: number; + // 작성 시각 — 디버그/정렬용. UI 에선 노출 안 해도 됨. + createdAt: string; +}; + +function safeStorage(): Storage | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage; + } catch { + return null; + } +} + +export function readLines(code: string): HLine[] { + const s = safeStorage(); + if (!s) return []; + try { + const raw = s.getItem(KEY(code)); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + const out: HLine[] = []; + for (const item of parsed) { + if ( + item && + typeof item === "object" && + typeof (item as HLine).id === "string" && + typeof (item as HLine).price === "number" && + Number.isFinite((item as HLine).price) && + (item as HLine).price > 0 + ) { + out.push({ + id: (item as HLine).id, + price: (item as HLine).price, + createdAt: + typeof (item as HLine).createdAt === "string" + ? (item as HLine).createdAt + : new Date().toISOString(), + }); + } + } + return out.slice(0, HLINE_MAX); + } catch { + return []; + } +} + +function writeRaw(code: string, lines: HLine[]): boolean { + const s = safeStorage(); + if (!s) return false; + try { + if (lines.length === 0) s.removeItem(KEY(code)); + else s.setItem(KEY(code), JSON.stringify(lines.slice(0, HLINE_MAX))); + return true; + } catch { + return false; + } +} + +// id 충돌 가능성이 거의 없는 short id. crypto.randomUUID 미지원 환경 폴백. +function newId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +// price 같음 검사용 — 유효숫자 4 자리 정도 비교로 부동소수 오차 무시. +function nearlyEqual(a: number, b: number): boolean { + const denom = Math.max(Math.abs(a), Math.abs(b), 1); + return Math.abs(a - b) / denom < 1e-4; +} + +export function addLine(code: string, price: number): HLine[] { + if (!Number.isFinite(price) || price <= 0) return readLines(code); + const cur = readLines(code); + // 같은 가격이 이미 있으면 중복 추가 안 함 — 라인이 안 보일 뿐 헷갈림 유발. + if (cur.some((l) => nearlyEqual(l.price, price))) return cur; + const next = [...cur, { id: newId(), price, createdAt: new Date().toISOString() }]; + writeRaw(code, next); + return next.slice(0, HLINE_MAX); +} + +export function removeLine(code: string, id: string): HLine[] { + const cur = readLines(code); + const next = cur.filter((l) => l.id !== id); + writeRaw(code, next); + return next; +} + +export function clearLines(code: string): HLine[] { + writeRaw(code, []); + return []; +}