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";
|
"use client";
|
||||||
|
|
||||||
// 종목 페이지 공유 버튼 — 현재 URL 을 클립보드에 복사.
|
// 종목 페이지 공유 버튼 — 현재 URL 을 공유 / 클립보드에 복사.
|
||||||
// Toss 의 종목 공유 패턴 (개별 모달 없이 즉시 복사 + 짧은 토스트).
|
// Toss 의 종목 공유 패턴 (개별 모달 없이 즉시 시도 + 짧은 토스트).
|
||||||
//
|
//
|
||||||
// 폴백 순서:
|
// 동작 우선순위:
|
||||||
// 1) navigator.clipboard.writeText (HTTPS or localhost)
|
// 1) navigator.share (모바일 native sheet — 성공 시 OS 시트가 피드백, 토스트 안 띄움)
|
||||||
// 2) document.execCommand("copy") (legacy, http LAN 등 secure context 아닌 경우)
|
// 2) navigator.clipboard.writeText (HTTPS or localhost — 클립보드에 복사 + "링크 복사됨")
|
||||||
// 3) 두 경로 모두 실패면 "복사 안됨" 안내
|
// 3) document.execCommand("copy") (legacy, http LAN 등 secure context 아닌 경우)
|
||||||
|
// 4) 모두 실패면 "복사 안됨" 안내
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type UTCTimestamp,
|
type UTCTimestamp,
|
||||||
} 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 type { Targets } from "../lib/targets";
|
import type { Targets } from "../lib/targets";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -755,8 +756,9 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full rounded-lg border border-zinc-800 bg-zinc-900/30 p-2">
|
<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">
|
<div className="mb-1 flex flex-wrap items-center gap-1.5 px-1">
|
||||||
|
{showSma && (
|
||||||
|
<>
|
||||||
{SMA_PRESETS.map((p) => {
|
{SMA_PRESETS.map((p) => {
|
||||||
const on = active.has(p.window);
|
const on = active.has(p.window);
|
||||||
return (
|
return (
|
||||||
@@ -814,8 +816,27 @@ export function StockChart({ chart, prediction, targets }: Props) {
|
|||||||
/>
|
/>
|
||||||
MACD
|
MACD
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const filename = csvFilename(
|
||||||
|
chart.name,
|
||||||
|
chart.code,
|
||||||
|
chart.interval,
|
||||||
|
chart.range.from,
|
||||||
|
chart.range.to,
|
||||||
|
);
|
||||||
|
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 className="relative h-[460px] w-full">
|
||||||
<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} />
|
||||||
|
|||||||
69
web/lib/csv.ts
Normal file
69
web/lib/csv.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// OHLCV → CSV 변환 + 다운로드 트리거.
|
||||||
|
// 차트 데이터를 분석가 친화 CSV (UTF-8 BOM, Excel 호환) 로 내려받게 해줌.
|
||||||
|
|
||||||
|
import type { ChartInterval, OhlcvPoint } from "./api";
|
||||||
|
|
||||||
|
function escapeField(v: string): string {
|
||||||
|
// 표준 RFC 4180 — 쉼표/큰따옴표/개행 포함 시 큰따옴표로 감싸고 내부 큰따옴표는 이중화.
|
||||||
|
if (v.includes(",") || v.includes('"') || v.includes("\n")) {
|
||||||
|
return `"${v.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(n: number | null | undefined): string {
|
||||||
|
if (n == null || !Number.isFinite(n)) return "";
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ohlcvToCsv(ohlcv: OhlcvPoint[]): string {
|
||||||
|
const header = ["date", "open", "high", "low", "close", "volume"];
|
||||||
|
const lines: string[] = [header.join(",")];
|
||||||
|
for (const p of ohlcv) {
|
||||||
|
lines.push(
|
||||||
|
[
|
||||||
|
escapeField(p.date),
|
||||||
|
num(p.open),
|
||||||
|
num(p.high),
|
||||||
|
num(p.low),
|
||||||
|
num(p.close),
|
||||||
|
num(p.volume),
|
||||||
|
].join(","),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadCsv(filename: string, csv: string): boolean {
|
||||||
|
if (typeof window === "undefined" || typeof document === "undefined") return false;
|
||||||
|
// Excel BOM — 한글 Excel 이 UTF-8 로 추론하도록.
|
||||||
|
const BOM = "\uFEFF";
|
||||||
|
const blob = new Blob([BOM + csv], { type: "text/csv;charset=utf-8" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
try {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `삼성전자_005930_1d_2025-08-01_2025-11-28.csv` — 파일시스템 금지 문자만 `_` 치환.
|
||||||
|
export function csvFilename(
|
||||||
|
name: string,
|
||||||
|
code: string,
|
||||||
|
interval: ChartInterval,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): string {
|
||||||
|
const safe = (s: string) => s.replace(/[\\/:*?"<>|]/g, "_").trim() || "data";
|
||||||
|
return `${safe(name)}_${code}_${interval}_${from}_${to}.csv`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user