대시보드 방식 이전

This commit is contained in:
dohyeons 2025-10-16 09:55:14 +09:00
parent d7613713cf
commit 18e2280623
7 changed files with 498 additions and 99 deletions

View File

@ -156,8 +156,6 @@ export default function DashboardListPage() {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[80px]"></TableHead>
@ -166,29 +164,10 @@ export default function DashboardListPage() {
<TableBody>
{dashboards.map((dashboard) => (
<TableRow key={dashboard.id} className="cursor-pointer hover:bg-gray-50">
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{dashboard.title}
{dashboard.isPublic && (
<Badge variant="outline" className="text-xs">
</Badge>
)}
</div>
</TableCell>
<TableCell className="font-medium">{dashboard.title}</TableCell>
<TableCell className="max-w-md truncate text-sm text-gray-500">
{dashboard.description || "-"}
</TableCell>
<TableCell>
<Badge variant="secondary">{dashboard.elementsCount || 0}</Badge>
</TableCell>
<TableCell>
{dashboard.isPublic ? (
<Badge className="bg-green-100 text-green-800"></Badge>
) : (
<Badge variant="outline"></Badge>
)}
</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.createdAt)}</TableCell>
<TableCell className="text-sm text-gray-500">{formatDate(dashboard.updatedAt)}</TableCell>
<TableCell>

View File

@ -110,6 +110,7 @@ interface CanvasElementProps {
element: DashboardElement;
isSelected: boolean;
cellSize: number;
canvasWidth?: number;
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
onRemove: (id: string) => void;
onSelect: (id: string | null) => void;
@ -126,6 +127,7 @@ export function CanvasElement({
element,
isSelected,
cellSize,
canvasWidth = 1560,
onUpdate,
onRemove,
onSelect,
@ -207,7 +209,7 @@ export function CanvasElement({
const rawY = Math.max(0, dragStart.elementY + deltaY);
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width;
const maxX = canvasWidth - element.size.width;
rawX = Math.min(rawX, maxX);
setTempPosition({ x: rawX, y: rawY });
@ -250,7 +252,7 @@ export function CanvasElement({
}
// 가로 너비가 캔버스를 벗어나지 않도록 제한
const maxWidth = GRID_CONFIG.CANVAS_WIDTH - newX;
const maxWidth = canvasWidth - newX;
newWidth = Math.min(newWidth, maxWidth);
// 임시 크기/위치 저장 (스냅 안 됨)
@ -258,7 +260,7 @@ export function CanvasElement({
setTempSize({ width: newWidth, height: newHeight });
}
},
[isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype],
[isDragging, isResizing, dragStart, resizeStart, element.size.width, element.type, element.subtype, canvasWidth],
);
// 마우스 업 처리 (그리드 스냅 적용)
@ -269,7 +271,7 @@ export function CanvasElement({
const snappedY = snapToGrid(tempPosition.y, cellSize);
// X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한
const maxX = GRID_CONFIG.CANVAS_WIDTH - element.size.width;
const maxX = canvasWidth - element.size.width;
snappedX = Math.min(snappedX, maxX);
onUpdate(element.id, {
@ -287,7 +289,7 @@ export function CanvasElement({
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
// 가로 너비가 캔버스를 벗어나지 않도록 최종 제한
const maxWidth = GRID_CONFIG.CANVAS_WIDTH - snappedX;
const maxWidth = canvasWidth - snappedX;
snappedWidth = Math.min(snappedWidth, maxWidth);
onUpdate(element.id, {
@ -301,7 +303,7 @@ export function CanvasElement({
setIsDragging(false);
setIsResizing(false);
}, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize]);
}, [isDragging, isResizing, tempPosition, tempSize, element.id, element.size.width, onUpdate, cellSize, canvasWidth]);
// 전역 마우스 이벤트 등록
React.useEffect(() => {
@ -545,12 +547,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "status-summary" ? (
// 커스텀 상태 카드 - 범용 위젯
<div className="widget-interactive-area h-full w-full">
<StatusSummaryWidget
element={element}
title="상태 요약"
icon="📊"
bgGradient="from-slate-50 to-blue-50"
/>
<StatusSummaryWidget element={element} title="상태 요약" icon="📊" bgGradient="from-slate-50 to-blue-50" />
</div>
) : /* element.type === "widget" && element.subtype === "list-summary" ? (
// 커스텀 목록 카드 - 범용 위젯 (다른 분 작업 중 - 임시 주석)
@ -576,10 +573,10 @@ export function CanvasElement({
icon="📊"
bgGradient="from-slate-50 to-blue-50"
statusConfig={{
"배송중": { label: "배송중", color: "blue" },
"완료": { label: "완료", color: "green" },
"지연": { label: "지연", color: "red" },
"픽업 대기": { label: "픽업 대기", color: "yellow" }
: { label: "배송중", color: "blue" },
: { label: "완료", color: "green" },
: { label: "지연", color: "red" },
"픽업 대기": { label: "픽업 대기", color: "yellow" },
}}
/>
</div>

View File

@ -1,9 +1,9 @@
"use client";
import React, { forwardRef, useState, useCallback, useEffect } from "react";
import React, { forwardRef, useState, useCallback, useMemo } from "react";
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
import { CanvasElement } from "./CanvasElement";
import { GRID_CONFIG, snapToGrid } from "./gridUtils";
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
interface DashboardCanvasProps {
elements: DashboardElement[];
@ -14,6 +14,8 @@ interface DashboardCanvasProps {
onSelectElement: (id: string | null) => void;
onConfigureElement?: (element: DashboardElement) => void;
backgroundColor?: string;
canvasWidth?: number;
canvasHeight?: number;
}
/**
@ -34,11 +36,17 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
onSelectElement,
onConfigureElement,
backgroundColor = "#f9fafb",
canvasWidth = 1560,
canvasHeight = 768,
},
ref,
) => {
const [isDragOver, setIsDragOver] = useState(false);
// 현재 캔버스 크기에 맞는 그리드 설정 계산
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
const cellSize = gridConfig.CELL_SIZE;
// 드래그 오버 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
@ -71,20 +79,20 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
// 그리드에 스냅 (고정 셀 크기 사용)
let snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE);
const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE);
// 그리드에 스냅 (동적 셀 크기 사용)
let snappedX = snapToGrid(rawX, cellSize);
const snappedY = snapToGrid(rawY, cellSize);
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = GRID_CONFIG.CANVAS_WIDTH - GRID_CONFIG.CELL_SIZE * 2; // 최소 2칸 너비 보장
const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
snappedX = Math.max(0, Math.min(snappedX, maxX));
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
} catch (error) {
// console.error('드롭 데이터 파싱 오류:', error);
} catch {
// 드롭 데이터 파싱 오류 무시
}
},
[ref, onCreateElement],
[ref, onCreateElement, canvasWidth, cellSize],
);
// 캔버스 클릭 시 선택 해제
@ -97,28 +105,23 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
[onSelectElement],
);
// 고정 그리드 크기
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
// 동적 그리드 크기 계산
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
// 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장)
const minCanvasHeight = Math.max(
typeof window !== "undefined" ? window.innerHeight : 800,
...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px
);
// 12개 컬럼 구분선 위치 계산
const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap);
return (
<div
ref={ref}
className={`relative rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
className={`relative h-full w-full rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
style={{
backgroundColor,
width: `${GRID_CONFIG.CANVAS_WIDTH}px`,
minHeight: `${minCanvasHeight}px`,
// 12 컬럼 그리드 배경
// 세밀한 그리드 배경
backgroundImage: `
linear-gradient(rgba(59, 130, 246, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.15) 1px, transparent 1px)
linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px)
`,
backgroundSize: gridSize,
backgroundPosition: "0 0",
@ -129,13 +132,33 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
onDrop={handleDrop}
onClick={handleCanvasClick}
>
{/* 12개 컬럼 메인 구분선 */}
{columnLines.map((x, i) => (
<div
key={`col-${i}`}
className="pointer-events-none absolute top-0 h-full"
style={{
left: `${x}px`,
width: "2px",
zIndex: 1,
}}
/>
))}
{/* 배치된 요소들 렌더링 */}
{elements.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
<div className="text-center">
<div className="text-sm"> </div>
</div>
</div>
)}
{elements.map((element) => (
<CanvasElement
key={element.id}
element={element}
isSelected={selectedElement === element.id}
cellSize={GRID_CONFIG.CELL_SIZE}
cellSize={cellSize}
canvasWidth={canvasWidth}
onUpdate={onUpdateElement}
onRemove={onRemoveElement}
onSelect={onSelectElement}

View File

@ -3,12 +3,12 @@
import React, { useState, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { DashboardCanvas } from "./DashboardCanvas";
import { DashboardSidebar } from "./DashboardSidebar";
import { DashboardToolbar } from "./DashboardToolbar";
import { DashboardTopMenu } from "./DashboardTopMenu";
import { ElementConfigModal } from "./ElementConfigModal";
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
import { DashboardElement, ElementType, ElementSubtype } from "./types";
import { GRID_CONFIG } from "./gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector";
interface DashboardDesignerProps {
dashboardId?: string;
@ -33,6 +33,13 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
const [canvasBackgroundColor, setCanvasBackgroundColor] = useState<string>("#f9fafb");
const canvasRef = useRef<HTMLDivElement>(null);
// 화면 해상도 자동 감지 및 기본 해상도 설정
const [screenResolution] = useState<Resolution>(() => detectScreenResolution());
const [resolution, setResolution] = useState<Resolution>(screenResolution);
// 현재 해상도 설정
const canvasConfig = RESOLUTIONS[resolution];
// 대시보드 ID가 props로 전달되면 로드
React.useEffect(() => {
if (initialDashboardId) {
@ -81,9 +88,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
}
};
// 새로운 요소 생성 (고정 그리드 기반 기본 크기)
// 새로운 요소 생성 (동적 그리드 기반 기본 크기)
const createElement = useCallback(
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
// 좌표 유효성 검사
if (isNaN(x) || isNaN(y)) {
console.error("Invalid coordinates:", { x, y });
return;
}
// 기본 크기 설정
let defaultCells = { width: 2, height: 2 }; // 기본 위젯 크기
@ -93,7 +106,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
defaultCells = { width: 2, height: 3 }; // 달력 최소 크기
}
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
// 현재 해상도에 맞는 셀 크기 계산
const cellSize = Math.floor((canvasConfig.width + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
@ -112,7 +127,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
setElementCounter((prev) => prev + 1);
setSelectedElement(newElement.id);
},
[elementCounter],
[elementCounter, canvasConfig.width],
);
// 메뉴에서 요소 추가 시 (캔버스 중앙에 배치)
const addElementFromMenu = useCallback(
(type: ElementType, subtype: ElementSubtype) => {
// 캔버스 중앙 좌표 계산
const centerX = Math.floor(canvasConfig.width / 2);
const centerY = Math.floor(canvasConfig.height / 2);
// 좌표 유효성 확인
if (isNaN(centerX) || isNaN(centerY)) {
console.error("Invalid canvas config:", canvasConfig);
return;
}
createElement(type, subtype, centerX, centerY);
},
[canvasConfig.width, canvasConfig.height, createElement],
);
// 요소 업데이트
@ -245,25 +278,30 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
}
return (
<div className="flex h-full bg-gray-50">
{/* 캔버스 영역 */}
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
{/* 편집 중인 대시보드 표시 */}
{dashboardTitle && (
<div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
📝 : {dashboardTitle}
</div>
)}
<DashboardToolbar
onClearCanvas={clearCanvas}
<div className="flex h-full flex-col bg-gray-50">
{/* 상단 메뉴바 */}
<DashboardTopMenu
onSaveLayout={saveLayout}
canvasBackgroundColor={canvasBackgroundColor}
onCanvasBackgroundColorChange={setCanvasBackgroundColor}
onClearCanvas={clearCanvas}
onViewDashboard={dashboardId ? () => router.push(`/dashboard/${dashboardId}`) : undefined}
dashboardTitle={dashboardTitle}
onAddElement={addElementFromMenu}
resolution={resolution}
onResolutionChange={setResolution}
currentScreenResolution={screenResolution}
backgroundColor={canvasBackgroundColor}
onBackgroundColorChange={setCanvasBackgroundColor}
/>
{/* 캔버스 중앙 정렬 컨테이너 */}
<div className="flex justify-center p-4">
{/* 캔버스 영역 - 해상도에 따른 크기, 중앙 정렬 */}
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gray-100">
<div
className="relative shadow-2xl"
style={{
width: `${canvasConfig.width}px`,
height: `${canvasConfig.height}px`,
}}
>
<DashboardCanvas
ref={canvasRef}
elements={elements}
@ -274,13 +312,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
onSelectElement={setSelectedElement}
onConfigureElement={openConfigModal}
backgroundColor={canvasBackgroundColor}
canvasWidth={canvasConfig.width}
canvasHeight={canvasConfig.height}
/>
</div>
</div>
{/* 사이드바 */}
<DashboardSidebar />
{/* 요소 설정 모달 */}
{configModalElement && (
<>

View File

@ -0,0 +1,223 @@
"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, Eye, Palette } from "lucide-react";
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;
onViewDashboard?: () => 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,
onViewDashboard,
dashboardTitle,
onAddElement,
resolution = "fhd",
onResolutionChange,
currentScreenResolution,
backgroundColor = "#f9fafb",
onBackgroundColorChange,
}: DashboardTopMenuProps) {
// 차트 선택 시 캔버스 중앙에 추가
const handleChartSelect = (value: string) => {
if (onAddElement) {
onAddElement("chart", value as ElementSubtype);
}
};
// 위젯 선택 시 캔버스 중앙에 추가
const handleWidgetSelect = (value: string) => {
if (onAddElement) {
onAddElement("widget", value as ElementSubtype);
}
};
return (
<div className="flex h-16 items-center justify-between border-b bg-white 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-gray-900">{dashboardTitle}</span>
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700"> </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-gray-300" />
{/* 배경색 선택 */}
{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-gray-300" 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-gray-300" />
{/* 차트 선택 */}
<Select 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="pie"> </SelectItem>
<SelectItem value="donut"> </SelectItem>
<SelectItem value="combo"> </SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{/* 위젯 선택 */}
<Select onValueChange={handleWidgetSelect}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="위젯 추가" />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="list"> </SelectItem>
<SelectItem value="map"></SelectItem>
</SelectGroup>
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="weather"></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="maintenance"> </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">
{onViewDashboard && (
<Button variant="outline" size="sm" onClick={onViewDashboard} className="gap-2">
<Eye className="h-4 w-4" />
</Button>
)}
<Button variant="outline" size="sm" onClick={onClearCanvas} className="gap-2 text-red-600 hover:text-red-700">
<Trash2 className="h-4 w-4" />
</Button>
<Button size="sm" onClick={onSaveLayout} className="gap-2">
<Save className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,122 @@
"use client";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Monitor } from "lucide-react";
export type Resolution = "hd" | "fhd" | "qhd" | "uhd";
export interface ResolutionConfig {
width: number;
height: number;
label: string;
}
export const RESOLUTIONS: Record<Resolution, ResolutionConfig> = {
hd: {
width: 1280 - 360,
height: 720 - 312,
label: "HD (1280x720)",
},
fhd: {
width: 1920 - 360,
height: 1080 - 312,
label: "Full HD (1920x1080)",
},
qhd: {
width: 2560 - 360,
height: 1440 - 312,
label: "QHD (2560x1440)",
},
uhd: {
width: 3840 - 360,
height: 2160 - 312,
label: "4K UHD (3840x2160)",
},
};
interface ResolutionSelectorProps {
value: Resolution;
onChange: (resolution: Resolution) => void;
currentScreenResolution?: Resolution;
}
/**
*
*/
export function detectScreenResolution(): Resolution {
if (typeof window === "undefined") return "fhd";
const width = window.screen.width;
const height = window.screen.height;
// 화면 해상도에 따라 적절한 캔버스 해상도 반환
if (width >= 3840 || height >= 2160) return "uhd";
if (width >= 2560 || height >= 1440) return "qhd";
if (width >= 1920 || height >= 1080) return "fhd";
return "hd";
}
/**
*
* - HD, Full HD, QHD, 4K UHD
* - 12 ,
* -
*/
export function ResolutionSelector({ value, onChange, currentScreenResolution }: ResolutionSelectorProps) {
const currentConfig = RESOLUTIONS[value];
const screenConfig = currentScreenResolution ? RESOLUTIONS[currentScreenResolution] : null;
// 현재 선택된 해상도가 화면보다 큰지 확인
const isTooLarge =
screenConfig &&
(currentConfig.width > screenConfig.width + 360 || currentConfig.height > screenConfig.height + 312);
return (
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-gray-500" />
<Select value={value} onValueChange={(v) => onChange(v as Resolution)}>
<SelectTrigger className={`w-[180px] ${isTooLarge ? "border-orange-500" : ""}`}>
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[99999]">
<SelectGroup>
<SelectLabel> </SelectLabel>
<SelectItem value="hd">
<div className="flex items-center gap-2">
<span>HD</span>
<span className="text-xs text-gray-500">1280x720</span>
</div>
</SelectItem>
<SelectItem value="fhd">
<div className="flex items-center gap-2">
<span>Full HD</span>
<span className="text-xs text-gray-500">1920x1080</span>
</div>
</SelectItem>
<SelectItem value="qhd">
<div className="flex items-center gap-2">
<span>QHD</span>
<span className="text-xs text-gray-500">2560x1440</span>
</div>
</SelectItem>
<SelectItem value="uhd">
<div className="flex items-center gap-2">
<span>4K UHD</span>
<span className="text-xs text-gray-500">3840x2160</span>
</div>
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{isTooLarge && <span className="text-xs text-orange-600"> </span>}
</div>
);
}

View File

@ -5,18 +5,36 @@
* -
*/
// 그리드 설정 (고정 크기)
// 기본 그리드 설정 (FHD 기준)
export const GRID_CONFIG = {
COLUMNS: 12,
CELL_SIZE: 132, // 고정 셀 크기
GAP: 8, // 셀 간격
COLUMNS: 12, // 모든 해상도에서 12칸 고정
GAP: 8, // 셀 간격 고정
SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값)
// 계산식: (132 + 8) × 12 - 8 = 1672px (그리드)
// 추가 여백 10px 포함 = 1682px
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
} as const;
/**
*
* : (CELL_SIZE + GAP) * 12 - GAP = canvasWidth
* CELL_SIZE = (canvasWidth + GAP) / 12 - GAP
*/
export function calculateCellSize(canvasWidth: number): number {
return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
}
/**
*
*/
export function calculateGridConfig(canvasWidth: number) {
const cellSize = calculateCellSize(canvasWidth);
return {
...GRID_CONFIG,
CELL_SIZE: cellSize,
CANVAS_WIDTH: canvasWidth,
};
}
/**
* (gap )
*/