대시보드 다운로드 기능 추가

This commit is contained in:
leeheejin
2025-10-29 16:57:38 +09:00
parent 398c47618b
commit 2517261db9
8 changed files with 679 additions and 15 deletions

View File

@@ -466,7 +466,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
return (
<div
ref={ref}
className={`relative w-full ${isDragOver ? "bg-blue-50/50" : ""} `}
className={`dashboard-canvas relative w-full ${isDragOver ? "bg-blue-50/50" : ""} `}
style={{
backgroundColor,
height: `${canvasHeight}px`,

View File

@@ -610,7 +610,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
<div className="flex flex-1 items-start justify-center bg-gray-100 p-8">
<div className="dashboard-canvas-container flex flex-1 items-start justify-center bg-gray-100 p-8">
<div
className="relative"
style={{

View File

@@ -11,7 +11,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Save, Trash2, Palette } from "lucide-react";
import { Save, Trash2, Palette, Download } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ElementType, ElementSubtype } from "./types";
import { ResolutionSelector, Resolution } from "./ResolutionSelector";
import { Input } from "@/components/ui/input";
@@ -66,6 +72,198 @@ export function DashboardTopMenu({
}
};
// 대시보드 다운로드
// 헬퍼 함수: dataUrl로 다운로드 처리
const handleDownloadWithDataUrl = async (
dataUrl: string,
format: "png" | "pdf",
canvasWidth: number,
canvasHeight: number
) => {
if (format === "png") {
console.log("💾 PNG 다운로드 시작...");
const link = document.createElement("a");
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.png`;
link.download = filename;
link.href = dataUrl;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log("✅ PNG 다운로드 완료:", filename);
} else {
console.log("📄 PDF 생성 중...");
const jsPDF = (await import("jspdf")).default;
// dataUrl에서 이미지 크기 계산
const img = new Image();
img.src = dataUrl;
await new Promise((resolve) => {
img.onload = resolve;
});
console.log("📐 이미지 실제 크기:", { width: img.width, height: img.height });
console.log("📐 캔버스 계산 크기:", { width: canvasWidth, height: canvasHeight });
// PDF 크기 계산 (A4 기준)
const imgWidth = 210; // A4 width in mm
const actualHeight = canvasHeight;
const actualWidth = canvasWidth;
const imgHeight = (actualHeight * imgWidth) / actualWidth;
console.log("📄 PDF 크기:", { width: imgWidth, height: imgHeight });
const pdf = new jsPDF({
orientation: imgHeight > imgWidth ? "portrait" : "landscape",
unit: "mm",
format: [imgWidth, imgHeight],
});
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
const filename = `${dashboardTitle || "dashboard"}_${new Date().toISOString().split("T")[0]}.pdf`;
pdf.save(filename);
console.log("✅ PDF 다운로드 완료:", filename);
}
};
const handleDownload = async (format: "png" | "pdf") => {
try {
console.log("🔍 다운로드 시작:", format);
// 실제 위젯들이 있는 캔버스 찾기
const canvas = document.querySelector(".dashboard-canvas") as HTMLElement;
console.log("🔍 캔버스 찾기:", canvas);
if (!canvas) {
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
return;
}
console.log("📸 html-to-image 로딩 중...");
// html-to-image 동적 import
const { toPng, toJpeg } = await import("html-to-image");
console.log("📸 캔버스 캡처 중...");
// 3D/WebGL 렌더링 완료 대기
console.log("⏳ 3D 렌더링 완료 대기 중...");
await new Promise((resolve) => setTimeout(resolve, 1000));
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
console.log("🎨 WebGL 캔버스 처리 중...");
const webglCanvases = canvas.querySelectorAll("canvas");
const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = [];
webglCanvases.forEach((webglCanvas) => {
try {
const rect = webglCanvas.getBoundingClientRect();
const dataUrl = webglCanvas.toDataURL("image/png");
webglImages.push({ canvas: webglCanvas, dataUrl, rect });
console.log("✅ WebGL 캔버스 캡처:", { width: rect.width, height: rect.height });
} catch (error) {
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
}
});
// 캔버스의 실제 크기와 위치 가져오기
const rect = canvas.getBoundingClientRect();
const canvasWidth = canvas.scrollWidth;
// 실제 콘텐츠의 최하단 위치 계산
const children = canvas.querySelectorAll(".canvas-element");
let maxBottom = 0;
children.forEach((child) => {
const childRect = child.getBoundingClientRect();
const relativeBottom = childRect.bottom - rect.top;
if (relativeBottom > maxBottom) {
maxBottom = relativeBottom;
}
});
// 실제 콘텐츠 높이 + 여유 공간 (50px)
const canvasHeight = maxBottom > 0 ? maxBottom + 50 : canvas.scrollHeight;
console.log("📐 캔버스 정보:", {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
scroll: { width: canvasWidth, height: canvas.scrollHeight },
calculated: { width: canvasWidth, height: canvasHeight },
maxBottom: maxBottom,
webglCount: webglImages.length
});
// html-to-image로 캔버스 캡처 (WebGL 제외)
const dataUrl = await toPng(canvas, {
backgroundColor: backgroundColor || "#ffffff",
width: canvasWidth,
height: canvasHeight,
pixelRatio: 2, // 고해상도
cacheBust: true,
skipFonts: false,
preferredFontFormat: 'woff2',
filter: (node) => {
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
if (node instanceof HTMLCanvasElement) {
return false;
}
return true;
},
});
// WebGL 캔버스를 이미지 위에 합성
if (webglImages.length > 0) {
console.log("🖼️ WebGL 이미지 합성 중...");
const img = new Image();
img.src = dataUrl;
await new Promise((resolve) => {
img.onload = resolve;
});
// 새 캔버스에 합성
const compositeCanvas = document.createElement("canvas");
compositeCanvas.width = img.width;
compositeCanvas.height = img.height;
const ctx = compositeCanvas.getContext("2d");
if (ctx) {
// 기본 이미지 그리기
ctx.drawImage(img, 0, 0);
// WebGL 이미지들을 위치에 맞게 그리기
for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) {
const webglImg = new Image();
webglImg.src = webglDataUrl;
await new Promise((resolve) => {
webglImg.onload = resolve;
});
// 상대 위치 계산 (pixelRatio 2 고려)
const relativeX = (webglRect.left - rect.left) * 2;
const relativeY = (webglRect.top - rect.top) * 2;
const width = webglRect.width * 2;
const height = webglRect.height * 2;
ctx.drawImage(webglImg, relativeX, relativeY, width, height);
console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height });
}
// 합성된 이미지를 dataUrl로 변환
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
console.log("✅ 최종 합성 완료");
// 기존 dataUrl을 합성된 것으로 교체
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
}
}
console.log("✅ 캡처 완료 (WebGL 없음)");
// WebGL이 없는 경우 기본 다운로드
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
} catch (error) {
console.error("❌ 다운로드 실패:", error);
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
}
};
return (
<div className="flex h-16 items-center justify-between border-b bg-white px-6 shadow-sm">
{/* 좌측: 대시보드 제목 */}
@@ -152,7 +350,7 @@ export function DashboardTopMenu({
)}
<div className="h-6 w-px bg-gray-300" />
{/* 차트 선택 */}
<Select value={chartValue} onValueChange={handleChartSelect}>
<SelectTrigger className="w-[200px]">
@@ -227,6 +425,20 @@ export function DashboardTopMenu({
<Save className="h-4 w-4" />
</Button>
{/* 다운로드 버튼 */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Download className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleDownload("png")}>PNG </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDownload("pdf")}>PDF </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);

View File

@@ -647,6 +647,7 @@ export default function Yard3DCanvas({
fov: 50,
}}
shadows
gl={{ preserveDrawingBuffer: true }}
>
<Suspense fallback={null}>
<Scene