feat(chart): horizontal line drawing tool + modal-open shortcut guards
리뷰어 093ac86 지적 처리:
- 종목 페이지 단축키 핸들러 (←/→/S) 에 modal 가드 추가.
`document.querySelector('[role="dialog"][aria-modal="true"]')` 가 매치되면
return — ShortcutsHelp 같은 모달이 열린 상태에서 close 버튼이 포커스를
잡고 있어도 페이지 단축키가 뒤에서 동작하지 않도록.
- StockChart 의 F 단축키에도 동일한 modal 가드 — 동일 문제 (모달 위에서
F 가 차트 전체화면 토글) 차단.
신규 슬라이스 — 차트 수평선 그리기 도구:
- `web/lib/lines.ts` — `hlines:<code>` 키 localStorage. id 단위 add/remove/clear,
최대 10 개, 같은 가격 중복 추가 방지(부동소수 4 자리 비교), crypto.randomUUID
폴백 포함.
- StockChart 툴바: `─ 수평선 (N/10)` 버튼 — hover 가격 또는 마지막 봉 종가에
추가. 한도 도달 / 가격 없음이면 disabled.
- 추가된 라인은 chips 형태로 toolbar 아래 한 줄에 표시 — 각각 ✕ 로 제거,
`전체 해제` 버튼 별도.
- H 키 단축키 — modifier/editable/modal 가드 일관 적용. 가격은 ref 로 들고
있어 keydown effect 가 재구독되지 않음.
- 차트엔 `createPriceLine` 으로 zinc-400 solid 라인 (목표/손절의 점선과 톤
분리). 종목 전환 시 자동 reload.
- ShortcutsHelp 에 H 단축키 등록.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -96,6 +96,10 @@ export default function CodePage({ params }: { params: { code: string } }) {
|
|||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||||
if (isEditableTarget(e.target)) return;
|
if (isEditableTarget(e.target)) return;
|
||||||
|
// 모달/대화상자가 열려 있으면 뒤의 페이지 단축키를 가로채지 않음.
|
||||||
|
// ShortcutsHelp 처럼 close 버튼이 포커스 잡혀 있어도 editable 가드는 통과되므로,
|
||||||
|
// 페이지 단축키가 뒤에서 작동하는 것을 막기 위해 aria-modal 으로 추가 확인.
|
||||||
|
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||||
if (e.key === "ArrowLeft") {
|
if (e.key === "ArrowLeft") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPeriod((cur) => periodNeighbor(cur, -1));
|
setPeriod((cur) => periodNeighbor(cur, -1));
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const SHORTCUTS: Shortcut[] = [
|
|||||||
{ scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" },
|
{ scope: "전역", keys: ["Esc"], label: "팝오버 / 오버레이 닫기" },
|
||||||
{ scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" },
|
{ scope: "전역", keys: ["?"], label: "이 도움말 열기 / 닫기" },
|
||||||
{ scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" },
|
{ scope: "종목 페이지", keys: ["F"], label: "차트 전체화면 토글" },
|
||||||
|
{ scope: "종목 페이지", keys: ["H"], label: "현재 가격에 수평선 추가" },
|
||||||
{ scope: "종목 페이지", keys: ["←", "→"], label: "기간 탭 이전 / 다음" },
|
{ scope: "종목 페이지", keys: ["←", "→"], label: "기간 탭 이전 / 다음" },
|
||||||
{ scope: "종목 페이지", keys: ["S"], label: "관심종목 추가 / 제거" },
|
{ scope: "종목 페이지", keys: ["S"], label: "관심종목 추가 / 제거" },
|
||||||
{ scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" },
|
{ scope: "검색 팝오버", keys: ["↑", "↓"], label: "결과 이동" },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from "lightweight-charts";
|
} from "lightweight-charts";
|
||||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
||||||
import { csvFilename, downloadCsv, ohlcvToCsv } from "../lib/csv";
|
import { csvFilename, downloadCsv, ohlcvToCsv } from "../lib/csv";
|
||||||
|
import { addLine, clearLines, type HLine, readLines, removeLine } from "../lib/lines";
|
||||||
import type { Targets } from "../lib/targets";
|
import type { Targets } from "../lib/targets";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -223,6 +224,8 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
// 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착.
|
// 목표가/손절가 가로 라인 — candleRef 의 priceLine 으로 부착.
|
||||||
const targetLineRef = useRef<IPriceLine | null>(null);
|
const targetLineRef = useRef<IPriceLine | null>(null);
|
||||||
const stopLineRef = useRef<IPriceLine | null>(null);
|
const stopLineRef = useRef<IPriceLine | null>(null);
|
||||||
|
// 사용자 수평선 — id → IPriceLine. localStorage 에 영속.
|
||||||
|
const hlineRefs = useRef<Map<string, IPriceLine>>(new Map());
|
||||||
|
|
||||||
// 보조 차트 crosshair 동기화용 time→value lookup.
|
// 보조 차트 crosshair 동기화용 time→value lookup.
|
||||||
// 메인 차트에서 hover 한 시점의 RSI/MACD 값을 O(1) 로 찾아 setCrosshairPosition 에
|
// 메인 차트에서 hover 한 시점의 RSI/MACD 값을 O(1) 로 찾아 setCrosshairPosition 에
|
||||||
@@ -240,6 +243,8 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
// 두 경로 모두 같은 state 로 통일 — UI(버튼 라벨/컨테이너 클래스) 분기 단순.
|
// 두 경로 모두 같은 state 로 통일 — UI(버튼 라벨/컨테이너 클래스) 분기 단순.
|
||||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
// 사용자 수평선 — localStorage 동기. 종목 전환 시 reload.
|
||||||
|
const [hlines, setHlines] = useState<HLine[]>([]);
|
||||||
|
|
||||||
const isIntraday = chart.interval === "10m";
|
const isIntraday = chart.interval === "10m";
|
||||||
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
|
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
|
||||||
@@ -479,6 +484,33 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
}
|
}
|
||||||
}, [targets, isIntraday]);
|
}, [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 와 별 인스턴스.
|
// RSI 보조 차트 생성/제거 — toggle 또는 interval 변경 시. 메인 chart 와 별 인스턴스.
|
||||||
// priceScale fix (0-100) + 30/70 가이드 라인.
|
// priceScale fix (0-100) + 30/70 가이드 라인.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -672,6 +704,14 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
};
|
};
|
||||||
}, [chart, isIntraday]);
|
}, [chart, isIntraday]);
|
||||||
|
|
||||||
|
// H 키로 수평선 추가 시 사용할 "현재 가격" ref — hover 있으면 hover.close, 없으면
|
||||||
|
// lastSummary.close. ref 로 들고 있어 keydown effect 가 매번 재구독되지 않음.
|
||||||
|
const currentPriceRef = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const p = hover?.close ?? lastSummary?.close ?? null;
|
||||||
|
currentPriceRef.current = p;
|
||||||
|
}, [hover, lastSummary]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const c = chartRef.current;
|
const c = chartRef.current;
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
@@ -793,24 +833,29 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 모달/대화상자가 열려 있으면 차트 단축키도 양보 — ShortcutsHelp 등이 열린 상태에서
|
||||||
|
// 뒤의 차트가 전체화면으로 토글되는 것을 막음.
|
||||||
|
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||||
// Ctrl+F / Cmd+F (브라우저 찾기) / Alt+F (브라우저 메뉴) / Shift+F 는 가로채지 않음.
|
// Ctrl+F / Cmd+F (브라우저 찾기) / Alt+F (브라우저 메뉴) / Shift+F 는 가로채지 않음.
|
||||||
// 단순 F 만 차트 전체화면 토글로 사용.
|
// 단순 F 만 차트 전체화면 토글로 사용.
|
||||||
if (
|
const noMod = !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
|
||||||
!e.ctrlKey &&
|
if (noMod && e.key.toLowerCase() === "f") {
|
||||||
!e.metaKey &&
|
|
||||||
!e.altKey &&
|
|
||||||
!e.shiftKey &&
|
|
||||||
e.key.toLowerCase() === "f"
|
|
||||||
) {
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void toggleFullscreen();
|
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) {
|
} else if (e.key === "Escape" && fullscreen && !document.fullscreenElement) {
|
||||||
setFullscreen(false);
|
setFullscreen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("keydown", onKey);
|
window.addEventListener("keydown", onKey);
|
||||||
return () => window.removeEventListener("keydown", onKey);
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
}, [toggleFullscreen, fullscreen]);
|
}, [toggleFullscreen, fullscreen, chart.code]);
|
||||||
|
|
||||||
const toggle = (w: SmaWindow) => {
|
const toggle = (w: SmaWindow) => {
|
||||||
setActive((cur) => {
|
setActive((cur) => {
|
||||||
@@ -895,6 +940,20 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const price = hover?.close ?? lastSummary?.close ?? null;
|
||||||
|
if (price != null && Number.isFinite(price) && price > 0) {
|
||||||
|
setHlines(addLine(chart.code, price));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={hlines.length >= 10 || (hover?.close ?? lastSummary?.close ?? null) == null}
|
||||||
|
className="ml-auto flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
title="현재 가격(hover 또는 마지막 봉 종가)에 수평선 추가 (H)"
|
||||||
|
>
|
||||||
|
─ 수평선 ({hlines.length}/10)
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -908,7 +967,7 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
downloadCsv(filename, ohlcvToCsv(chart.ohlcv));
|
downloadCsv(filename, ohlcvToCsv(chart.ohlcv));
|
||||||
}}
|
}}
|
||||||
disabled={!chart.ohlcv.length}
|
disabled={!chart.ohlcv.length}
|
||||||
className="ml-auto flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
className="flex items-center gap-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-400 transition hover:border-zinc-600 hover:text-zinc-200 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
title={`현재 로드된 기간(${chart.range.from} ~ ${chart.range.to}) 의 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)`}
|
title={`현재 로드된 기간(${chart.range.from} ~ ${chart.range.to}) 의 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)`}
|
||||||
>
|
>
|
||||||
⬇ CSV
|
⬇ CSV
|
||||||
@@ -923,6 +982,36 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
{fullscreen ? "⛶ 해제" : "⛶ 전체화면"}
|
{fullscreen ? "⛶ 해제" : "⛶ 전체화면"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{hlines.length > 0 && (
|
||||||
|
<div className="mb-1 flex flex-wrap items-center gap-1 px-1">
|
||||||
|
<span className="text-[10px] text-zinc-500">수평선:</span>
|
||||||
|
{hlines.map((hl) => (
|
||||||
|
<span
|
||||||
|
key={hl.id}
|
||||||
|
className="flex items-center gap-1 rounded-full border border-zinc-800 bg-zinc-900/60 pl-2 pr-1 py-0.5 text-[10px] tabular-nums text-zinc-300"
|
||||||
|
>
|
||||||
|
{fmtKPrice(hl.price)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHlines(removeLine(chart.code, hl.id))}
|
||||||
|
className="rounded-full px-1 text-[10px] text-zinc-500 hover:text-zinc-100"
|
||||||
|
title="이 수평선 제거"
|
||||||
|
aria-label={`${fmtKPrice(hl.price)} 원 수평선 제거`}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setHlines(clearLines(chart.code))}
|
||||||
|
className="ml-1 rounded-full border border-zinc-800 px-2 py-0.5 text-[10px] text-zinc-500 hover:border-zinc-600 hover:text-zinc-200"
|
||||||
|
title="모든 수평선 제거"
|
||||||
|
>
|
||||||
|
전체 해제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className={chartBoxCls}>
|
<div className={chartBoxCls}>
|
||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
<CrosshairLabel info={hover ?? lastSummary} isHover={hover != null} />
|
<CrosshairLabel info={hover ?? lastSummary} isHover={hover != null} />
|
||||||
|
|||||||
104
web/lib/lines.ts
Normal file
104
web/lib/lines.ts
Normal file
@@ -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 [];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user