feat(chart): fullscreen mode + CSV scope label clarification
- StockChart 툴바에 `⛶ 전체화면` 버튼 추가. Fullscreen API 우선,
미지원/거부 시 fixed inset 오버레이 폴백. F 키 토글, ESC 해제.
- 입력 필드(INPUT/TEXTAREA/SELECT/contentEditable) 포커스 시 단축키 무시.
- fullscreenchange 이벤트로 native 상태 동기화 → ESC 로 빠져나와도 UI 정합.
- CSV 버튼 title 을 '현재 로드된 기간(from ~ to)' 으로 명시.
리뷰어 지적 (1e0c917): "현재 보이는 봉" 이라는 표현은 timeScale
visible range 와 혼동 — 실제는 PeriodTabs 가 선택한 range 전체.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
createChart,
|
createChart,
|
||||||
type CandlestickData,
|
type CandlestickData,
|
||||||
@@ -236,6 +236,10 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
const [macdOn, setMacdOn] = useState(false);
|
const [macdOn, setMacdOn] = useState(false);
|
||||||
// crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시).
|
// crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시).
|
||||||
const [hover, setHover] = useState<CrosshairInfo | null>(null);
|
const [hover, setHover] = useState<CrosshairInfo | null>(null);
|
||||||
|
// 전체화면 모드 — Fullscreen API 가 동작하면 그게 우선, 안되면 fixed inset 오버레이 폴백.
|
||||||
|
// 두 경로 모두 같은 state 로 통일 — UI(버튼 라벨/컨테이너 클래스) 분기 단순.
|
||||||
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
const isIntraday = chart.interval === "10m";
|
const isIntraday = chart.interval === "10m";
|
||||||
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
|
const showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
|
||||||
@@ -745,6 +749,61 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
};
|
};
|
||||||
}, [showRsi, showMacd, chart]);
|
}, [showRsi, showMacd, chart]);
|
||||||
|
|
||||||
|
// 전체화면 토글 — Fullscreen API 가능하면 native, 아니면 오버레이 폴백.
|
||||||
|
// iOS Safari 등 element.requestFullscreen 미지원 환경은 자연히 폴백 경로로.
|
||||||
|
const toggleFullscreen = useCallback(async () => {
|
||||||
|
const el = wrapperRef.current;
|
||||||
|
if (!el || typeof document === "undefined") return;
|
||||||
|
const docAny = document as Document & { fullscreenEnabled?: boolean };
|
||||||
|
if (docAny.fullscreenEnabled && typeof el.requestFullscreen === "function") {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
} else {
|
||||||
|
await el.requestFullscreen();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// 권한 거부/실패 → 오버레이 폴백.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFullscreen((v) => !v);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Native fullscreen 상태 → state 동기화. ESC 로 빠져나오면 여기로 들어옴.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const onChange = () => setFullscreen(!!document.fullscreenElement);
|
||||||
|
document.addEventListener("fullscreenchange", onChange);
|
||||||
|
return () => document.removeEventListener("fullscreenchange", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// F 키 = 토글, ESC = 폴백 오버레이 해제 (native 는 브라우저가 알아서).
|
||||||
|
// 입력 필드에 포커스가 있으면 무시 — 메모 작성 등 방해 금지.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const t = e.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
t &&
|
||||||
|
(t.tagName === "INPUT" ||
|
||||||
|
t.tagName === "TEXTAREA" ||
|
||||||
|
t.tagName === "SELECT" ||
|
||||||
|
t.isContentEditable)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "f" || e.key === "F") {
|
||||||
|
e.preventDefault();
|
||||||
|
void toggleFullscreen();
|
||||||
|
} else if (e.key === "Escape" && fullscreen && !document.fullscreenElement) {
|
||||||
|
setFullscreen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [toggleFullscreen, fullscreen]);
|
||||||
|
|
||||||
const toggle = (w: SmaWindow) => {
|
const toggle = (w: SmaWindow) => {
|
||||||
setActive((cur) => {
|
setActive((cur) => {
|
||||||
const next = new Set(cur);
|
const next = new Set(cur);
|
||||||
@@ -754,8 +813,18 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 전체화면(native or 폴백) 일 때는 viewport 를 꽉 채워서 차트 면적 최대화.
|
||||||
|
// 일반 모드는 기존 카드 스타일 유지.
|
||||||
|
const wrapperCls = fullscreen
|
||||||
|
? "fixed inset-0 z-50 flex w-full flex-col overflow-auto bg-zinc-950 p-3"
|
||||||
|
: "w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2";
|
||||||
|
// 차트 본체 높이 — 전체화면이면 가용 공간을 차지하도록 flex-1.
|
||||||
|
const chartBoxCls = fullscreen
|
||||||
|
? "relative min-h-[300px] flex-1 w-full"
|
||||||
|
: "relative h-[460px] w-full";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
<div ref={wrapperRef} className={wrapperCls}>
|
||||||
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
|
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
|
||||||
{showSma && (
|
{showSma && (
|
||||||
<>
|
<>
|
||||||
@@ -832,12 +901,21 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
}}
|
}}
|
||||||
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="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="현재 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)"
|
title={`현재 로드된 기간(${chart.range.from} ~ ${chart.range.to}) 의 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)`}
|
||||||
>
|
>
|
||||||
⬇ CSV
|
⬇ CSV
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleFullscreen()}
|
||||||
|
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"
|
||||||
|
title={fullscreen ? "전체화면 해제 (ESC / F)" : "전체화면 (F)"}
|
||||||
|
aria-pressed={fullscreen}
|
||||||
|
>
|
||||||
|
{fullscreen ? "⛶ 해제" : "⛶ 전체화면"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-[460px] w-full">
|
<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} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user