# POP 그리드 시스템 코딩 계획 > 작성일: 2026-02-05 > 상태: 코딩 준비 완료 --- ## 작업 목록 ``` Phase 5.1: 타입 정의 ───────────────────────────── [ ] 1. v5 타입 정의 (PopLayoutDataV5, PopGridConfig 등) [ ] 2. 브레이크포인트 상수 정의 [ ] 3. v5 생성/변환 함수 Phase 5.2: 그리드 렌더러 ───────────────────────── [ ] 4. PopGridRenderer.tsx 생성 [ ] 5. 위치 변환 로직 (12칸→4칸) Phase 5.3: 디자이너 UI ─────────────────────────── [ ] 6. PopCanvasV5.tsx 생성 [ ] 7. 드래그 스냅 기능 [ ] 8. ComponentEditorPanelV5.tsx Phase 5.4: 통합 ────────────────────────────────── [ ] 9. 자동 변환 알고리즘 [ ] 10. PopDesigner.tsx 통합 ``` --- ## Phase 5.1: 타입 정의 ### 작업 1: v5 타입 정의 **파일**: `frontend/components/pop/designer/types/pop-layout.ts` **추가할 코드**: ```typescript // ======================================== // v5.0 그리드 기반 레이아웃 // ======================================== // 핵심: CSS Grid로 정확한 위치 지정 // - 열/행 좌표로 배치 (col, row) // - 칸 단위 크기 (colSpan, rowSpan) /** * v5 레이아웃 (그리드 기반) */ export interface PopLayoutDataV5 { version: "pop-5.0"; // 그리드 설정 gridConfig: PopGridConfig; // 컴포넌트 정의 (ID → 정의) components: Record; // 데이터 흐름 (기존과 동일) dataFlow: PopDataFlow; // 전역 설정 settings: PopGlobalSettingsV5; // 메타데이터 metadata?: PopLayoutMetadata; // 모드별 오버라이드 (위치 변경용) overrides?: { mobile_portrait?: PopModeOverrideV5; mobile_landscape?: PopModeOverrideV5; tablet_portrait?: PopModeOverrideV5; }; } /** * 그리드 설정 */ export interface PopGridConfig { // 행 높이 (px) - 1행의 기본 높이 rowHeight: number; // 기본 48px // 간격 (px) gap: number; // 기본 8px // 패딩 (px) padding: number; // 기본 16px } /** * v5 컴포넌트 정의 */ export interface PopComponentDefinitionV5 { id: string; type: PopComponentType; label?: string; // 위치 (열/행 좌표) - 기본 모드(태블릿 가로 12칸) 기준 position: PopGridPosition; // 모드별 표시/숨김 visibility?: { tablet_landscape?: boolean; tablet_portrait?: boolean; mobile_landscape?: boolean; mobile_portrait?: boolean; }; // 기존 속성 dataBinding?: PopDataBinding; style?: PopStylePreset; config?: PopComponentConfig; } /** * 그리드 위치 */ export interface PopGridPosition { col: number; // 시작 열 (1부터, 최대 12) row: number; // 시작 행 (1부터) colSpan: number; // 차지할 열 수 (1~12) rowSpan: number; // 차지할 행 수 (1~) } /** * v5 전역 설정 */ export interface PopGlobalSettingsV5 { // 터치 최소 크기 (px) touchTargetMin: number; // 기본 48 // 모드 mode: "normal" | "industrial"; } /** * v5 모드별 오버라이드 */ export interface PopModeOverrideV5 { // 컴포넌트별 위치 오버라이드 positions?: Record>; // 컴포넌트별 숨김 hidden?: string[]; } ``` ### 작업 2: 브레이크포인트 상수 **파일**: `frontend/components/pop/designer/types/pop-layout.ts` ```typescript // ======================================== // 그리드 브레이크포인트 // ======================================== export type GridMode = | "mobile_portrait" | "mobile_landscape" | "tablet_portrait" | "tablet_landscape"; export const GRID_BREAKPOINTS: Record = { // 4~6인치 모바일 세로 mobile_portrait: { maxWidth: 599, columns: 4, rowHeight: 40, gap: 8, padding: 12, label: "모바일 세로 (4칸)", }, // 6~8인치 모바일 가로 / 작은 태블릿 mobile_landscape: { minWidth: 600, maxWidth: 839, columns: 6, rowHeight: 44, gap: 8, padding: 16, label: "모바일 가로 (6칸)", }, // 8~10인치 태블릿 세로 tablet_portrait: { minWidth: 840, maxWidth: 1023, columns: 8, rowHeight: 48, gap: 12, padding: 16, label: "태블릿 세로 (8칸)", }, // 10~14인치 태블릿 가로 (기본) tablet_landscape: { minWidth: 1024, columns: 12, rowHeight: 48, gap: 16, padding: 24, label: "태블릿 가로 (12칸)", }, }; // 기본 모드 export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape"; // 뷰포트 너비로 모드 감지 export function detectGridMode(viewportWidth: number): GridMode { if (viewportWidth < 600) return "mobile_portrait"; if (viewportWidth < 840) return "mobile_landscape"; if (viewportWidth < 1024) return "tablet_portrait"; return "tablet_landscape"; } ``` ### 작업 3: v5 생성/변환 함수 **파일**: `frontend/components/pop/designer/types/pop-layout.ts` ```typescript // ======================================== // v5 유틸리티 함수 // ======================================== /** * 빈 v5 레이아웃 생성 */ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ version: "pop-5.0", gridConfig: { rowHeight: 48, gap: 8, padding: 16, }, components: {}, dataFlow: { connections: [] }, settings: { touchTargetMin: 48, mode: "normal", }, }); /** * v5 레이아웃 여부 확인 */ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { return layout?.version === "pop-5.0"; }; /** * v5 컴포넌트 정의 생성 */ export const createComponentDefinitionV5 = ( id: string, type: PopComponentType, position: PopGridPosition, label?: string ): PopComponentDefinitionV5 => ({ id, type, label, position, }); /** * 컴포넌트 타입별 기본 크기 (칸 단위) */ export const DEFAULT_COMPONENT_SIZE: Record = { "pop-field": { colSpan: 3, rowSpan: 1 }, "pop-button": { colSpan: 2, rowSpan: 1 }, "pop-list": { colSpan: 12, rowSpan: 4 }, "pop-indicator": { colSpan: 3, rowSpan: 2 }, "pop-scanner": { colSpan: 6, rowSpan: 2 }, "pop-numpad": { colSpan: 4, rowSpan: 5 }, "pop-spacer": { colSpan: 1, rowSpan: 1 }, "pop-break": { colSpan: 12, rowSpan: 0 }, }; /** * v4 → v5 마이그레이션 */ export const migrateV4ToV5 = (layoutV4: PopLayoutDataV4): PopLayoutDataV5 => { const componentsV4 = Object.values(layoutV4.components); const componentsV5: Record = {}; // Flexbox 순서 → Grid 위치 변환 let currentRow = 1; let currentCol = 1; const columns = 12; componentsV4.forEach((comp) => { // 픽셀 → 칸 변환 (대략적) const colSpan = comp.size.width === "fill" ? columns : Math.max(1, Math.min(12, Math.round((comp.size.fixedWidth || 100) / 85))); const rowSpan = Math.max(1, Math.round((comp.size.fixedHeight || 48) / 48)); // 줄바꿈 체크 if (currentCol + colSpan - 1 > columns) { currentRow += 1; currentCol = 1; } componentsV5[comp.id] = { id: comp.id, type: comp.type, label: comp.label, position: { col: currentCol, row: currentRow, colSpan, rowSpan, }, visibility: comp.visibility, dataBinding: comp.dataBinding, config: comp.config, }; currentCol += colSpan; }); return { version: "pop-5.0", gridConfig: { rowHeight: 48, gap: layoutV4.settings.defaultGap, padding: layoutV4.settings.defaultPadding, }, components: componentsV5, dataFlow: layoutV4.dataFlow, settings: { touchTargetMin: layoutV4.settings.touchTargetMin, mode: layoutV4.settings.mode, }, }; }; ``` --- ## Phase 5.2: 그리드 렌더러 ### 작업 4: PopGridRenderer.tsx **파일**: `frontend/components/pop/designer/renderers/PopGridRenderer.tsx` ```typescript "use client"; import React, { useMemo } from "react"; import { cn } from "@/lib/utils"; import { PopLayoutDataV5, PopComponentDefinitionV5, PopGridPosition, GridMode, GRID_BREAKPOINTS, detectGridMode, } from "../types/pop-layout"; interface PopGridRendererProps { layout: PopLayoutDataV5; viewportWidth: number; currentMode?: GridMode; isDesignMode?: boolean; selectedComponentId?: string | null; onComponentClick?: (componentId: string) => void; onBackgroundClick?: () => void; className?: string; } export function PopGridRenderer({ layout, viewportWidth, currentMode, isDesignMode = false, selectedComponentId, onComponentClick, onBackgroundClick, className, }: PopGridRendererProps) { const { gridConfig, components, overrides } = layout; // 현재 모드 (자동 감지 또는 지정) const mode = currentMode || detectGridMode(viewportWidth); const breakpoint = GRID_BREAKPOINTS[mode]; // CSS Grid 스타일 const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, gridAutoRows: `${breakpoint.rowHeight}px`, gap: `${breakpoint.gap}px`, padding: `${breakpoint.padding}px`, minHeight: "100%", }), [breakpoint]); // visibility 체크 const isVisible = (comp: PopComponentDefinitionV5): boolean => { if (!comp.visibility) return true; return comp.visibility[mode] !== false; }; // 위치 변환 (12칸 기준 → 현재 모드 칸 수) const convertPosition = (position: PopGridPosition): React.CSSProperties => { const sourceColumns = 12; // 항상 12칸 기준으로 저장 const targetColumns = breakpoint.columns; if (sourceColumns === targetColumns) { return { gridColumn: `${position.col} / span ${position.colSpan}`, gridRow: `${position.row} / span ${position.rowSpan}`, }; } // 비율 계산 const ratio = targetColumns / sourceColumns; let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); // 범위 초과 방지 if (newCol + newColSpan - 1 > targetColumns) { newColSpan = targetColumns - newCol + 1; } return { gridColumn: `${newCol} / span ${Math.max(1, newColSpan)}`, gridRow: `${position.row} / span ${position.rowSpan}`, }; }; // 오버라이드 적용 const getEffectivePosition = (comp: PopComponentDefinitionV5): PopGridPosition => { const override = overrides?.[mode]?.positions?.[comp.id]; if (override) { return { ...comp.position, ...override }; } return comp.position; }; return (
{ if (e.target === e.currentTarget) { onBackgroundClick?.(); } }} > {Object.values(components).map((comp) => { if (!isVisible(comp)) return null; const position = getEffectivePosition(comp); const positionStyle = convertPosition(position); return (
{ e.stopPropagation(); onComponentClick?.(comp.id); }} > {/* 컴포넌트 내용 */}
); })}
); } // 컴포넌트 내용 렌더링 function ComponentContent({ component, isDesignMode }: { component: PopComponentDefinitionV5; isDesignMode: boolean; }) { const typeLabels: Record = { "pop-field": "필드", "pop-button": "버튼", "pop-list": "리스트", "pop-indicator": "인디케이터", "pop-scanner": "스캐너", "pop-numpad": "숫자패드", "pop-spacer": "스페이서", "pop-break": "줄바꿈", }; if (isDesignMode) { return (
{component.label || typeLabels[component.type] || component.type}
{typeLabels[component.type]}
); } // 실제 컴포넌트 렌더링 (Phase 4에서 구현) return (
{component.label || typeLabels[component.type]}
); } export default PopGridRenderer; ``` ### 작업 5: 위치 변환 유틸리티 **파일**: `frontend/components/pop/designer/utils/gridUtils.ts` ```typescript import { PopGridPosition, GridMode, GRID_BREAKPOINTS } from "../types/pop-layout"; /** * 12칸 기준 위치를 다른 모드로 변환 */ export function convertPositionToMode( position: PopGridPosition, targetMode: GridMode ): PopGridPosition { const sourceColumns = 12; const targetColumns = GRID_BREAKPOINTS[targetMode].columns; if (sourceColumns === targetColumns) { return position; } const ratio = targetColumns / sourceColumns; // 열 위치 변환 let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); // 범위 초과 방지 if (newCol > targetColumns) { newCol = 1; } if (newCol + newColSpan - 1 > targetColumns) { newColSpan = targetColumns - newCol + 1; } return { col: newCol, row: position.row, colSpan: Math.max(1, newColSpan), rowSpan: position.rowSpan, }; } /** * 두 위치가 겹치는지 확인 */ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { // 열 겹침 const colOverlap = !(a.col + a.colSpan - 1 < b.col || b.col + b.colSpan - 1 < a.col); // 행 겹침 const rowOverlap = !(a.row + a.rowSpan - 1 < b.row || b.row + b.rowSpan - 1 < a.row); return colOverlap && rowOverlap; } /** * 겹침 해결 (아래로 밀기) */ export function resolveOverlaps( positions: Array<{ id: string; position: PopGridPosition }>, columns: number ): Array<{ id: string; position: PopGridPosition }> { // row, col 순으로 정렬 const sorted = [...positions].sort((a, b) => a.position.row - b.position.row || a.position.col - b.position.col ); const resolved: Array<{ id: string; position: PopGridPosition }> = []; sorted.forEach((item) => { let { row, col, colSpan, rowSpan } = item.position; // 기존 배치와 겹치면 아래로 이동 let attempts = 0; while (attempts < 100) { const currentPos: PopGridPosition = { col, row, colSpan, rowSpan }; const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position)); if (!hasOverlap) break; row++; attempts++; } resolved.push({ id: item.id, position: { col, row, colSpan, rowSpan }, }); }); return resolved; } /** * 마우스 좌표 → 그리드 좌표 변환 */ export function mouseToGridPosition( mouseX: number, mouseY: number, canvasRect: DOMRect, columns: number, rowHeight: number, gap: number, padding: number ): { col: number; row: number } { // 캔버스 내 상대 위치 const relX = mouseX - canvasRect.left - padding; const relY = mouseY - canvasRect.top - padding; // 칸 너비 계산 const totalGap = gap * (columns - 1); const colWidth = (canvasRect.width - padding * 2 - totalGap) / columns; // 그리드 좌표 계산 (1부터 시작) const col = Math.max(1, Math.min(columns, Math.floor(relX / (colWidth + gap)) + 1)); const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); return { col, row }; } /** * 그리드 좌표 → 픽셀 좌표 변환 */ export function gridToPixelPosition( col: number, row: number, canvasWidth: number, columns: number, rowHeight: number, gap: number, padding: number ): { x: number; y: number; width: number; height: number } { const totalGap = gap * (columns - 1); const colWidth = (canvasWidth - padding * 2 - totalGap) / columns; return { x: padding + (col - 1) * (colWidth + gap), y: padding + (row - 1) * (rowHeight + gap), width: colWidth, height: rowHeight, }; } ``` --- ## Phase 5.3: 디자이너 UI ### 작업 6-7: PopCanvasV5.tsx **파일**: `frontend/components/pop/designer/PopCanvasV5.tsx` 핵심 기능: - 그리드 배경 표시 (바둑판) - 4개 모드 프리셋 버튼 - 드래그 앤 드롭 (칸에 스냅) - 컴포넌트 리사이즈 (칸 단위) ### 작업 8: ComponentEditorPanelV5.tsx **파일**: `frontend/components/pop/designer/panels/ComponentEditorPanelV5.tsx` 핵심 기능: - 위치 편집 (col, row 입력) - 크기 편집 (colSpan, rowSpan 입력) - visibility 체크박스 --- ## Phase 5.4: 통합 ### 작업 9: 자동 변환 알고리즘 이미 `gridUtils.ts`에 포함 ### 작업 10: PopDesigner.tsx 통합 **수정 파일**: `frontend/components/pop/designer/PopDesigner.tsx` 변경 사항: - v5 레이아웃 상태 추가 - v3/v4/v5 자동 판별 - 새 화면 → v5로 시작 - v4 → v5 마이그레이션 옵션 --- ## 파일 목록 | 상태 | 파일 | 작업 | |------|------|------| | 수정 | `types/pop-layout.ts` | v5 타입, 상수, 함수 추가 | | 생성 | `renderers/PopGridRenderer.tsx` | 그리드 렌더러 | | 생성 | `utils/gridUtils.ts` | 유틸리티 함수 | | 생성 | `PopCanvasV5.tsx` | 그리드 캔버스 | | 생성 | `panels/ComponentEditorPanelV5.tsx` | 속성 패널 | | 수정 | `PopDesigner.tsx` | v5 통합 | --- ## 시작 순서 ``` 1. pop-layout.ts에 v5 타입 추가 (작업 1-3) ↓ 2. PopGridRenderer.tsx 생성 (작업 4) ↓ 3. gridUtils.ts 생성 (작업 5) ↓ 4. PopCanvasV5.tsx 생성 (작업 6-7) ↓ 5. ComponentEditorPanelV5.tsx 생성 (작업 8) ↓ 6. PopDesigner.tsx 수정 (작업 9-10) ↓ 7. 테스트 ``` --- *다음 단계: Phase 5.1 작업 1 시작 (v5 타입 정의)*