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:
claude-owner
2026-05-29 00:17:00 +09:00
parent 1e0c917f68
commit 9f215acaaa

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
createChart,
type CandlestickData,
@@ -236,6 +236,10 @@ export function StockChart({ chart, prediction, targets }: Props) {
const [macdOn, setMacdOn] = useState(false);
// crosshair 위에 떠 있는 한국어 라벨 — null 이면 hover 안 함 (기본 마지막 봉 표시).
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 showSma = !isIntraday; // 10분봉에선 의미 적음 — UI 도 숨김.
@@ -745,6 +749,61 @@ export function StockChart({ chart, prediction, targets }: Props) {
};
}, [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) => {
setActive((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 (
<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">
{showSma && (
<>
@@ -832,12 +901,21 @@ export function StockChart({ chart, prediction, targets }: Props) {
}}
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"
title="현재 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)"
title={`현재 로드된 기간(${chart.range.from} ~ ${chart.range.to}) 의 봉 데이터를 CSV 로 내보내기 (UTF-8 BOM, Excel 호환)`}
>
CSV
</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 className="relative h-[460px] w-full">
<div className={chartBoxCls}>
<div ref={containerRef} className="h-full w-full" />
<CrosshairLabel info={hover ?? lastSummary} isHover={hover != null} />
</div>