From 32024a6d7067607ed18e5533fa019a813cc7e7fc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 15:08:31 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A0=88=ED=8F=AC=ED=8A=B8=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=97=90=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=201=EC=B0=A8=20=EC=A0=81=EC=9A=A9(2=EC=B0=A8=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EA=B0=9C=EC=84=A0=20=ED=95=84=EC=9A=94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report-grid-system-implementation-plan.md | 591 ++++++++++++++++++ .../components/report/designer/GridLayer.tsx | 48 ++ .../report/designer/GridSettingsPanel.tsx | 138 ++++ .../report/designer/ReportDesignerCanvas.tsx | 16 +- .../designer/ReportDesignerRightPanel.tsx | 16 +- .../report/designer/ReportPreviewModal.tsx | 325 ++++++++-- frontend/contexts/ReportDesignerContext.tsx | 150 ++++- frontend/lib/utils/gridUtils.ts | 492 ++++----------- frontend/types/report.ts | 18 + 9 files changed, 1341 insertions(+), 453 deletions(-) create mode 100644 docs/report-grid-system-implementation-plan.md create mode 100644 frontend/components/report/designer/GridLayer.tsx create mode 100644 frontend/components/report/designer/GridSettingsPanel.tsx diff --git a/docs/report-grid-system-implementation-plan.md b/docs/report-grid-system-implementation-plan.md new file mode 100644 index 00000000..31bc4d82 --- /dev/null +++ b/docs/report-grid-system-implementation-plan.md @@ -0,0 +1,591 @@ +# 리포트 디자이너 그리드 시스템 구현 계획 + +## 개요 + +현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다. +안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다. + +## 목표 + +1. **정렬된 레이아웃**: 그리드 기반으로 요소들이 자동 정렬 +2. **Word/PDF 변환 개선**: 그리드 정보를 활용하여 정확한 문서 변환 +3. **직관적인 UI**: 그리드 시각화를 통한 명확한 배치 가이드 +4. **사용자 제어**: 그리드 크기, 가시성 등 사용자 설정 가능 + +## 핵심 개념 + +### 그리드 시스템 + +```typescript +interface GridConfig { + // 그리드 설정 + cellWidth: number; // 그리드 셀 너비 (px) + cellHeight: number; // 그리드 셀 높이 (px) + rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight) + columns: number; // 가로 그리드 수 (계산값: pageWidth / cellWidth) + + // 표시 설정 + visible: boolean; // 그리드 표시 여부 + snapToGrid: boolean; // 그리드 스냅 활성화 여부 + + // 시각적 설정 + gridColor: string; // 그리드 선 색상 + gridOpacity: number; // 그리드 투명도 (0-1) +} +``` + +### 컴포넌트 위치/크기 (그리드 기반) + +```typescript +interface ComponentPosition { + // 그리드 좌표 (셀 단위) + gridX: number; // 시작 열 (0부터 시작) + gridY: number; // 시작 행 (0부터 시작) + gridWidth: number; // 차지하는 열 수 + gridHeight: number; // 차지하는 행 수 + + // 실제 픽셀 좌표 (계산값) + x: number; // gridX * cellWidth + y: number; // gridY * cellHeight + width: number; // gridWidth * cellWidth + height: number; // gridHeight * cellHeight +} +``` + +## 구현 단계 + +### Phase 1: 그리드 시스템 기반 구조 + +#### 1.1 타입 정의 + +- **파일**: `frontend/types/report.ts` +- **내용**: + - `GridConfig` 인터페이스 추가 + - `ComponentConfig`에 `gridX`, `gridY`, `gridWidth`, `gridHeight` 추가 + - `ReportPage`에 `gridConfig` 추가 + +#### 1.2 Context 확장 + +- **파일**: `frontend/contexts/ReportDesignerContext.tsx` +- **내용**: + - `gridConfig` 상태 추가 + - `updateGridConfig()` 함수 추가 + - `snapToGrid()` 유틸리티 함수 추가 + - 컴포넌트 추가/이동/리사이즈 시 그리드 스냅 적용 + +#### 1.3 그리드 계산 유틸리티 + +- **파일**: `frontend/lib/utils/gridUtils.ts` (신규) +- **내용**: + + ```typescript + // 픽셀 좌표 → 그리드 좌표 변환 + export function pixelToGrid(pixel: number, cellSize: number): number; + + // 그리드 좌표 → 픽셀 좌표 변환 + export function gridToPixel(grid: number, cellSize: number): number; + + // 컴포넌트 위치/크기를 그리드에 스냅 + export function snapComponentToGrid( + component: ComponentConfig, + gridConfig: GridConfig + ): ComponentConfig; + + // 그리드 충돌 감지 + export function detectGridCollision( + component: ComponentConfig, + otherComponents: ComponentConfig[] + ): boolean; + ``` + +### Phase 2: 그리드 시각화 + +#### 2.1 그리드 레이어 컴포넌트 + +- **파일**: `frontend/components/report/designer/GridLayer.tsx` (신규) +- **내용**: + - Canvas 위에 그리드 선 렌더링 + - SVG 또는 Canvas API 사용 + - 그리드 크기/색상/투명도 적용 + - 줌/스크롤 시에도 정확한 위치 유지 + +```tsx +interface GridLayerProps { + gridConfig: GridConfig; + pageWidth: number; + pageHeight: number; +} + +export function GridLayer({ + gridConfig, + pageWidth, + pageHeight, +}: GridLayerProps) { + if (!gridConfig.visible) return null; + + // SVG로 그리드 선 렌더링 + return ( + + {/* 세로 선 */} + {Array.from({ length: gridConfig.columns + 1 }).map((_, i) => ( + + ))} + {/* 가로 선 */} + {Array.from({ length: gridConfig.rows + 1 }).map((_, i) => ( + + ))} + + ); +} +``` + +#### 2.2 Canvas 통합 + +- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx` +- **내용**: + - `` 추가 + - 컴포넌트 렌더링 시 그리드 기반 위치 사용 + +### Phase 3: 드래그 앤 드롭 스냅 + +#### 3.1 드래그 시 그리드 스냅 + +- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx` +- **내용**: + - `useDrop` 훅 수정 + - 드롭 위치를 그리드에 스냅 + - 실시간 스냅 가이드 표시 + +```typescript +const [, drop] = useDrop({ + accept: ["TEXT", "LABEL", "TABLE", "SIGNATURE", "STAMP"], + drop: (item: any, monitor) => { + const offset = monitor.getClientOffset(); + if (!offset) return; + + // 캔버스 상대 좌표 계산 + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (!canvasRect) return; + + let x = offset.x - canvasRect.left; + let y = offset.y - canvasRect.top; + + // 그리드 스냅 적용 + if (gridConfig.snapToGrid) { + const gridX = Math.round(x / gridConfig.cellWidth); + const gridY = Math.round(y / gridConfig.cellHeight); + x = gridX * gridConfig.cellWidth; + y = gridY * gridConfig.cellHeight; + } + + // 컴포넌트 추가 + addComponent({ type: item.type, x, y }); + }, +}); +``` + +#### 3.2 리사이즈 시 그리드 스냅 + +- **파일**: `frontend/components/report/designer/ComponentWrapper.tsx` +- **내용**: + - `react-resizable` 또는 `react-rnd`의 `snap` 설정 활용 + - 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절 + +```typescript + { + let newX = d.x; + let newY = d.y; + + if (gridConfig.snapToGrid) { + const gridX = Math.round(newX / gridConfig.cellWidth); + const gridY = Math.round(newY / gridConfig.cellHeight); + newX = gridX * gridConfig.cellWidth; + newY = gridY * gridConfig.cellHeight; + } + + updateComponent(component.id, { x: newX, y: newY }); + }} + onResizeStop={(e, direction, ref, delta, position) => { + let newWidth = parseInt(ref.style.width); + let newHeight = parseInt(ref.style.height); + + if (gridConfig.snapToGrid) { + const gridWidth = Math.round(newWidth / gridConfig.cellWidth); + const gridHeight = Math.round(newHeight / gridConfig.cellHeight); + newWidth = gridWidth * gridConfig.cellWidth; + newHeight = gridHeight * gridConfig.cellHeight; + } + + updateComponent(component.id, { + width: newWidth, + height: newHeight, + ...position, + }); + }} + grid={ + gridConfig.snapToGrid + ? [gridConfig.cellWidth, gridConfig.cellHeight] + : undefined + } +/> +``` + +### Phase 4: 그리드 설정 UI + +#### 4.1 그리드 설정 패널 + +- **파일**: `frontend/components/report/designer/GridSettingsPanel.tsx` (신규) +- **내용**: + - 그리드 크기 조절 (cellWidth, cellHeight) + - 그리드 표시/숨김 토글 + - 스냅 활성화/비활성화 토글 + - 그리드 색상/투명도 조절 + +```tsx +export function GridSettingsPanel() { + const { gridConfig, updateGridConfig } = useReportDesigner(); + + return ( + + + 그리드 설정 + + + {/* 그리드 표시 */} +
+ + updateGridConfig({ visible })} + /> +
+ + {/* 스냅 활성화 */} +
+ + updateGridConfig({ snapToGrid })} + /> +
+ + {/* 셀 크기 */} +
+ + + updateGridConfig({ cellWidth: parseInt(e.target.value) }) + } + min={10} + max={100} + /> +
+ +
+ + + updateGridConfig({ cellHeight: parseInt(e.target.value) }) + } + min={10} + max={100} + /> +
+ + {/* 프리셋 */} +
+ + +
+
+
+ ); +} +``` + +#### 4.2 툴바에 그리드 토글 추가 + +- **파일**: `frontend/components/report/designer/ReportDesignerToolbar.tsx` +- **내용**: + - 그리드 표시/숨김 버튼 + - 그리드 설정 모달 열기 버튼 + - 키보드 단축키 (`G` 키로 그리드 토글) + +### Phase 5: Word 변환 개선 + +#### 5.1 그리드 기반 레이아웃 변환 + +- **파일**: `frontend/components/report/designer/ReportPreviewModal.tsx` +- **내용**: + - 그리드 정보를 활용하여 더 정확한 테이블 레이아웃 생성 + - 그리드 행/열을 Word 테이블의 행/열로 매핑 + +```typescript +const handleDownloadWord = async () => { + // 그리드 기반으로 컴포넌트 배치 맵 생성 + const gridMap: (ComponentConfig | null)[][] = Array(gridConfig.rows) + .fill(null) + .map(() => Array(gridConfig.columns).fill(null)); + + // 각 컴포넌트를 그리드 맵에 배치 + for (const component of components) { + const gridX = Math.round(component.x / gridConfig.cellWidth); + const gridY = Math.round(component.y / gridConfig.cellHeight); + const gridWidth = Math.round(component.width / gridConfig.cellWidth); + const gridHeight = Math.round(component.height / gridConfig.cellHeight); + + // 컴포넌트가 차지하는 모든 셀에 참조 저장 + for (let y = gridY; y < gridY + gridHeight; y++) { + for (let x = gridX; x < gridX + gridWidth; x++) { + if (y < gridConfig.rows && x < gridConfig.columns) { + gridMap[y][x] = component; + } + } + } + } + + // 그리드 맵을 Word 테이블로 변환 + const tableRows: TableRow[] = []; + + for (let y = 0; y < gridConfig.rows; y++) { + const cells: TableCell[] = []; + let x = 0; + + while (x < gridConfig.columns) { + const component = gridMap[y][x]; + + if (!component) { + // 빈 셀 + cells.push(new TableCell({ children: [new Paragraph("")] })); + x++; + } else { + // 컴포넌트 셀 + const gridWidth = Math.round(component.width / gridConfig.cellWidth); + const gridHeight = Math.round(component.height / gridConfig.cellHeight); + + const cell = createTableCell(component, gridWidth, gridHeight); + if (cell) cells.push(cell); + + x += gridWidth; + } + } + + if (cells.length > 0) { + tableRows.push(new TableRow({ children: cells })); + } + } + + // ... Word 문서 생성 +}; +``` + +### Phase 6: 데이터 마이그레이션 + +#### 6.1 기존 레이아웃 자동 변환 + +- **파일**: `frontend/lib/utils/layoutMigration.ts` (신규) +- **내용**: + - 기존 절대 위치 데이터를 그리드 기반으로 변환 + - 가장 가까운 그리드 셀에 스냅 + - 마이그레이션 로그 생성 + +```typescript +export function migrateLayoutToGrid( + layout: ReportLayoutConfig, + gridConfig: GridConfig +): ReportLayoutConfig { + return { + ...layout, + pages: layout.pages.map((page) => ({ + ...page, + gridConfig, + components: page.components.map((component) => { + // 픽셀 좌표를 그리드 좌표로 변환 + const gridX = Math.round(component.x / gridConfig.cellWidth); + const gridY = Math.round(component.y / gridConfig.cellHeight); + const gridWidth = Math.max( + 1, + Math.round(component.width / gridConfig.cellWidth) + ); + const gridHeight = Math.max( + 1, + Math.round(component.height / gridConfig.cellHeight) + ); + + return { + ...component, + gridX, + gridY, + gridWidth, + gridHeight, + x: gridX * gridConfig.cellWidth, + y: gridY * gridConfig.cellHeight, + width: gridWidth * gridConfig.cellWidth, + height: gridHeight * gridConfig.cellHeight, + }; + }), + })), + }; +} +``` + +#### 6.2 마이그레이션 UI + +- **파일**: `frontend/components/report/designer/MigrationModal.tsx` (신규) +- **내용**: + - 기존 리포트 로드 시 마이그레이션 필요 여부 체크 + - 마이그레이션 전/후 미리보기 + - 사용자 확인 후 적용 + +## 데이터베이스 스키마 변경 + +### report_layout_pages 테이블 + +```sql +ALTER TABLE report_layout_pages +ADD COLUMN grid_cell_width INTEGER DEFAULT 20, +ADD COLUMN grid_cell_height INTEGER DEFAULT 20, +ADD COLUMN grid_visible BOOLEAN DEFAULT true, +ADD COLUMN grid_snap_enabled BOOLEAN DEFAULT true, +ADD COLUMN grid_color VARCHAR(7) DEFAULT '#e5e7eb', +ADD COLUMN grid_opacity DECIMAL(3,2) DEFAULT 0.5; +``` + +### report_layout_components 테이블 + +```sql +ALTER TABLE report_layout_components +ADD COLUMN grid_x INTEGER, +ADD COLUMN grid_y INTEGER, +ADD COLUMN grid_width INTEGER, +ADD COLUMN grid_height INTEGER; + +-- 기존 데이터 마이그레이션 +UPDATE report_layout_components +SET + grid_x = ROUND(position_x / 20.0), + grid_y = ROUND(position_y / 20.0), + grid_width = GREATEST(1, ROUND(width / 20.0)), + grid_height = GREATEST(1, ROUND(height / 20.0)) +WHERE grid_x IS NULL; +``` + +## 테스트 계획 + +### 단위 테스트 + +- `gridUtils.ts`의 모든 함수 테스트 +- 그리드 좌표 ↔ 픽셀 좌표 변환 정확성 +- 충돌 감지 로직 + +### 통합 테스트 + +- 드래그 앤 드롭 시 그리드 스냅 동작 +- 리사이즈 시 그리드 스냅 동작 +- 그리드 크기 변경 시 컴포넌트 재배치 + +### E2E 테스트 + +- 새 리포트 생성 및 그리드 설정 +- 기존 리포트 마이그레이션 +- Word 다운로드 시 레이아웃 정확성 + +## 예상 개발 일정 + +- **Phase 1**: 그리드 시스템 기반 구조 (2일) +- **Phase 2**: 그리드 시각화 (1일) +- **Phase 3**: 드래그 앤 드롭 스냅 (2일) +- **Phase 4**: 그리드 설정 UI (1일) +- **Phase 5**: Word 변환 개선 (2일) +- **Phase 6**: 데이터 마이그레이션 (1일) +- **테스트 및 디버깅**: (2일) + +**총 예상 기간**: 11일 + +## 기술적 고려사항 + +### 성능 최적화 + +- 그리드 렌더링: SVG 대신 Canvas API 고려 (많은 셀의 경우) +- 메모이제이션: 그리드 계산 결과 캐싱 +- 가상화: 큰 페이지에서 보이는 영역만 렌더링 + +### 사용자 경험 + +- 실시간 스냅 가이드: 드래그 중 스냅될 위치 미리 표시 +- 키보드 단축키: 방향키로 그리드 단위 이동, Shift+방향키로 픽셀 단위 미세 조정 +- 언두/리두: 그리드 스냅 적용 전/후 상태 저장 + +### 하위 호환성 + +- 기존 리포트는 자동 마이그레이션 제공 +- 마이그레이션 옵션: 자동 / 수동 선택 가능 +- 레거시 모드: 그리드 없이 자유 배치 가능 (옵션) + +## 추가 기능 (향후 확장) + +### 스마트 가이드 + +- 다른 컴포넌트와 정렬 시 가이드 라인 표시 +- 균등 간격 가이드 + +### 그리드 템플릿 + +- 자주 사용하는 그리드 레이아웃 템플릿 제공 +- 문서 종류별 프리셋 (계약서, 보고서, 송장 등) + +### 그리드 병합 + +- 여러 그리드 셀을 하나로 병합 +- 복잡한 레이아웃 지원 + +## 참고 자료 + +- Android Home Screen Widget System +- Microsoft Word Table Layout +- CSS Grid Layout +- Figma Auto Layout diff --git a/frontend/components/report/designer/GridLayer.tsx b/frontend/components/report/designer/GridLayer.tsx new file mode 100644 index 00000000..6c3cd2be --- /dev/null +++ b/frontend/components/report/designer/GridLayer.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { GridConfig } from "@/types/report"; + +interface GridLayerProps { + gridConfig: GridConfig; + pageWidth: number; + pageHeight: number; +} + +export function GridLayer({ gridConfig, pageWidth, pageHeight }: GridLayerProps) { + if (!gridConfig.visible) return null; + + const { cellWidth, cellHeight, columns, rows, gridColor, gridOpacity } = gridConfig; + + // SVG로 그리드 선 렌더링 + return ( + + {/* 세로 선 */} + {Array.from({ length: columns + 1 }).map((_, i) => ( + + ))} + + {/* 가로 선 */} + {Array.from({ length: rows + 1 }).map((_, i) => ( + + ))} + + ); +} diff --git a/frontend/components/report/designer/GridSettingsPanel.tsx b/frontend/components/report/designer/GridSettingsPanel.tsx new file mode 100644 index 00000000..301514ba --- /dev/null +++ b/frontend/components/report/designer/GridSettingsPanel.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useReportDesigner } from "@/contexts/ReportDesignerContext"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Slider } from "@/components/ui/slider"; + +export function GridSettingsPanel() { + const { gridConfig, updateGridConfig } = useReportDesigner(); + + return ( + + + 그리드 설정 + + + {/* 그리드 표시 */} +
+ + updateGridConfig({ visible })} /> +
+ + {/* 스냅 활성화 */} +
+ + updateGridConfig({ snapToGrid })} /> +
+ + {/* 프리셋 */} +
+ + +
+ + {/* 셀 너비 */} +
+
+ + {gridConfig.cellWidth}px +
+ updateGridConfig({ cellWidth: value })} + min={5} + max={100} + step={5} + className="w-full" + /> +
+ + {/* 셀 높이 */} +
+
+ + {gridConfig.cellHeight}px +
+ updateGridConfig({ cellHeight: value })} + min={5} + max={100} + step={5} + className="w-full" + /> +
+ + {/* 그리드 투명도 */} +
+
+ + {Math.round(gridConfig.gridOpacity * 100)}% +
+ updateGridConfig({ gridOpacity: value / 100 })} + min={10} + max={100} + step={10} + className="w-full" + /> +
+ + {/* 그리드 색상 */} +
+ +
+ updateGridConfig({ gridColor: e.target.value })} + className="h-8 w-16 cursor-pointer" + /> + updateGridConfig({ gridColor: e.target.value })} + className="h-8 flex-1 font-mono text-xs" + placeholder="#e5e7eb" + /> +
+
+ + {/* 그리드 정보 */} +
+
+ 행: + {gridConfig.rows} +
+
+ 열: + {gridConfig.columns} +
+
+
+
+ ); +} diff --git a/frontend/components/report/designer/ReportDesignerCanvas.tsx b/frontend/components/report/designer/ReportDesignerCanvas.tsx index ace87249..de18d715 100644 --- a/frontend/components/report/designer/ReportDesignerCanvas.tsx +++ b/frontend/components/report/designer/ReportDesignerCanvas.tsx @@ -6,6 +6,7 @@ import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { ComponentConfig } from "@/types/report"; import { CanvasComponent } from "./CanvasComponent"; import { Ruler } from "./Ruler"; +import { GridLayer } from "./GridLayer"; import { v4 as uuidv4 } from "uuid"; export function ReportDesignerCanvas() { @@ -32,6 +33,7 @@ export function ReportDesignerCanvas() { undo, redo, showRuler, + gridConfig, } = useReportDesigner(); const [{ isOver }, drop] = useDrop(() => ({ @@ -331,16 +333,16 @@ export function ReportDesignerCanvas() { style={{ width: `${canvasWidth}mm`, minHeight: `${canvasHeight}mm`, - backgroundImage: showGrid - ? ` - linear-gradient(to right, #e5e7eb 1px, transparent 1px), - linear-gradient(to bottom, #e5e7eb 1px, transparent 1px) - ` - : undefined, - backgroundSize: showGrid ? `${gridSize}px ${gridSize}px` : undefined, }} onClick={handleCanvasClick} > + {/* 그리드 레이어 */} + + {/* 페이지 여백 가이드 */} {currentPage && (
- + 페이지 @@ -111,6 +112,10 @@ export function ReportDesignerRightPanel() { 속성 + + + 그리드 + 쿼리 @@ -1396,6 +1401,15 @@ export function ReportDesignerRightPanel() { {/* 쿼리 탭 */} + {/* 그리드 탭 */} + + +
+ +
+
+
+ diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index fd0e9a31..97b3ac48 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -13,7 +13,21 @@ import { Printer, FileDown, FileText } from "lucide-react"; import { useReportDesigner } from "@/contexts/ReportDesignerContext"; import { useState } from "react"; import { useToast } from "@/hooks/use-toast"; -import { Document, Packer, Paragraph, TextRun, Table, TableCell, TableRow, WidthType } from "docx"; +// @ts-ignore - docx 라이브러리 타입 이슈 +import { + Document, + Packer, + Paragraph, + TextRun, + Table, + TableCell, + TableRow, + WidthType, + ImageRun, + AlignmentType, + VerticalAlign, + convertInchesToTwip, +} from "docx"; import { getFullImageUrl } from "@/lib/api/client"; interface ReportPreviewModalProps { @@ -268,82 +282,263 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) }); }; + // Base64를 Uint8Array로 변환 + const base64ToUint8Array = (base64: string): Uint8Array => { + const base64Data = base64.split(",")[1] || base64; + const binaryString = atob(base64Data); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; + }; + + // 컴포넌트를 TableCell로 변환 + const createTableCell = (component: any, pageWidth: number, widthPercent?: number): TableCell | null => { + const cellWidth = widthPercent || 100; + + if (component.type === "text" || component.type === "label") { + const value = getComponentValue(component); + return new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: value, + size: (component.fontSize || 13) * 2, + color: component.fontColor?.replace("#", "") || "000000", + bold: component.fontWeight === "bold", + }), + ], + alignment: + component.textAlign === "center" + ? AlignmentType.CENTER + : component.textAlign === "right" + ? AlignmentType.RIGHT + : AlignmentType.LEFT, + }), + ], + width: { size: cellWidth, type: WidthType.PERCENTAGE }, + verticalAlign: VerticalAlign.CENTER, + borders: { + top: { style: 0, size: 0, color: "FFFFFF" }, + bottom: { style: 0, size: 0, color: "FFFFFF" }, + left: { style: 0, size: 0, color: "FFFFFF" }, + right: { style: 0, size: 0, color: "FFFFFF" }, + }, + }); + } else if (component.type === "signature" || component.type === "stamp") { + if (component.imageUrl) { + try { + const imageData = base64ToUint8Array(component.imageUrl); + return new TableCell({ + children: [ + new Paragraph({ + children: [ + new ImageRun({ + data: imageData, + transformation: { + width: component.width || 150, + height: component.height || 50, + }, + }), + ], + alignment: AlignmentType.CENTER, + }), + ], + width: { size: cellWidth, type: WidthType.PERCENTAGE }, + verticalAlign: VerticalAlign.CENTER, + borders: { + top: { style: 0, size: 0, color: "FFFFFF" }, + bottom: { style: 0, size: 0, color: "FFFFFF" }, + left: { style: 0, size: 0, color: "FFFFFF" }, + right: { style: 0, size: 0, color: "FFFFFF" }, + }, + }); + } catch { + return new TableCell({ + children: [ + new Paragraph({ + children: [ + new TextRun({ + text: `[${component.type === "signature" ? "서명" : "도장"}]`, + size: 24, + }), + ], + }), + ], + width: { size: cellWidth, type: WidthType.PERCENTAGE }, + borders: { + top: { style: 0, size: 0, color: "FFFFFF" }, + bottom: { style: 0, size: 0, color: "FFFFFF" }, + left: { style: 0, size: 0, color: "FFFFFF" }, + right: { style: 0, size: 0, color: "FFFFFF" }, + }, + }); + } + } + } else if (component.type === "table" && component.queryId) { + const queryResult = getQueryResult(component.queryId); + if (queryResult && queryResult.rows.length > 0) { + const headerCells = queryResult.fields.map( + (field) => + new TableCell({ + children: [new Paragraph({ text: field })], + width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE }, + }), + ); + + const dataRows = queryResult.rows.map( + (row) => + new TableRow({ + children: queryResult.fields.map( + (field) => + new TableCell({ + children: [new Paragraph({ text: String(row[field] ?? "") })], + }), + ), + }), + ); + + const table = new Table({ + rows: [new TableRow({ children: headerCells }), ...dataRows], + width: { size: 100, type: WidthType.PERCENTAGE }, + }); + + return new TableCell({ + children: [table], + width: { size: cellWidth, type: WidthType.PERCENTAGE }, + borders: { + top: { style: 0, size: 0, color: "FFFFFF" }, + bottom: { style: 0, size: 0, color: "FFFFFF" }, + left: { style: 0, size: 0, color: "FFFFFF" }, + right: { style: 0, size: 0, color: "FFFFFF" }, + }, + }); + } + } + + return null; + }; + // WORD 다운로드 const handleDownloadWord = async () => { setIsExporting(true); try { - // 컴포넌트를 Paragraph로 변환 - const paragraphs: (Paragraph | Table)[] = []; - - // 모든 페이지의 컴포넌트 수집 - const allComponents = layoutConfig.pages + // 페이지별로 섹션 생성 + const sections = layoutConfig.pages .sort((a, b) => a.page_order - b.page_order) - .flatMap((page) => page.components); + .map((page) => { + // 페이지 크기 설정 (A4 기준) + const pageWidth = convertInchesToTwip(8.27); // A4 width in inches + const pageHeight = convertInchesToTwip(11.69); // A4 height in inches + const marginTop = convertInchesToTwip(page.margins.top / 96); // px to inches (96 DPI) + const marginBottom = convertInchesToTwip(page.margins.bottom / 96); + const marginLeft = convertInchesToTwip(page.margins.left / 96); + const marginRight = convertInchesToTwip(page.margins.right / 96); - // Y 좌표로 정렬 - const sortedComponents = [...allComponents].sort((a, b) => a.y - b.y); + // 페이지 내 컴포넌트를 Y좌표 기준으로 정렬 + const sortedComponents = [...page.components].sort((a, b) => { + // Y좌표 우선, 같으면 X좌표 + if (Math.abs(a.y - b.y) < 5) { + return a.x - b.x; + } + return a.y - b.y; + }); - for (const component of sortedComponents) { - if (component.type === "text" || component.type === "label") { - const value = getComponentValue(component); - paragraphs.push( - new Paragraph({ - children: [ - new TextRun({ - text: value, - size: (component.fontSize || 13) * 2, // pt to half-pt - color: component.fontColor?.replace("#", "") || "000000", - bold: component.fontWeight === "bold", - }), - ], - spacing: { - after: 200, - }, - }), - ); - } else if (component.type === "table" && component.queryId) { - const queryResult = getQueryResult(component.queryId); - if (queryResult && queryResult.rows.length > 0) { - // 테이블 헤더 - const headerCells = queryResult.fields.map( - (field) => - new TableCell({ - children: [new Paragraph({ text: field })], - width: { size: 100 / queryResult.fields.length, type: WidthType.PERCENTAGE }, - }), - ); + // 컴포넌트들을 행으로 그룹화 (같은 Y 범위 = 같은 행) + const rows: Array> = []; + const rowTolerance = 20; // Y 좌표 허용 오차 - // 테이블 행 - const dataRows = queryResult.rows.map( - (row) => - new TableRow({ - children: queryResult.fields.map( - (field) => - new TableCell({ - children: [new Paragraph({ text: String(row[field] ?? "") })], - }), - ), - }), - ); - - const table = new Table({ - rows: [new TableRow({ children: headerCells }), ...dataRows], - width: { size: 100, type: WidthType.PERCENTAGE }, - }); - - paragraphs.push(table); + for (const component of sortedComponents) { + const existingRow = rows.find((row) => Math.abs(row[0].y - component.y) < rowTolerance); + if (existingRow) { + existingRow.push(component); + } else { + rows.push([component]); + } } - } - } + + // 각 행 내에서 X좌표로 정렬 + rows.forEach((row) => row.sort((a, b) => a.x - b.x)); + + // 페이지 전체를 큰 테이블로 구성 (레이아웃 제어용) + const tableRows: TableRow[] = []; + + for (const row of rows) { + if (row.length === 1) { + // 단일 컴포넌트 - 전체 너비 사용 + const component = row[0]; + const cell = createTableCell(component, pageWidth); + if (cell) { + tableRows.push( + new TableRow({ + children: [cell], + height: { value: component.height * 15, rule: 1 }, // 최소 높이 설정 + }), + ); + } + } else { + // 여러 컴포넌트 - 가로 배치 + const cells: TableCell[] = []; + const totalWidth = row.reduce((sum, c) => sum + c.width, 0); + + for (const component of row) { + const widthPercent = (component.width / totalWidth) * 100; + const cell = createTableCell(component, pageWidth, widthPercent); + if (cell) { + cells.push(cell); + } + } + + if (cells.length > 0) { + const maxHeight = Math.max(...row.map((c) => c.height)); + tableRows.push( + new TableRow({ + children: cells, + height: { value: maxHeight * 15, rule: 1 }, + }), + ); + } + } + } + + return { + properties: { + page: { + width: pageWidth, + height: pageHeight, + margin: { + top: marginTop, + bottom: marginBottom, + left: marginLeft, + right: marginRight, + }, + }, + }, + children: + tableRows.length > 0 + ? [ + new Table({ + rows: tableRows, + width: { size: 100, type: WidthType.PERCENTAGE }, + borders: { + top: { style: 0, size: 0, color: "FFFFFF" }, + bottom: { style: 0, size: 0, color: "FFFFFF" }, + left: { style: 0, size: 0, color: "FFFFFF" }, + right: { style: 0, size: 0, color: "FFFFFF" }, + insideHorizontal: { style: 0, size: 0, color: "FFFFFF" }, + insideVertical: { style: 0, size: 0, color: "FFFFFF" }, + }, + }), + ] + : [new Paragraph({ text: "" })], + }; + }); // 문서 생성 const doc = new Document({ - sections: [ - { - properties: {}, - children: paragraphs, - }, - ], + sections, }); // Blob 생성 및 다운로드 diff --git a/frontend/contexts/ReportDesignerContext.tsx b/frontend/contexts/ReportDesignerContext.tsx index 1f58eea6..5c82c18a 100644 --- a/frontend/contexts/ReportDesignerContext.tsx +++ b/frontend/contexts/ReportDesignerContext.tsx @@ -1,10 +1,23 @@ "use client"; import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react"; -import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig } from "@/types/report"; +import { + ComponentConfig, + ReportDetail, + ReportLayout, + ReportPage, + ReportLayoutConfig, + GridConfig, +} from "@/types/report"; import { reportApi } from "@/lib/api/reportApi"; import { useToast } from "@/hooks/use-toast"; import { v4 as uuidv4 } from "uuid"; +import { + snapComponentToGrid, + createDefaultGridConfig, + calculateGridDimensions, + detectGridCollision, +} from "@/lib/utils/gridUtils"; export interface ReportQuery { id: string; @@ -71,6 +84,10 @@ interface ReportDesignerContextType { // 템플릿 적용 applyTemplate: (templateId: string) => void; + // 그리드 관리 + gridConfig: GridConfig; + updateGridConfig: (updates: Partial) => void; + // 캔버스 설정 canvasWidth: number; canvasHeight: number; @@ -209,10 +226,50 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin [], // ref를 사용하므로 의존성 배열 비움 ); - // 레이아웃 도구 설정 - const [gridSize, setGridSize] = useState(10); // Grid Snap 크기 (px) - const [showGrid, setShowGrid] = useState(true); // Grid 표시 여부 - const [snapToGrid, setSnapToGrid] = useState(true); // Grid Snap 활성화 + // 그리드 설정 + const [gridConfig, setGridConfig] = useState(() => { + // 기본 페이지 크기 (A4: 794 x 1123 px at 96 DPI) + const defaultPageWidth = 794; + const defaultPageHeight = 1123; + return createDefaultGridConfig(defaultPageWidth, defaultPageHeight); + }); + + // gridConfig 업데이트 함수 + const updateGridConfig = useCallback( + (updates: Partial) => { + setGridConfig((prev) => { + const newConfig = { ...prev, ...updates }; + + // cellWidth나 cellHeight가 변경되면 rows/columns 재계산 + if (updates.cellWidth || updates.cellHeight) { + const pageWidth = currentPage?.width ? currentPage.width * 3.7795275591 : 794; // mm to px + const pageHeight = currentPage?.height ? currentPage.height * 3.7795275591 : 1123; + const { rows, columns } = calculateGridDimensions( + pageWidth, + pageHeight, + newConfig.cellWidth, + newConfig.cellHeight, + ); + newConfig.rows = rows; + newConfig.columns = columns; + } + + return newConfig; + }); + }, + [currentPage], + ); + + // 레거시 호환성을 위한 별칭 + const gridSize = gridConfig.cellWidth; + const showGrid = gridConfig.visible; + const snapToGrid = gridConfig.snapToGrid; + const setGridSize = useCallback( + (size: number) => updateGridConfig({ cellWidth: size, cellHeight: size }), + [updateGridConfig], + ); + const setShowGrid = useCallback((visible: boolean) => updateGridConfig({ visible }), [updateGridConfig]); + const setSnapToGrid = useCallback((snap: boolean) => updateGridConfig({ snapToGrid: snap }), [updateGridConfig]); // 눈금자 표시 const [showRuler, setShowRuler] = useState(true); @@ -1178,9 +1235,23 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin // 컴포넌트 추가 (현재 페이지에) const addComponent = useCallback( (component: ComponentConfig) => { - setComponents((prev) => [...prev, component]); + // 그리드 스냅 적용 + const snappedComponent = snapComponentToGrid(component, gridConfig); + + // 충돌 감지 + const currentComponents = currentPage?.components || []; + if (detectGridCollision(snappedComponent, currentComponents, gridConfig)) { + toast({ + title: "경고", + description: "다른 컴포넌트와 겹칩니다. 다른 위치에 배치해주세요.", + variant: "destructive", + }); + return; + } + + setComponents((prev) => [...prev, snappedComponent]); }, - [setComponents], + [setComponents, gridConfig, currentPage, toast], ); // 컴포넌트 업데이트 (현재 페이지에서) @@ -1188,18 +1259,60 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin (id: string, updates: Partial) => { if (!currentPageId) return; - setLayoutConfig((prev) => ({ - pages: prev.pages.map((page) => - page.page_id === currentPageId - ? { - ...page, - components: page.components.map((comp) => (comp.id === id ? { ...comp, ...updates } : comp)), + setLayoutConfig((prev) => { + let hasCollision = false; + + const newPages = prev.pages.map((page) => { + if (page.page_id !== currentPageId) return page; + + const newComponents = page.components.map((comp) => { + if (comp.id !== id) return comp; + + // 업데이트된 컴포넌트에 그리드 스냅 적용 + const updated = { ...comp, ...updates }; + + // 위치나 크기가 변경된 경우에만 스냅 적용 및 충돌 감지 + if ( + updates.x !== undefined || + updates.y !== undefined || + updates.width !== undefined || + updates.height !== undefined + ) { + const snapped = snapComponentToGrid(updated, gridConfig); + + // 충돌 감지 (자신을 제외한 다른 컴포넌트와) + const otherComponents = page.components.filter((c) => c.id !== id); + if (detectGridCollision(snapped, otherComponents, gridConfig)) { + hasCollision = true; + return comp; // 충돌 시 원래 상태 유지 } - : page, - ), - })); + + return snapped; + } + + return updated; + }); + + return { + ...page, + components: newComponents, + }; + }); + + // 충돌이 감지된 경우 토스트 메시지 표시 및 업데이트 취소 + if (hasCollision) { + toast({ + title: "경고", + description: "다른 컴포넌트와 겹칩니다.", + variant: "destructive", + }); + return prev; + } + + return { pages: newPages }; + }); }, - [currentPageId], + [currentPageId, gridConfig, toast], ); // 컴포넌트 삭제 (현재 페이지에서) @@ -1541,6 +1654,9 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin // 그룹화 groupComponents, ungroupComponents, + // 그리드 관리 + gridConfig, + updateGridConfig, }; return {children}; diff --git a/frontend/lib/utils/gridUtils.ts b/frontend/lib/utils/gridUtils.ts index b23c0ec0..7c0ecace 100644 --- a/frontend/lib/utils/gridUtils.ts +++ b/frontend/lib/utils/gridUtils.ts @@ -1,389 +1,155 @@ -import { Position, Size } from "@/types/screen"; -import { GridSettings } from "@/types/screen-management"; +import type { ComponentConfig, GridConfig } from "@/types/report"; -export interface GridInfo { - columnWidth: number; - totalWidth: number; - totalHeight: number; +/** + * 픽셀 좌표를 그리드 좌표로 변환 + */ +export function pixelToGrid(pixel: number, cellSize: number): number { + return Math.round(pixel / cellSize); } /** - * 격자 정보 계산 + * 그리드 좌표를 픽셀 좌표로 변환 */ -export function calculateGridInfo( - containerWidth: number, - containerHeight: number, - gridSettings: GridSettings, -): GridInfo { - const { columns, gap, padding } = gridSettings; +export function gridToPixel(grid: number, cellSize: number): number { + return grid * cellSize; +} - // 사용 가능한 너비 계산 (패딩 제외) - const availableWidth = containerWidth - padding * 2; +/** + * 컴포넌트 위치/크기를 그리드에 스냅 + */ +export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig { + if (!gridConfig.snapToGrid) { + return component; + } - // 격자 간격을 고려한 컬럼 너비 계산 - const totalGaps = (columns - 1) * gap; - const columnWidth = (availableWidth - totalGaps) / columns; + // 픽셀 좌표를 그리드 좌표로 변환 + const gridX = pixelToGrid(component.x, gridConfig.cellWidth); + const gridY = pixelToGrid(component.y, gridConfig.cellHeight); + const gridWidth = Math.max(1, pixelToGrid(component.width, gridConfig.cellWidth)); + const gridHeight = Math.max(1, pixelToGrid(component.height, gridConfig.cellHeight)); + // 그리드 좌표를 다시 픽셀로 변환 return { - columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시 - totalWidth: containerWidth, - totalHeight: containerHeight, + ...component, + gridX, + gridY, + gridWidth, + gridHeight, + x: gridToPixel(gridX, gridConfig.cellWidth), + y: gridToPixel(gridY, gridConfig.cellHeight), + width: gridToPixel(gridWidth, gridConfig.cellWidth), + height: gridToPixel(gridHeight, gridConfig.cellHeight), }; } /** - * 위치를 격자에 맞춤 + * 그리드 충돌 감지 + * 두 컴포넌트가 겹치는지 확인 */ -export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position { - if (!gridSettings.snapToGrid) { - return position; - } - - const { columnWidth } = gridInfo; - const { gap, padding } = gridSettings; - - // 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산) - const cellWidth = columnWidth + gap; - const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정 - - // 패딩을 제외한 상대 위치 - const relativeX = position.x - padding; - const relativeY = position.y - padding; - - // 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅) - const gridX = Math.round(relativeX / cellWidth); - const gridY = Math.round(relativeY / cellHeight); - - // 실제 픽셀 위치로 변환 - const snappedX = Math.max(padding, padding + gridX * cellWidth); - const snappedY = Math.max(padding, padding + gridY * cellHeight); - - return { - x: snappedX, - y: snappedY, - z: position.z, - }; -} - -/** - * 크기를 격자에 맞춤 - */ -export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size { - if (!gridSettings.snapToGrid) { - return size; - } - - const { columnWidth } = gridInfo; - const { gap } = gridSettings; - - // 격자 단위로 너비 계산 - // 컴포넌트가 차지하는 컬럼 수를 올바르게 계산 - let gridColumns = 1; - - // 현재 너비에서 가장 가까운 격자 컬럼 수 찾기 - for (let cols = 1; cols <= gridSettings.columns; cols++) { - const targetWidth = cols * columnWidth + (cols - 1) * gap; - if (size.width <= targetWidth + (columnWidth + gap) / 2) { - gridColumns = cols; - break; - } - gridColumns = cols; - } - - const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; - - // 높이는 동적 행 높이 단위로 스냅 - const rowHeight = Math.max(20, gap); - const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight); - - console.log( - `📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`, - ); - - return { - width: Math.max(columnWidth, snappedWidth), - height: snappedHeight, - }; -} - -/** - * 격자 컬럼 수로 너비 계산 - */ -export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number { - const { columnWidth } = gridInfo; - const { gap } = gridSettings; - - return columns * columnWidth + (columns - 1) * gap; -} - -/** - * gridColumns 속성을 기반으로 컴포넌트 크기 업데이트 - */ -export function updateSizeFromGridColumns( - component: { gridColumns?: number; size: Size }, - gridInfo: GridInfo, - gridSettings: GridSettings, -): Size { - if (!component.gridColumns || component.gridColumns < 1) { - return component.size; - } - - const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings); - - return { - width: newWidth, - height: component.size.height, // 높이는 유지 - }; -} - -/** - * 컴포넌트의 gridColumns를 자동으로 크기에 맞게 조정 - */ -export function adjustGridColumnsFromSize( - component: { size: Size }, - gridInfo: GridInfo, - gridSettings: GridSettings, -): number { - const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings); - return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한 -} - -/** - * 너비에서 격자 컬럼 수 계산 - */ -export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number { - const { columnWidth } = gridInfo; - const { gap } = gridSettings; - - return Math.max(1, Math.round((width + gap) / (columnWidth + gap))); -} - -/** - * 격자 가이드라인 생성 - */ -export function generateGridLines( - containerWidth: number, - containerHeight: number, - gridSettings: GridSettings, -): { - verticalLines: number[]; - horizontalLines: number[]; -} { - const { columns, gap, padding } = gridSettings; - const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings); - const { columnWidth } = gridInfo; - - // 격자 셀 크기 (스냅 로직과 동일하게) - const cellWidth = columnWidth + gap; - const cellHeight = Math.max(40, gap * 2); - - // 세로 격자선 - const verticalLines: number[] = []; - for (let i = 0; i <= columns; i++) { - const x = padding + i * cellWidth; - if (x <= containerWidth) { - verticalLines.push(x); - } - } - - // 가로 격자선 - const horizontalLines: number[] = []; - for (let y = padding; y < containerHeight; y += cellHeight) { - horizontalLines.push(y); - } - - return { - verticalLines, - horizontalLines, - }; -} - -/** - * 컴포넌트가 격자 경계에 있는지 확인 - */ -export function isOnGridBoundary( - position: Position, - size: Size, - gridInfo: GridInfo, - gridSettings: GridSettings, - tolerance: number = 5, +export function detectGridCollision( + component: ComponentConfig, + otherComponents: ComponentConfig[], + gridConfig: GridConfig, ): boolean { - const snappedPos = snapToGrid(position, gridInfo, gridSettings); - const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings); + const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth); + const comp1GridY = component.gridY ?? pixelToGrid(component.y, gridConfig.cellHeight); + const comp1GridWidth = component.gridWidth ?? pixelToGrid(component.width, gridConfig.cellWidth); + const comp1GridHeight = component.gridHeight ?? pixelToGrid(component.height, gridConfig.cellHeight); - const positionMatch = - Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance; + for (const other of otherComponents) { + if (other.id === component.id) continue; - const sizeMatch = - Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance; + const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth); + const comp2GridY = other.gridY ?? pixelToGrid(other.y, gridConfig.cellHeight); + const comp2GridWidth = other.gridWidth ?? pixelToGrid(other.width, gridConfig.cellWidth); + const comp2GridHeight = other.gridHeight ?? pixelToGrid(other.height, gridConfig.cellHeight); - return positionMatch && sizeMatch; -} + // AABB (Axis-Aligned Bounding Box) 충돌 감지 + const xOverlap = comp1GridX < comp2GridX + comp2GridWidth && comp1GridX + comp1GridWidth > comp2GridX; + const yOverlap = comp1GridY < comp2GridY + comp2GridHeight && comp1GridY + comp1GridHeight > comp2GridY; -/** - * 그룹 내부 컴포넌트들을 격자에 맞게 정렬 - */ -export function alignGroupChildrenToGrid( - children: any[], - groupPosition: Position, - gridInfo: GridInfo, - gridSettings: GridSettings, -): any[] { - if (!gridSettings.snapToGrid || children.length === 0) return children; - - console.log("🔧 alignGroupChildrenToGrid 시작:", { - childrenCount: children.length, - groupPosition, - gridInfo, - gridSettings, - }); - - return children.map((child, index) => { - console.log(`📐 자식 ${index + 1} 처리 중:`, { - childId: child.id, - originalPosition: child.position, - originalSize: child.size, - }); - - const { columnWidth } = gridInfo; - const { gap } = gridSettings; - - // 그룹 내부 패딩 고려한 격자 정렬 - const padding = 16; - const effectiveX = child.position.x - padding; - const columnIndex = Math.round(effectiveX / (columnWidth + gap)); - const snappedX = padding + columnIndex * (columnWidth + gap); - - // Y 좌표는 동적 행 높이 단위로 스냅 - const rowHeight = Math.max(20, gap); - const effectiveY = child.position.y - padding; - const rowIndex = Math.round(effectiveY / rowHeight); - const snappedY = padding + rowIndex * rowHeight; - - // 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용) - const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기 - const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth)); - const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기 - const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight); - - const snappedChild = { - ...child, - position: { - x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 - y: Math.max(padding, snappedY), - z: child.position.z || 1, - }, - size: { - width: snappedWidth, - height: snappedHeight, - }, - }; - - console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, { - childId: child.id, - calculation: { - effectiveX, - effectiveY, - columnIndex, - rowIndex, - widthInColumns, - originalX: child.position.x, - snappedX: snappedChild.position.x, - padding, - }, - snappedPosition: snappedChild.position, - snappedSize: snappedChild.size, - deltaX: snappedChild.position.x - child.position.x, - deltaY: snappedChild.position.y - child.position.y, - }); - - return snappedChild; - }); -} - -/** - * 그룹 생성 시 최적화된 그룹 크기 계산 - */ -export function calculateOptimalGroupSize( - children: Array<{ position: Position; size: Size }>, - gridInfo: GridInfo, - gridSettings: GridSettings, -): Size { - if (children.length === 0) { - return { width: gridInfo.columnWidth * 2, height: 40 * 2 }; + if (xOverlap && yOverlap) { + return true; + } } - console.log("📏 calculateOptimalGroupSize 시작:", { - childrenCount: children.length, - children: children.map((c) => ({ pos: c.position, size: c.size })), - }); - - // 모든 자식 컴포넌트를 포함하는 최소 경계 계산 - const bounds = children.reduce( - (acc, child) => ({ - minX: Math.min(acc.minX, child.position.x), - minY: Math.min(acc.minY, child.position.y), - maxX: Math.max(acc.maxX, child.position.x + child.size.width), - maxY: Math.max(acc.maxY, child.position.y + child.size.height), - }), - { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, - ); - - console.log("📐 경계 계산:", bounds); - - const contentWidth = bounds.maxX - bounds.minX; - const contentHeight = bounds.maxY - bounds.minY; - - // 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기 - const padding = 16; // 그룹 내부 여백 - const groupSize = { - width: contentWidth + padding * 2, - height: contentHeight + padding * 2, - }; - - console.log("✅ 자연스러운 그룹 크기:", { - contentSize: { width: contentWidth, height: contentHeight }, - withPadding: groupSize, - strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤", - }); - - return groupSize; + return false; } /** - * 그룹 내 상대 좌표를 격자 기준으로 정규화 + * 페이지 크기 기반 그리드 행/열 계산 */ -export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] { - if (!gridSettings.snapToGrid || children.length === 0) return children; - - console.log("🔄 normalizeGroupChildPositions 시작:", { - childrenCount: children.length, - originalPositions: children.map((c) => ({ id: c.id, pos: c.position })), - }); - - // 모든 자식의 최소 위치 찾기 - const minX = Math.min(...children.map((child) => child.position.x)); - const minY = Math.min(...children.map((child) => child.position.y)); - - console.log("📍 최소 위치:", { minX, minY }); - - // 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백) - const padding = 16; - const startX = padding; - const startY = padding; - - const normalizedChildren = children.map((child) => ({ - ...child, - position: { - x: child.position.x - minX + startX, - y: child.position.y - minY + startY, - z: child.position.z || 1, - }, - })); - - console.log("✅ 정규화 완료:", { - normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })), - }); - - return normalizedChildren; +export function calculateGridDimensions( + pageWidth: number, + pageHeight: number, + cellWidth: number, + cellHeight: number, +): { rows: number; columns: number } { + return { + columns: Math.floor(pageWidth / cellWidth), + rows: Math.floor(pageHeight / cellHeight), + }; +} + +/** + * 기본 그리드 설정 생성 + */ +export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig { + const cellWidth = 20; + const cellHeight = 20; + const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight); + + return { + cellWidth, + cellHeight, + rows, + columns, + visible: true, + snapToGrid: true, + gridColor: "#e5e7eb", + gridOpacity: 0.5, + }; +} + +/** + * 위치가 페이지 경계 내에 있는지 확인 + */ +export function isWithinPageBounds( + component: ComponentConfig, + pageWidth: number, + pageHeight: number, + margins: { top: number; bottom: number; left: number; right: number }, +): boolean { + const minX = margins.left; + const minY = margins.top; + const maxX = pageWidth - margins.right; + const maxY = pageHeight - margins.bottom; + + return ( + component.x >= minX && + component.y >= minY && + component.x + component.width <= maxX && + component.y + component.height <= maxY + ); +} + +/** + * 컴포넌트를 페이지 경계 내로 제한 + */ +export function constrainToPageBounds( + component: ComponentConfig, + pageWidth: number, + pageHeight: number, + margins: { top: number; bottom: number; left: number; right: number }, +): ComponentConfig { + const minX = margins.left; + const minY = margins.top; + const maxX = pageWidth - margins.right - component.width; + const maxY = pageHeight - margins.bottom - component.height; + + return { + ...component, + x: Math.max(minX, Math.min(maxX, component.x)), + y: Math.max(minY, Math.min(maxY, component.y)), + }; } diff --git a/frontend/types/report.ts b/frontend/types/report.ts index 2c720d77..127a3a4c 100644 --- a/frontend/types/report.ts +++ b/frontend/types/report.ts @@ -81,6 +81,18 @@ export interface ExternalConnection { is_active: string; } +// 그리드 설정 +export interface GridConfig { + cellWidth: number; // 그리드 셀 너비 (px) + cellHeight: number; // 그리드 셀 높이 (px) + rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight) + columns: number; // 가로 그리드 수 (계산값: pageWidth / cellHeight) + visible: boolean; // 그리드 표시 여부 + snapToGrid: boolean; // 그리드 스냅 활성화 여부 + gridColor: string; // 그리드 선 색상 + gridOpacity: number; // 그리드 투명도 (0-1) +} + // 페이지 설정 export interface ReportPage { page_id: string; @@ -96,6 +108,7 @@ export interface ReportPage { right: number; }; background_color: string; + gridConfig?: GridConfig; // 그리드 설정 (옵셔널) components: ComponentConfig[]; } @@ -113,6 +126,11 @@ export interface ComponentConfig { width: number; height: number; zIndex: number; + // 그리드 좌표 (옵셔널) + gridX?: number; // 시작 열 (0부터 시작) + gridY?: number; // 시작 행 (0부터 시작) + gridWidth?: number; // 차지하는 열 수 + gridHeight?: number; // 차지하는 행 수 fontSize?: number; fontFamily?: string; fontWeight?: string;