- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
428 lines
16 KiB
TypeScript
428 lines
16 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") {
|
|
const link = document.createElement("a");
|
|
const _fd = new Date();
|
|
const filename = `${dashboardTitle || "dashboard"}_${_fd.getFullYear()}-${String(_fd.getMonth() + 1).padStart(2, "0")}-${String(_fd.getDate()).padStart(2, "0")}.png`;
|
|
link.download = filename;
|
|
link.href = dataUrl;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
} else {
|
|
const jsPDF = (await import("jspdf")).default;
|
|
|
|
// dataUrl에서 이미지 크기 계산
|
|
const img = new Image();
|
|
img.src = dataUrl;
|
|
await new Promise((resolve) => {
|
|
img.onload = resolve;
|
|
});
|
|
|
|
// PDF 크기 계산 (A4 기준)
|
|
const imgWidth = 210; // A4 width in mm
|
|
const actualHeight = canvasHeight;
|
|
const actualWidth = canvasWidth;
|
|
const imgHeight = (actualHeight * imgWidth) / actualWidth;
|
|
|
|
const pdf = new jsPDF({
|
|
orientation: imgHeight > imgWidth ? "portrait" : "landscape",
|
|
unit: "mm",
|
|
format: [imgWidth, imgHeight],
|
|
});
|
|
|
|
pdf.addImage(dataUrl, "PNG", 0, 0, imgWidth, imgHeight);
|
|
const _pd = new Date();
|
|
const filename = `${dashboardTitle || "dashboard"}_${_pd.getFullYear()}-${String(_pd.getMonth() + 1).padStart(2, "0")}-${String(_pd.getDate()).padStart(2, "0")}.pdf`;
|
|
pdf.save(filename);
|
|
}
|
|
};
|
|
|
|
const handleDownload = async (format: "png" | "pdf") => {
|
|
try {
|
|
// 실제 위젯들이 있는 캔버스 찾기
|
|
const canvas = document.querySelector(".dashboard-canvas") as HTMLElement;
|
|
|
|
if (!canvas) {
|
|
alert("대시보드를 찾을 수 없습니다. 페이지를 새로고침 후 다시 시도해주세요.");
|
|
return;
|
|
}
|
|
|
|
// html-to-image 동적 import
|
|
// @ts-expect-error - 동적 import
|
|
const { toPng } = await import("html-to-image");
|
|
|
|
// 3D/WebGL 렌더링 완료 대기
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
|
|
// WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존)
|
|
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 });
|
|
} catch {
|
|
// WebGL 캔버스 캡처 실패 시 무시
|
|
}
|
|
});
|
|
|
|
// 캔버스의 실제 크기와 위치 가져오기
|
|
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;
|
|
|
|
// html-to-image로 캔버스 캡처 (WebGL 제외)
|
|
const getDefaultBackgroundColor = () => {
|
|
if (typeof window === "undefined") return "#ffffff";
|
|
const bgValue = getComputedStyle(document.documentElement).getPropertyValue("--background").trim();
|
|
return bgValue ? `hsl(${bgValue})` : "#ffffff";
|
|
};
|
|
|
|
const dataUrl = await toPng(canvas, {
|
|
backgroundColor: backgroundColor || getDefaultBackgroundColor(),
|
|
width: canvasWidth,
|
|
height: canvasHeight,
|
|
pixelRatio: 2, // 고해상도
|
|
cacheBust: true,
|
|
skipFonts: false,
|
|
preferredFontFormat: "woff2",
|
|
filter: (node: Node) => {
|
|
// WebGL 캔버스는 제외 (나중에 수동으로 합성)
|
|
if (node instanceof HTMLCanvasElement) {
|
|
return false;
|
|
}
|
|
return true;
|
|
},
|
|
});
|
|
|
|
// WebGL 캔버스를 이미지 위에 합성
|
|
if (webglImages.length > 0) {
|
|
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);
|
|
}
|
|
|
|
// 합성된 이미지를 dataUrl로 변환
|
|
const compositeDataUrl = compositeCanvas.toDataURL("image/png");
|
|
|
|
// 기존 dataUrl을 합성된 것으로 교체
|
|
return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight);
|
|
}
|
|
}
|
|
|
|
// WebGL이 없는 경우 기본 다운로드
|
|
await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight);
|
|
} catch (error) {
|
|
alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background flex h-16 items-center justify-between border-b px-4 py-3 shadow-sm">
|
|
{/* 좌측: 대시보드 제목 */}
|
|
<div className="flex items-center gap-2 sm:gap-4">
|
|
{dashboardTitle && (
|
|
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
|
<span className="text-foreground text-base font-semibold sm:text-lg">{dashboardTitle}</span>
|
|
<span className="bg-primary/10 text-primary w-fit rounded px-2 py-0.5 text-xs font-medium">편집 중</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 중앙: 해상도 선택 & 요소 추가 */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* 해상도 선택 */}
|
|
{onResolutionChange && (
|
|
<ResolutionSelector
|
|
value={resolution}
|
|
onChange={onResolutionChange}
|
|
currentScreenResolution={currentScreenResolution}
|
|
/>
|
|
)}
|
|
|
|
<div className="bg-border h-6 w-px" />
|
|
|
|
{/* 배경색 선택 */}
|
|
{onBackgroundColorChange && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-2">
|
|
<Palette className="h-4 w-4" />
|
|
<div className="border-border h-4 w-4 rounded 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="bg-border hidden h-6 w-px sm:block" />
|
|
|
|
{/* 차트 선택 */}
|
|
<Select value={chartValue} onValueChange={handleChartSelect}>
|
|
<SelectTrigger className="w-full sm: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-full sm: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 flex-wrap items-center gap-3 sm:flex-nowrap">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onClearCanvas}
|
|
className="text-destructive hover:text-destructive gap-2"
|
|
>
|
|
<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>
|
|
);
|
|
}
|