대시보드 다운로드 기능 추가
This commit is contained in:
parent
398c47618b
commit
2517261db9
|
|
@ -163,6 +163,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
|||
<DashboardViewer
|
||||
elements={dashboard.elements}
|
||||
dashboardId={dashboard.id}
|
||||
dashboardTitle={dashboard.title}
|
||||
backgroundColor={dashboard.settings?.backgroundColor}
|
||||
resolution={dashboard.settings?.resolution}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -647,6 +647,7 @@ export default function Yard3DCanvas({
|
|||
fov: 50,
|
||||
}}
|
||||
shadows
|
||||
gl={{ preserveDrawingBuffer: true }}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Scene
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ import { DashboardElement, QueryResult } from "@/components/admin/dashboard/type
|
|||
import { ChartRenderer } from "@/components/admin/dashboard/charts/ChartRenderer";
|
||||
import { DashboardProvider } from "@/contexts/DashboardContext";
|
||||
import { RESOLUTIONS, Resolution } from "@/components/admin/dashboard/ResolutionSelector";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Download } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
// 위젯 동적 import - 모든 위젯
|
||||
|
|
@ -179,6 +187,7 @@ interface DashboardViewerProps {
|
|||
refreshInterval?: number; // 전체 대시보드 새로고침 간격 (ms)
|
||||
backgroundColor?: string; // 배경색
|
||||
resolution?: string; // 대시보드 해상도
|
||||
dashboardTitle?: string; // 대시보드 제목 (다운로드 파일명용)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -192,10 +201,217 @@ export function DashboardViewer({
|
|||
refreshInterval,
|
||||
backgroundColor = "#f9fafb",
|
||||
resolution = "fhd",
|
||||
dashboardTitle,
|
||||
}: DashboardViewerProps) {
|
||||
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
|
||||
const [loadingElements, setLoadingElements] = useState<Set<string>>(new Set());
|
||||
|
||||
// 대시보드 다운로드
|
||||
// 헬퍼 함수: 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 = useCallback(
|
||||
async (format: "png" | "pdf") => {
|
||||
try {
|
||||
console.log("🔍 다운로드 시작:", format);
|
||||
|
||||
const canvas = document.querySelector(".dashboard-viewer-canvas") as HTMLElement;
|
||||
console.log("🔍 캔버스 찾기:", canvas);
|
||||
|
||||
if (!canvas) {
|
||||
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📸 html-to-image 로딩 중...");
|
||||
// html-to-image 동적 import
|
||||
const { toPng } = 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,
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
bottom: rect.bottom
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("⚠️ WebGL 캔버스 캡처 실패:", error);
|
||||
}
|
||||
});
|
||||
|
||||
// 캔버스의 실제 크기와 위치 가져오기
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasWidth = canvas.scrollWidth;
|
||||
|
||||
// 실제 콘텐츠의 최하단 위치 계산
|
||||
// 뷰어 모드에서는 모든 자식 요소를 확인
|
||||
const children = canvas.querySelectorAll("*");
|
||||
let maxBottom = 0;
|
||||
children.forEach((child) => {
|
||||
// canvas 자신이나 너무 작은 요소는 제외
|
||||
if (child === canvas || child.clientHeight < 10) {
|
||||
return;
|
||||
}
|
||||
const childRect = child.getBoundingClientRect();
|
||||
const relativeBottom = childRect.bottom - rect.top;
|
||||
if (relativeBottom > maxBottom) {
|
||||
maxBottom = relativeBottom;
|
||||
}
|
||||
});
|
||||
|
||||
// 실제 콘텐츠 높이 + 여유 공간 (50px)
|
||||
// maxBottom이 0이면 기본 캔버스 높이 사용
|
||||
const canvasHeight = maxBottom > 50 ? maxBottom + 50 : Math.max(canvas.scrollHeight, rect.height);
|
||||
|
||||
console.log("📐 캔버스 정보:", {
|
||||
rect: { x: rect.x, y: rect.y, left: rect.left, top: rect.top, 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("✅ 최종 합성 완료");
|
||||
|
||||
// 합성된 이미지로 다운로드
|
||||
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)}`);
|
||||
}
|
||||
},
|
||||
[backgroundColor, dashboardTitle],
|
||||
);
|
||||
|
||||
// 캔버스 설정 계산
|
||||
const canvasConfig = useMemo(() => {
|
||||
return RESOLUTIONS[resolution as Resolution] || RESOLUTIONS.fhd;
|
||||
|
|
@ -327,8 +543,24 @@ export function DashboardViewer({
|
|||
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
|
||||
<div className="hidden min-h-screen bg-gray-100 py-8 lg:block" style={{ backgroundColor }}>
|
||||
<div className="mx-auto px-4" style={{ maxWidth: `${canvasConfig.width}px` }}>
|
||||
{/* 다운로드 버튼 */}
|
||||
<div className="mb-4 flex justify-end">
|
||||
<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
|
||||
className="relative rounded-lg"
|
||||
className="dashboard-viewer-canvas relative rounded-lg"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: `${canvasConfig.height}px`,
|
||||
|
|
@ -354,16 +586,34 @@ export function DashboardViewer({
|
|||
{/* 태블릿 이하: 반응형 세로 정렬 */}
|
||||
<div className="block min-h-screen bg-gray-100 p-4 lg:hidden" style={{ backgroundColor }}>
|
||||
<div className="mx-auto max-w-3xl space-y-4">
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={true}
|
||||
/>
|
||||
))}
|
||||
{/* 다운로드 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<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 className="dashboard-viewer-canvas">
|
||||
{sortedElements.map((element) => (
|
||||
<ViewerElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
data={elementData[element.id]}
|
||||
isLoading={loadingElements.has(element.id)}
|
||||
onRefresh={() => loadElementData(element)}
|
||||
isMobile={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardProvider>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,10 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"docx-preview": "^0.3.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
|
|
@ -5585,6 +5588,19 @@
|
|||
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
|
||||
|
|
@ -6638,6 +6654,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
|
|
@ -6851,6 +6876,26 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
|
|
@ -7077,6 +7122,18 @@
|
|||
"node": "^14.18.0 || >=16.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.46.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.46.0.tgz",
|
||||
"integrity": "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
|
|
@ -7127,6 +7184,15 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||
|
|
@ -8785,6 +8851,23 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png/node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
"integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
|
|
@ -9306,6 +9389,25 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
|
|
@ -9447,6 +9549,12 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
|
|
@ -10028,6 +10136,23 @@
|
|||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
|
||||
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jsts": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/jsts/-/jsts-2.7.1.tgz",
|
||||
|
|
@ -11052,6 +11177,13 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -11403,6 +11535,16 @@
|
|||
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rbush": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
|
||||
|
|
@ -11823,6 +11965,13 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/regexp.prototype.flags": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||
|
|
@ -11911,6 +12060,16 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/robust-predicates": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
|
||||
|
|
@ -12333,6 +12492,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/stats-gl": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz",
|
||||
|
|
@ -12576,6 +12745,16 @@
|
|||
"react": ">=17.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sweepline-intersections": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz",
|
||||
|
|
@ -12655,6 +12834,15 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.180.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||
|
|
@ -13171,6 +13359,15 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,10 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"docx-preview": "^0.3.6",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
|
|
|
|||
Loading…
Reference in New Issue