feat(chart): CSV export of current chart range + fix ShareButton comment
- New lib/csv.ts: RFC 4180 escaping, UTF-8 BOM prefix (한글 Excel 호환),
Blob + temporary anchor download, filename
`<name>_<code>_<interval>_<from>_<to>.csv` with filesystem-illegal
characters replaced by `_`.
- StockChart toolbar now always renders (was hidden in 10m intraday mode
because it gated on showSma) and includes a right-aligned `⬇ CSV`
button next to the SMA/RSI/MACD chips. Button is disabled when ohlcv
is empty.
Also addresses the reviewer's non-blocking note on f18493f:
ShareButton.tsx header comment claimed clipboard-first, but the code
calls navigator.share() first. Comment now matches the actual order:
share → clipboard → execCommand → "복사 안됨".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
// 종목 페이지 공유 버튼 — 현재 URL 을 클립보드에 복사.
|
||||
// Toss 의 종목 공유 패턴 (개별 모달 없이 즉시 복사 + 짧은 토스트).
|
||||
// 종목 페이지 공유 버튼 — 현재 URL 을 공유 / 클립보드에 복사.
|
||||
// Toss 의 종목 공유 패턴 (개별 모달 없이 즉시 시도 + 짧은 토스트).
|
||||
//
|
||||
// 폴백 순서:
|
||||
// 1) navigator.clipboard.writeText (HTTPS or localhost)
|
||||
// 2) document.execCommand("copy") (legacy, http LAN 등 secure context 아닌 경우)
|
||||
// 3) 두 경로 모두 실패면 "복사 안됨" 안내
|
||||
// 동작 우선순위:
|
||||
// 1) navigator.share (모바일 native sheet — 성공 시 OS 시트가 피드백, 토스트 안 띄움)
|
||||
// 2) navigator.clipboard.writeText (HTTPS or localhost — 클립보드에 복사 + "링크 복사됨")
|
||||
// 3) document.execCommand("copy") (legacy, http LAN 등 secure context 아닌 경우)
|
||||
// 4) 모두 실패면 "복사 안됨" 안내
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type UTCTimestamp,
|
||||
} from "lightweight-charts";
|
||||
import type { ChartPayload, LatestPredictionResponse } from "../lib/api";
|
||||
import { csvFilename, downloadCsv, ohlcvToCsv } from "../lib/csv";
|
||||
import type { Targets } from "../lib/targets";
|
||||
|
||||
type Props = {
|
||||
@@ -755,67 +756,87 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
||||
{showSma && (
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
|
||||
{SMA_PRESETS.map((p) => {
|
||||
const on = active.has(p.window);
|
||||
return (
|
||||
<button
|
||||
key={p.window}
|
||||
type="button"
|
||||
onClick={() => toggle(p.window)}
|
||||
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
||||
on
|
||||
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: on ? p.color : "transparent", border: `1px solid ${p.color}` }}
|
||||
/>
|
||||
{p.label}
|
||||
</button>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
|
||||
{showSma && (
|
||||
<>
|
||||
{SMA_PRESETS.map((p) => {
|
||||
const on = active.has(p.window);
|
||||
return (
|
||||
<button
|
||||
key={p.window}
|
||||
type="button"
|
||||
onClick={() => toggle(p.window)}
|
||||
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
||||
on
|
||||
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-pressed={on}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: on ? p.color : "transparent", border: `1px solid ${p.color}` }}
|
||||
/>
|
||||
{p.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="mx-1 text-zinc-700">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRsiOn((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
||||
rsiOn
|
||||
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-pressed={rsiOn}
|
||||
title="상대강도지수 (Relative Strength Index, 14일)"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: rsiOn ? "#e879f9" : "transparent", border: "1px solid #e879f9" }}
|
||||
/>
|
||||
RSI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMacdOn((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
||||
macdOn
|
||||
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-pressed={macdOn}
|
||||
title="MACD (12/26/9) — 추세 전환 신호"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: macdOn ? "#60a5fa" : "transparent", border: "1px solid #60a5fa" }}
|
||||
/>
|
||||
MACD
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const filename = csvFilename(
|
||||
chart.name,
|
||||
chart.code,
|
||||
chart.interval,
|
||||
chart.range.from,
|
||||
chart.range.to,
|
||||
);
|
||||
})}
|
||||
<span className="mx-1 text-zinc-700">|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRsiOn((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
||||
rsiOn
|
||||
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-pressed={rsiOn}
|
||||
title="상대강도지수 (Relative Strength Index, 14일)"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: rsiOn ? "#e879f9" : "transparent", border: "1px solid #e879f9" }}
|
||||
/>
|
||||
RSI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMacdOn((v) => !v)}
|
||||
className={`flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] transition ${
|
||||
macdOn
|
||||
? "border-zinc-600 bg-zinc-800/80 text-zinc-100"
|
||||
: "border-zinc-800 bg-transparent text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-pressed={macdOn}
|
||||
title="MACD (12/26/9) — 추세 전환 신호"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: macdOn ? "#60a5fa" : "transparent", border: "1px solid #60a5fa" }}
|
||||
/>
|
||||
MACD
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
downloadCsv(filename, ohlcvToCsv(chart.ohlcv));
|
||||
}}
|
||||
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 호환)"
|
||||
>
|
||||
⬇ CSV
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative h-[460px] w-full">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
<CrosshairLabel info={hover ?? lastSummary} isHover={hover != null} />
|
||||
|
||||
Reference in New Issue
Block a user