446 lines
17 KiB
TypeScript
446 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectGroup,
|
|
SelectItem,
|
|
SelectLabel,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select";
|
|
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";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
|
|
interface DashboardTopMenuProps {
|
|
onSaveLayout: () => void;
|
|
onClearCanvas: () => void;
|
|
dashboardTitle?: string;
|
|
onAddElement?: (type: ElementType, subtype: ElementSubtype) => void;
|
|
resolution?: Resolution;
|
|
onResolutionChange?: (resolution: Resolution) => void;
|
|
currentScreenResolution?: Resolution;
|
|
backgroundColor?: string;
|
|
onBackgroundColorChange?: (color: string) => void;
|
|
}
|
|
|
|
/**
|
|
* 대시보드 편집 화면 상단 메뉴바
|
|
* - 차트/위젯 선택 (셀렉트박스)
|
|
* - 저장/초기화 버튼
|
|
*/
|
|
export function DashboardTopMenu({
|
|
onSaveLayout,
|
|
onClearCanvas,
|
|
dashboardTitle,
|
|
onAddElement,
|
|
resolution = "fhd",
|
|
onResolutionChange,
|
|
currentScreenResolution,
|
|
backgroundColor = "#f9fafb",
|
|
onBackgroundColorChange,
|
|
}: DashboardTopMenuProps) {
|
|
const [chartValue, setChartValue] = React.useState<string>("");
|
|
const [widgetValue, setWidgetValue] = React.useState<string>("");
|
|
|
|
// 차트 선택 시 캔버스 중앙에 추가
|
|
const handleChartSelect = (value: string) => {
|
|
if (onAddElement) {
|
|
onAddElement("chart", value as ElementSubtype);
|
|
// 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게
|
|
setTimeout(() => setChartValue(""), 0);
|
|
}
|
|
};
|
|
|
|
// 위젯 선택 시 캔버스 중앙에 추가
|
|
const handleWidgetSelect = (value: string) => {
|
|
if (onAddElement) {
|
|
onAddElement("widget", value as ElementSubtype);
|
|
// 선택 후 즉시 리셋하여 같은 항목을 연속으로 선택 가능하게
|
|
setTimeout(() => setWidgetValue(""), 0);
|
|
}
|
|
};
|
|
|
|
// 대시보드 다운로드
|
|
// 헬퍼 함수: 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-background px-6 shadow-sm">
|
|
{/* 좌측: 대시보드 제목 */}
|
|
<div className="flex items-center gap-4">
|
|
{dashboardTitle && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-lg font-semibold text-foreground">{dashboardTitle}</span>
|
|
<span className="rounded bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">편집 중</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 중앙: 해상도 선택 & 요소 추가 */}
|
|
<div className="flex items-center gap-3">
|
|
{/* 해상도 선택 */}
|
|
{onResolutionChange && (
|
|
<ResolutionSelector
|
|
value={resolution}
|
|
onChange={onResolutionChange}
|
|
currentScreenResolution={currentScreenResolution}
|
|
/>
|
|
)}
|
|
|
|
<div className="h-6 w-px bg-border" />
|
|
|
|
{/* 배경색 선택 */}
|
|
{onBackgroundColorChange && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Palette className="h-4 w-4" />
|
|
<div className="h-4 w-4 rounded border border-border" style={{ backgroundColor }} />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="z-[99999] w-64">
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-sm font-medium">캔버스 배경색</label>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
type="color"
|
|
value={backgroundColor}
|
|
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
|
className="h-10 w-20 cursor-pointer"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
value={backgroundColor}
|
|
onChange={(e) => onBackgroundColorChange(e.target.value)}
|
|
placeholder="#f9fafb"
|
|
className="flex-1"
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-6 gap-2">
|
|
{[
|
|
"#ffffff",
|
|
"#f9fafb",
|
|
"#f3f4f6",
|
|
"#e5e7eb",
|
|
"#1f2937",
|
|
"#111827",
|
|
"#fef3c7",
|
|
"#fde68a",
|
|
"#dbeafe",
|
|
"#bfdbfe",
|
|
"#fecaca",
|
|
"#fca5a5",
|
|
].map((color) => (
|
|
<button
|
|
key={color}
|
|
className="h-8 w-8 rounded border-2 transition-transform hover:scale-110"
|
|
style={{
|
|
backgroundColor: color,
|
|
borderColor: backgroundColor === color ? "#3b82f6" : "#d1d5db",
|
|
}}
|
|
onClick={() => onBackgroundColorChange(color)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
|
|
<div className="h-6 w-px bg-border" />
|
|
|
|
{/* 차트 선택 */}
|
|
<Select value={chartValue} onValueChange={handleChartSelect}>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="차트 추가" />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[99999]">
|
|
<SelectGroup>
|
|
<SelectLabel>축 기반 차트</SelectLabel>
|
|
<SelectItem value="bar">바 차트</SelectItem>
|
|
<SelectItem value="horizontal-bar">수평 바 차트</SelectItem>
|
|
<SelectItem value="stacked-bar">누적 바 차트</SelectItem>
|
|
<SelectItem value="line">꺾은선 차트</SelectItem>
|
|
<SelectItem value="area">영역 차트</SelectItem>
|
|
<SelectItem value="combo">콤보 차트</SelectItem>
|
|
</SelectGroup>
|
|
<SelectGroup>
|
|
<SelectLabel>원형 차트</SelectLabel>
|
|
<SelectItem value="pie">원형 차트</SelectItem>
|
|
<SelectItem value="donut">도넛 차트</SelectItem>
|
|
</SelectGroup>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{/* 위젯 선택 */}
|
|
<Select value={widgetValue} onValueChange={handleWidgetSelect}>
|
|
<SelectTrigger className="w-[200px]">
|
|
<SelectValue placeholder="위젯 추가" />
|
|
</SelectTrigger>
|
|
<SelectContent className="z-[99999]">
|
|
<SelectGroup>
|
|
<SelectLabel>데이터 위젯</SelectLabel>
|
|
<SelectItem value="map-summary-v2">지도</SelectItem>
|
|
<SelectItem value="chart">테스트용 차트 위젯</SelectItem>
|
|
<SelectItem value="list-v2">리스트</SelectItem>
|
|
<SelectItem value="custom-metric-v2">통계 카드</SelectItem>
|
|
<SelectItem value="risk-alert-v2">리스크/알림</SelectItem>
|
|
<SelectItem value="yard-management-3d">야드 관리 3D</SelectItem>
|
|
{/* <SelectItem value="transport-stats">커스텀 통계 카드</SelectItem> */}
|
|
{/* <SelectItem value="status-summary">커스텀 상태 카드</SelectItem> */}
|
|
</SelectGroup>
|
|
<SelectGroup>
|
|
<SelectLabel>일반 위젯</SelectLabel>
|
|
<SelectItem value="weather">날씨</SelectItem>
|
|
{/* <SelectItem value="weather-map">날씨 지도</SelectItem> */}
|
|
<SelectItem value="exchange">환율</SelectItem>
|
|
<SelectItem value="calculator">계산기</SelectItem>
|
|
<SelectItem value="calendar">달력</SelectItem>
|
|
<SelectItem value="clock">시계</SelectItem>
|
|
<SelectItem value="todo">일정관리 위젯</SelectItem>
|
|
{/* <SelectItem value="booking-alert">예약 알림</SelectItem> */}
|
|
<SelectItem value="document">문서</SelectItem>
|
|
{/* <SelectItem value="risk-alert">리스크 알림</SelectItem> */}
|
|
</SelectGroup>
|
|
{/* 범용 위젯으로 대체 가능하여 주석처리 */}
|
|
{/* <SelectGroup>
|
|
<SelectLabel>차량 관리</SelectLabel>
|
|
<SelectItem value="vehicle-status">차량 상태</SelectItem>
|
|
<SelectItem value="vehicle-list">차량 목록</SelectItem>
|
|
<SelectItem value="vehicle-map">차량 위치</SelectItem>
|
|
</SelectGroup> */}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 우측: 액션 버튼 */}
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="sm" onClick={onClearCanvas} className="gap-2 text-destructive hover:text-destructive">
|
|
<Trash2 className="h-4 w-4" />
|
|
초기화
|
|
</Button>
|
|
<Button size="sm" onClick={onSaveLayout} className="gap-2">
|
|
<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>
|
|
);
|
|
}
|