From 1e0c917f6870b87a510cfbdc0a38eddb2b38b790 Mon Sep 17 00:00:00 2001 From: claude-owner Date: Fri, 29 May 2026 00:11:57 +0900 Subject: [PATCH] feat(chart): CSV export of current chart range + fix ShareButton comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New lib/csv.ts: RFC 4180 escaping, UTF-8 BOM prefix (한글 Excel 호환), Blob + temporary anchor download, filename `____.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 --- web/components/ShareButton.tsx | 13 +-- web/components/StockChart.tsx | 141 +++++++++++++++++++-------------- web/lib/csv.ts | 69 ++++++++++++++++ 3 files changed, 157 insertions(+), 66 deletions(-) create mode 100644 web/lib/csv.ts diff --git a/web/components/ShareButton.tsx b/web/components/ShareButton.tsx index c572666..972b10d 100644 --- a/web/components/ShareButton.tsx +++ b/web/components/ShareButton.tsx @@ -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"; diff --git a/web/components/StockChart.tsx b/web/components/StockChart.tsx index 122e888..cdd37ab 100644 --- a/web/components/StockChart.tsx +++ b/web/components/StockChart.tsx @@ -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 (
- {showSma && ( -
- {SMA_PRESETS.map((p) => { - const on = active.has(p.window); - return ( - +
+ {showSma && ( + <> + {SMA_PRESETS.map((p) => { + const on = active.has(p.window); + return ( + + ); + })} + | + + + + )} + - -
- )} + 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 + +
diff --git a/web/lib/csv.ts b/web/lib/csv.ts new file mode 100644 index 0000000..6c4f5d1 --- /dev/null +++ b/web/lib/csv.ts @@ -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`; +}