|
|
|
|
@@ -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>
|
|
|
|
|
);
|
|
|
|
|
|