feat: V6 정사각형 블록 그리드 시스템 실험 구현

고정 칸 수(4/6/8/12) 기반의 V5 그리드를 24px 정사각형 블록 기반의
동적 칸 수 시스템으로 교체한다. 뷰포트 너비에 따라 블록 수가 자동
계산되며(375px=13칸, 1024px=38칸), 작은 화면에서는 행 그룹 리플로우
(CSS Flexbox wrap 원리)로 자동 재배치된다.
[그리드 코어]
- pop-layout.ts: BLOCK_SIZE=24, BLOCK_GAP=2, BLOCK_PADDING=8,
  getBlockColumns() 동적 칸 수 계산, GRID_BREAKPOINTS V6 값
- gridUtils.ts: 행 그룹 리플로우(방식 F) - 같은 행 묶음 처리,
  최소 2x2칸 터치 보장, 메인 컨텐츠 전체 너비 확장
- PopRenderer.tsx: repeat(N, 1fr) 블록 렌더링, 동적 칸 수
- PopCanvas.tsx: 뷰포트 프리셋 동적 칸 수, 블록 좌표 변환
[V5→V6 런타임 변환]
- convertV5LayoutToV6: DB 미수정, 로드 시 메모리 변환
  12칸 좌표 → 38칸 블록 변환, V5 overrides 제거
- PopDesigner/page.tsx: 로드 지점에 변환 함수 삽입
[충돌 해결]
- ComponentEditorPanel: 높이 표시/모드 라벨 V6 수치
- PopCardListConfig: 카드 추천 threshold V6 기준
- PopDesigner: handleHideComponent 기본 모드 제한 해제
[기본 사이즈]
- 소형(2x2): 아이콘, 프로필, 스캐너
- 중형(8x4): 검색, 버튼, 텍스트
- 대형(19x6~10): 카드, 대시보드, 필드
DB 변경 0건, 백엔드 변경 0건, 컴포넌트 코드 변경 0건.
This commit is contained in:
SeongHyun Kim 2026-03-13 16:03:24 +09:00
parent 29b9cbdc90
commit 842ac27d60
8 changed files with 375 additions and 292 deletions

View File

@ -23,8 +23,11 @@ import {
createEmptyPopLayoutV5, createEmptyPopLayoutV5,
GAP_PRESETS, GAP_PRESETS,
GRID_BREAKPOINTS, GRID_BREAKPOINTS,
BLOCK_GAP,
BLOCK_PADDING,
detectGridMode, detectGridMode,
} from "@/components/pop/designer/types/pop-layout"; } from "@/components/pop/designer/types/pop-layout";
import { convertV5LayoutToV6 } from "@/components/pop/designer/utils/gridUtils";
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
import "@/lib/registry/pop-components"; import "@/lib/registry/pop-components";
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
@ -117,8 +120,8 @@ function PopScreenViewPage() {
const popLayout = await screenApi.getLayoutPop(screenId); const popLayout = await screenApi.getLayoutPop(screenId);
if (popLayout && isV5Layout(popLayout)) { if (popLayout && isV5Layout(popLayout)) {
// v5 레이아웃 로드 const v6Layout = convertV5LayoutToV6(popLayout);
setLayout(popLayout); setLayout(v6Layout);
const componentCount = Object.keys(popLayout.components).length; const componentCount = Object.keys(popLayout.components).length;
console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`); console.log(`[POP] v5 레이아웃 로드됨: ${componentCount}개 컴포넌트`);
} else if (popLayout) { } else if (popLayout) {
@ -318,12 +321,8 @@ function PopScreenViewPage() {
style={{ maxWidth: 1366 }} style={{ maxWidth: 1366 }}
> >
{(() => { {(() => {
// Gap 프리셋 계산 const adjustedGap = BLOCK_GAP;
const currentGapPreset = layout.settings.gapPreset || "medium"; const adjustedPadding = BLOCK_PADDING;
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0;
const breakpoint = GRID_BREAKPOINTS[currentModeKey];
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier);
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
return ( return (
<PopViewerWithModals <PopViewerWithModals

View File

@ -17,6 +17,10 @@ import {
ModalSizePreset, ModalSizePreset,
MODAL_SIZE_PRESETS, MODAL_SIZE_PRESETS,
resolveModalWidth, resolveModalWidth,
BLOCK_SIZE,
BLOCK_GAP,
BLOCK_PADDING,
getBlockColumns,
} from "./types/pop-layout"; } from "./types/pop-layout";
import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react";
import { useDrag } from "react-dnd"; import { useDrag } from "react-dnd";
@ -34,9 +38,8 @@ import { findNextEmptyPosition, isOverlapping, getAllEffectivePositions, needsRe
import { DND_ITEM_TYPES } from "./constants"; import { DND_ITEM_TYPES } from "./constants";
/** /**
* * V6: 캔버스
* @param relX X ( ) * (BLOCK_SIZE) 1fr
* @param relY Y ( )
*/ */
function calcGridPosition( function calcGridPosition(
relX: number, relX: number,
@ -47,21 +50,13 @@ function calcGridPosition(
gap: number, gap: number,
padding: number padding: number
): { col: number; row: number } { ): { col: number; row: number } {
// 패딩 제외한 좌표
const x = relX - padding; const x = relX - padding;
const y = relY - padding; const y = relY - padding;
// 사용 가능한 너비 (패딩과 gap 제외) const cellStride = BLOCK_SIZE + gap;
const availableWidth = canvasWidth - padding * 2 - gap * (columns - 1);
const colWidth = availableWidth / columns;
// 셀+gap 단위로 계산
const cellStride = colWidth + gap;
const rowStride = rowHeight + gap;
// 그리드 좌표 (1부터 시작)
const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1)); const col = Math.max(1, Math.min(columns, Math.floor(x / cellStride) + 1));
const row = Math.max(1, Math.floor(y / rowStride) + 1); const row = Math.max(1, Math.floor(y / cellStride) + 1);
return { col, row }; return { col, row };
} }
@ -78,13 +73,13 @@ interface DragItemMoveComponent {
} }
// ======================================== // ========================================
// 프리셋 해상도 (4개 모드) - 너비만 정의 // V6: 프리셋 해상도 (블록 칸 수 동적 계산)
// ======================================== // ========================================
const VIEWPORT_PRESETS = [ const VIEWPORT_PRESETS = [
{ id: "mobile_portrait", label: "모바일 세로", shortLabel: "모바일↕ (4칸)", width: 375, icon: Smartphone }, { id: "mobile_portrait", label: "모바일 세로", shortLabel: `모바일↕ (${getBlockColumns(375)}칸)`, width: 375, icon: Smartphone },
{ id: "mobile_landscape", label: "모바일 가로", shortLabel: "모바일↔ (6칸)", width: 600, icon: Smartphone }, { id: "mobile_landscape", label: "모바일 가로", shortLabel: `모바일↔ (${getBlockColumns(600)}칸)`, width: 600, icon: Smartphone },
{ id: "tablet_portrait", label: "태블릿 세로", shortLabel: "태블릿↕ (8칸)", width: 820, icon: Tablet }, { id: "tablet_portrait", label: "태블릿 세로", shortLabel: `태블릿↕ (${getBlockColumns(820)}칸)`, width: 820, icon: Tablet },
{ id: "tablet_landscape", label: "태블릿 가로", shortLabel: "태블릿↔ (12칸)", width: 1024, icon: Tablet }, { id: "tablet_landscape", label: "태블릿 가로", shortLabel: `태블릿↔ (${getBlockColumns(1024)}칸)`, width: 1024, icon: Tablet },
] as const; ] as const;
type ViewportPreset = GridMode; type ViewportPreset = GridMode;
@ -202,15 +197,22 @@ export default function PopCanvas({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
// 현재 뷰포트 해상도 // V6: 뷰포트에서 동적 블록 칸 수 계산
const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!; const currentPreset = VIEWPORT_PRESETS.find((p) => p.id === currentMode)!;
const breakpoint = GRID_BREAKPOINTS[currentMode]; const dynamicColumns = getBlockColumns(customWidth);
const breakpoint = {
...GRID_BREAKPOINTS[currentMode],
columns: dynamicColumns,
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
label: `${dynamicColumns}칸 블록`,
};
// Gap 프리셋 적용 // V6: 블록 간격 고정 (프리셋 무관)
const currentGapPreset = layout.settings.gapPreset || "medium"; const currentGapPreset = layout.settings.gapPreset || "medium";
const gapMultiplier = GAP_PRESETS[currentGapPreset]?.multiplier || 1.0; const adjustedGap = BLOCK_GAP;
const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); const adjustedPadding = BLOCK_PADDING;
const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier));
// 숨김 컴포넌트 ID 목록 (activeLayout 기반) // 숨김 컴포넌트 ID 목록 (activeLayout 기반)
const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || [];
@ -805,7 +807,7 @@ export default function PopCanvas({
{/* 하단 정보 */} {/* 하단 정보 */}
<div className="flex items-center justify-between border-t bg-background px-4 py-2"> <div className="flex items-center justify-between border-t bg-background px-4 py-2">
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
{breakpoint.label} - {breakpoint.columns} ( : {breakpoint.rowHeight}px) V6 - {dynamicColumns} (: {BLOCK_SIZE}px, : {BLOCK_GAP}px)
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Space + 드래그: 패닝 | Ctrl + : Space + 드래그: 패닝 | Ctrl + :

View File

@ -33,7 +33,7 @@ import {
PopModalDefinition, PopModalDefinition,
PopDataConnection, PopDataConnection,
} from "./types/pop-layout"; } from "./types/pop-layout";
import { getAllEffectivePositions } from "./utils/gridUtils"; import { getAllEffectivePositions, convertV5LayoutToV6 } from "./utils/gridUtils";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { PopDesignerContext } from "./PopDesignerContext"; import { PopDesignerContext } from "./PopDesignerContext";
@ -151,13 +151,12 @@ export default function PopDesigner({
const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId); const loadedLayout = await screenApi.getLayoutPop(selectedScreen.screenId);
if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) { if (loadedLayout && isV5Layout(loadedLayout) && loadedLayout.components && Object.keys(loadedLayout.components).length > 0) {
// v5 레이아웃 로드
// 기존 레이아웃 호환성: gapPreset이 없으면 기본값 추가
if (!loadedLayout.settings.gapPreset) { if (!loadedLayout.settings.gapPreset) {
loadedLayout.settings.gapPreset = "medium"; loadedLayout.settings.gapPreset = "medium";
} }
setLayout(loadedLayout); const v6Layout = convertV5LayoutToV6(loadedLayout);
setHistory([loadedLayout]); setLayout(v6Layout);
setHistory([v6Layout]);
setHistoryIndex(0); setHistoryIndex(0);
// 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지) // 기존 컴포넌트 ID에서 최대 숫자 추출하여 idCounter 설정 (중복 방지)
@ -605,9 +604,6 @@ export default function PopDesigner({
// ======================================== // ========================================
const handleHideComponent = useCallback((componentId: string) => { const handleHideComponent = useCallback((componentId: string) => {
// 12칸 모드에서는 숨기기 불가
if (currentMode === "tablet_landscape") return;
const currentHidden = layout.overrides?.[currentMode]?.hidden || []; const currentHidden = layout.overrides?.[currentMode]?.hidden || [];
// 이미 숨겨져 있으면 무시 // 이미 숨겨져 있으면 무시

View File

@ -7,6 +7,8 @@ import {
PopGridPosition, PopGridPosition,
GridMode, GridMode,
GRID_BREAKPOINTS, GRID_BREAKPOINTS,
BLOCK_SIZE,
getBlockColumns,
} from "../types/pop-layout"; } from "../types/pop-layout";
import { import {
Settings, Settings,
@ -378,7 +380,7 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
</span> </span>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
: {position.rowSpan * GRID_BREAKPOINTS[currentMode].rowHeight}px : {position.rowSpan * BLOCK_SIZE + (position.rowSpan - 1) * 2}px
</p> </p>
</div> </div>
@ -470,10 +472,10 @@ interface VisibilityFormProps {
function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { function VisibilityForm({ component, onUpdate }: VisibilityFormProps) {
const modes: Array<{ key: GridMode; label: string }> = [ const modes: Array<{ key: GridMode; label: string }> = [
{ key: "tablet_landscape", label: "태블릿 가로 (12칸)" }, { key: "tablet_landscape", label: `태블릿 가로 (${getBlockColumns(1024)}칸)` },
{ key: "tablet_portrait", label: "태블릿 세로 (8칸)" }, { key: "tablet_portrait", label: `태블릿 세로 (${getBlockColumns(820)}칸)` },
{ key: "mobile_landscape", label: "모바일 가로 (6칸)" }, { key: "mobile_landscape", label: `모바일 가로 (${getBlockColumns(600)}칸)` },
{ key: "mobile_portrait", label: "모바일 세로 (4칸)" }, { key: "mobile_portrait", label: `모바일 세로 (${getBlockColumns(375)}칸)` },
]; ];
const handleVisibilityChange = (mode: GridMode, visible: boolean) => { const handleVisibilityChange = (mode: GridMode, visible: boolean) => {

View File

@ -13,6 +13,10 @@ import {
GridBreakpoint, GridBreakpoint,
detectGridMode, detectGridMode,
PopComponentType, PopComponentType,
BLOCK_SIZE,
BLOCK_GAP,
BLOCK_PADDING,
getBlockColumns,
} from "../types/pop-layout"; } from "../types/pop-layout";
import { import {
convertAndResolvePositions, convertAndResolvePositions,
@ -107,18 +111,27 @@ export default function PopRenderer({
}: PopRendererProps) { }: PopRendererProps) {
const { gridConfig, components, overrides } = layout; const { gridConfig, components, overrides } = layout;
// 현재 모드 (자동 감지 또는 지정) // V6: 뷰포트 너비에서 블록 칸 수 동적 계산
const mode = currentMode || detectGridMode(viewportWidth); const mode = currentMode || detectGridMode(viewportWidth);
const breakpoint = GRID_BREAKPOINTS[mode]; const columns = getBlockColumns(viewportWidth);
// Gap/Padding: 오버라이드 우선, 없으면 기본값 사용 // V6: 블록 간격 고정
const finalGap = overrideGap !== undefined ? overrideGap : breakpoint.gap; const finalGap = overrideGap !== undefined ? overrideGap : BLOCK_GAP;
const finalPadding = overridePadding !== undefined ? overridePadding : breakpoint.padding; const finalPadding = overridePadding !== undefined ? overridePadding : BLOCK_PADDING;
// 하위 호환: breakpoint 객체 (ResizeHandles 등에서 사용)
const breakpoint: GridBreakpoint = {
columns,
rowHeight: BLOCK_SIZE,
gap: finalGap,
padding: finalPadding,
label: `${columns}칸 블록`,
};
// 숨김 컴포넌트 ID 목록 // 숨김 컴포넌트 ID 목록
const hiddenIds = overrides?.[mode]?.hidden || []; const hiddenIds = overrides?.[mode]?.hidden || [];
// 동적 행 수 계산 (가이드 셀 + Grid 스타일 공유, 숨김 컴포넌트 제외) // 동적 행 수 계산
const dynamicRowCount = useMemo(() => { const dynamicRowCount = useMemo(() => {
const visibleComps = Object.values(components).filter( const visibleComps = Object.values(components).filter(
comp => !hiddenIds.includes(comp.id) comp => !hiddenIds.includes(comp.id)
@ -131,19 +144,17 @@ export default function PopRenderer({
return Math.max(10, maxRowEnd + 3); return Math.max(10, maxRowEnd + 3);
}, [components, overrides, mode, hiddenIds]); }, [components, overrides, mode, hiddenIds]);
// CSS Grid 스타일 // V6: CSS Grid - 열은 1fr(뷰포트 꽉 채움), 행은 고정 BLOCK_SIZE
// 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집)
// 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능)
const rowTemplate = isDesignMode const rowTemplate = isDesignMode
? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` ? `repeat(${dynamicRowCount}, ${BLOCK_SIZE}px)`
: `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`; : `repeat(${dynamicRowCount}, minmax(${BLOCK_SIZE}px, auto))`;
const autoRowHeight = isDesignMode const autoRowHeight = isDesignMode
? `${breakpoint.rowHeight}px` ? `${BLOCK_SIZE}px`
: `minmax(${breakpoint.rowHeight}px, auto)`; : `minmax(${BLOCK_SIZE}px, auto)`;
const gridStyle = useMemo((): React.CSSProperties => ({ const gridStyle = useMemo((): React.CSSProperties => ({
display: "grid", display: "grid",
gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, gridTemplateColumns: `repeat(${columns}, 1fr)`,
gridTemplateRows: rowTemplate, gridTemplateRows: rowTemplate,
gridAutoRows: autoRowHeight, gridAutoRows: autoRowHeight,
gap: `${finalGap}px`, gap: `${finalGap}px`,
@ -151,15 +162,15 @@ export default function PopRenderer({
minHeight: "100%", minHeight: "100%",
backgroundColor: "#ffffff", backgroundColor: "#ffffff",
position: "relative", position: "relative",
}), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); }), [columns, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]);
// 그리드 가이드 셀 생성 (동적 행 수) // 그리드 가이드 셀 생성
const gridCells = useMemo(() => { const gridCells = useMemo(() => {
if (!isDesignMode || !showGridGuide) return []; if (!isDesignMode || !showGridGuide) return [];
const cells = []; const cells = [];
for (let row = 1; row <= dynamicRowCount; row++) { for (let row = 1; row <= dynamicRowCount; row++) {
for (let col = 1; col <= breakpoint.columns; col++) { for (let col = 1; col <= columns; col++) {
cells.push({ cells.push({
id: `cell-${col}-${row}`, id: `cell-${col}-${row}`,
col, col,
@ -168,7 +179,7 @@ export default function PopRenderer({
} }
} }
return cells; return cells;
}, [isDesignMode, showGridGuide, breakpoint.columns, dynamicRowCount]); }, [isDesignMode, showGridGuide, columns, dynamicRowCount]);
// visibility 체크 // visibility 체크
const isVisible = (comp: PopComponentDefinitionV5): boolean => { const isVisible = (comp: PopComponentDefinitionV5): boolean => {

View File

@ -99,24 +99,39 @@ export interface PopLayoutMetadata {
} }
// ======================================== // ========================================
// v5 그리드 기반 레이아웃 // v6 정사각형 블록 그리드 시스템
// ======================================== // ========================================
// 핵심: CSS Grid로 정확한 위치 지정 // 핵심: 균일한 정사각형 블록 (24px x 24px)
// - 열/행 좌표로 배치 (col, row) // - 열/행 좌표로 배치 (col, row) - 블록 단위
// - 칸 단위 크기 (colSpan, rowSpan) // - 뷰포트 너비에 따라 칸 수 동적 계산
// - Material Design 브레이크포인트 기반 // - 단일 좌표계 (모드별 변환 불필요)
/** /**
* (4) * V6
*/
export const BLOCK_SIZE = 24; // 블록 크기 (px, 정사각형)
export const BLOCK_GAP = 2; // 블록 간격 (px)
export const BLOCK_PADDING = 8; // 캔버스 패딩 (px)
/**
*
*/
export function getBlockColumns(viewportWidth: number): number {
const available = viewportWidth - BLOCK_PADDING * 2;
return Math.max(1, Math.floor((available + BLOCK_GAP) / (BLOCK_SIZE + BLOCK_GAP)));
}
/**
* ( - V6에서는 )
*/ */
export type GridMode = export type GridMode =
| "mobile_portrait" // 4칸 | "mobile_portrait"
| "mobile_landscape" // 6칸 | "mobile_landscape"
| "tablet_portrait" // 8칸 | "tablet_portrait"
| "tablet_landscape"; // 12칸 (기본) | "tablet_landscape";
/** /**
* * ( )
*/ */
export interface GridBreakpoint { export interface GridBreakpoint {
minWidth?: number; minWidth?: number;
@ -129,50 +144,43 @@ export interface GridBreakpoint {
} }
/** /**
* * V6 ( )
* (768px, 1024px) + * columns는
*/ */
export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = { export const GRID_BREAKPOINTS: Record<GridMode, GridBreakpoint> = {
// 스마트폰 세로 (iPhone SE ~ Galaxy S25 Ultra)
mobile_portrait: { mobile_portrait: {
maxWidth: 479, maxWidth: 479,
columns: 4, columns: getBlockColumns(375),
rowHeight: 40, rowHeight: BLOCK_SIZE,
gap: 8, gap: BLOCK_GAP,
padding: 12, padding: BLOCK_PADDING,
label: "모바일 세로 (4칸)", label: `모바일 세로 (${getBlockColumns(375)}칸)`,
}, },
// 스마트폰 가로 + 소형 태블릿
mobile_landscape: { mobile_landscape: {
minWidth: 480, minWidth: 480,
maxWidth: 767, maxWidth: 767,
columns: 6, columns: getBlockColumns(600),
rowHeight: 44, rowHeight: BLOCK_SIZE,
gap: 8, gap: BLOCK_GAP,
padding: 16, padding: BLOCK_PADDING,
label: "모바일 가로 (6칸)", label: `모바일 가로 (${getBlockColumns(600)}칸)`,
}, },
// 태블릿 세로 (iPad Mini ~ iPad Pro)
tablet_portrait: { tablet_portrait: {
minWidth: 768, minWidth: 768,
maxWidth: 1023, maxWidth: 1023,
columns: 8, columns: getBlockColumns(820),
rowHeight: 48, rowHeight: BLOCK_SIZE,
gap: 12, gap: BLOCK_GAP,
padding: 16, padding: BLOCK_PADDING,
label: "태블릿 세로 (8칸)", label: `태블릿 세로 (${getBlockColumns(820)}칸)`,
}, },
// 태블릿 가로 + 데스크톱 (기본)
tablet_landscape: { tablet_landscape: {
minWidth: 1024, minWidth: 1024,
columns: 12, columns: getBlockColumns(1024),
rowHeight: 48, rowHeight: BLOCK_SIZE,
gap: 16, gap: BLOCK_GAP,
padding: 24, padding: BLOCK_PADDING,
label: "태블릿 가로 (12칸)", label: `태블릿 가로 (${getBlockColumns(1024)}칸)`,
}, },
} as const; } as const;
@ -183,7 +191,6 @@ export const DEFAULT_GRID_MODE: GridMode = "tablet_landscape";
/** /**
* *
* GRID_BREAKPOINTS와
*/ */
export function detectGridMode(viewportWidth: number): GridMode { export function detectGridMode(viewportWidth: number): GridMode {
if (viewportWidth < 480) return "mobile_portrait"; if (viewportWidth < 480) return "mobile_portrait";
@ -225,17 +232,17 @@ export interface PopLayoutDataV5 {
} }
/** /**
* * (V6: 블록 )
*/ */
export interface PopGridConfig { export interface PopGridConfig {
// 행 높이 (px) - 1행의 기본 높이 // 행 높이 = 블록 크기 (px)
rowHeight: number; // 기본 48px rowHeight: number; // V6 기본 24px (= BLOCK_SIZE)
// 간격 (px) // 간격 (px)
gap: number; // 기본 8px gap: number; // V6 기본 2px (= BLOCK_GAP)
// 패딩 (px) // 패딩 (px)
padding: number; // 기본 16px padding: number; // V6 기본 8px (= BLOCK_PADDING)
} }
/** /**
@ -274,7 +281,7 @@ export interface PopComponentDefinitionV5 {
} }
/** /**
* Gap * Gap (V6: 단일 medium만 , )
*/ */
export type GapPreset = "narrow" | "medium" | "wide"; export type GapPreset = "narrow" | "medium" | "wide";
@ -287,12 +294,12 @@ export interface GapPresetConfig {
} }
/** /**
* Gap * Gap (V6: 모두 - )
*/ */
export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = { export const GAP_PRESETS: Record<GapPreset, GapPresetConfig> = {
narrow: { multiplier: 0.5, label: "좁게" }, narrow: { multiplier: 1.0, label: "기본" },
medium: { multiplier: 1.0, label: "보통" }, medium: { multiplier: 1.0, label: "기본" },
wide: { multiplier: 1.5, label: "넓게" }, wide: { multiplier: 1.0, label: "기본" },
}; };
/** /**
@ -330,9 +337,9 @@ export interface PopModeOverrideV5 {
export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({ export const createEmptyPopLayoutV5 = (): PopLayoutDataV5 => ({
version: "pop-5.0", version: "pop-5.0",
gridConfig: { gridConfig: {
rowHeight: 48, rowHeight: BLOCK_SIZE,
gap: 8, gap: BLOCK_GAP,
padding: 16, padding: BLOCK_PADDING,
}, },
components: {}, components: {},
dataFlow: { connections: [] }, dataFlow: { connections: [] },
@ -351,22 +358,27 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
}; };
/** /**
* ( ) * ( , V6)
*
* (2x2) : . , ,
* (8x4) : , , /
* (8x6) : , ,
* (19x8~) : , ,
*/ */
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = { export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
"pop-sample": { colSpan: 2, rowSpan: 1 }, "pop-sample": { colSpan: 8, rowSpan: 6 },
"pop-text": { colSpan: 3, rowSpan: 1 }, "pop-text": { colSpan: 8, rowSpan: 4 },
"pop-icon": { colSpan: 1, rowSpan: 2 }, "pop-icon": { colSpan: 2, rowSpan: 2 },
"pop-dashboard": { colSpan: 6, rowSpan: 3 }, "pop-dashboard": { colSpan: 19, rowSpan: 10 },
"pop-card-list": { colSpan: 4, rowSpan: 3 }, "pop-card-list": { colSpan: 19, rowSpan: 10 },
"pop-card-list-v2": { colSpan: 4, rowSpan: 3 }, "pop-card-list-v2": { colSpan: 19, rowSpan: 10 },
"pop-button": { colSpan: 2, rowSpan: 1 }, "pop-button": { colSpan: 8, rowSpan: 4 },
"pop-string-list": { colSpan: 4, rowSpan: 3 }, "pop-string-list": { colSpan: 19, rowSpan: 10 },
"pop-search": { colSpan: 2, rowSpan: 1 }, "pop-search": { colSpan: 8, rowSpan: 4 },
"pop-status-bar": { colSpan: 6, rowSpan: 1 }, "pop-status-bar": { colSpan: 19, rowSpan: 4 },
"pop-field": { colSpan: 6, rowSpan: 2 }, "pop-field": { colSpan: 19, rowSpan: 6 },
"pop-scanner": { colSpan: 1, rowSpan: 1 }, "pop-scanner": { colSpan: 2, rowSpan: 2 },
"pop-profile": { colSpan: 1, rowSpan: 1 }, "pop-profile": { colSpan: 2, rowSpan: 2 },
}; };
/** /**

View File

@ -6,196 +6,148 @@ import {
GapPreset, GapPreset,
GAP_PRESETS, GAP_PRESETS,
PopLayoutDataV5, PopLayoutDataV5,
PopComponentDefinitionV5, PopComponentDefinitionV5,
BLOCK_SIZE,
BLOCK_GAP,
BLOCK_PADDING,
getBlockColumns,
} from "../types/pop-layout"; } from "../types/pop-layout";
// ======================================== // ========================================
// Gap/Padding 조정 // Gap/Padding 조정 (V6: 블록 간격 고정이므로 항상 원본 반환)
// ======================================== // ========================================
/**
* Gap breakpoint의 gap/padding
*
* @param base breakpoint
* @param preset Gap ("narrow" | "medium" | "wide")
* @returns breakpoint (gap, padding )
*/
export function getAdjustedBreakpoint( export function getAdjustedBreakpoint(
base: GridBreakpoint, base: GridBreakpoint,
preset: GapPreset preset: GapPreset
): GridBreakpoint { ): GridBreakpoint {
const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0; return { ...base };
return {
...base,
gap: Math.round(base.gap * multiplier),
padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px
};
} }
// ======================================== // ========================================
// 그리드 위치 변환 // 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요)
// ======================================== // ========================================
/** /**
* 12 * V6: 단일
* @deprecated V6에서는
*/ */
export function convertPositionToMode( export function convertPositionToMode(
position: PopGridPosition, position: PopGridPosition,
targetMode: GridMode targetMode: GridMode
): PopGridPosition { ): PopGridPosition {
const sourceColumns = 12; return position;
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,
};
} }
/** /**
* * V6 ( F)
* *
* v5.1 : * 원리: CSS Flexbox wrap과 .
* - col > targetColumns인 * 1.
* - 방지: 모든 * 2. 2x2칸 ( )
* 3. ( )
* 4. 50%
* 5. (resolveOverlaps)
*/ */
export function convertAndResolvePositions( export function convertAndResolvePositions(
components: Array<{ id: string; position: PopGridPosition }>, components: Array<{ id: string; position: PopGridPosition }>,
targetMode: GridMode targetMode: GridMode
): Array<{ id: string; position: PopGridPosition }> { ): Array<{ id: string; position: PopGridPosition }> {
// 엣지 케이스: 빈 배열 if (components.length === 0) return [];
if (components.length === 0) {
return [];
}
const targetColumns = GRID_BREAKPOINTS[targetMode].columns; const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns;
// 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존) if (targetColumns >= designColumns) {
const converted = components.map(comp => ({ return components.map(c => ({ id: c.id, position: { ...c.position } }));
id: comp.id, }
position: convertPositionToMode(comp.position, targetMode),
originalCol: comp.position.col, // 원본 col 보존
}));
// 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리 const ratio = targetColumns / designColumns;
const normalComponents = converted.filter(c => c.originalCol <= targetColumns); const MIN_COL_SPAN = 2;
const overflowComponents = converted.filter(c => c.originalCol > targetColumns); const MIN_ROW_SPAN = 2;
// 3단계: 정상 컴포넌트의 최대 row 계산 // 1. 원본 row 기준 그룹핑
const maxRow = normalComponents.length > 0 const rowGroups: Record<number, Array<{ id: string; position: PopGridPosition }>> = {};
? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1)) components.forEach(comp => {
: 0; const r = comp.position.row;
if (!rowGroups[r]) rowGroups[r] = [];
// 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치 rowGroups[r].push(comp);
let currentRow = maxRow + 1;
const wrappedComponents = overflowComponents.map(comp => {
const wrappedPosition: PopGridPosition = {
col: 1, // 왼쪽 끝부터 시작
row: currentRow,
colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한
rowSpan: comp.position.rowSpan,
};
currentRow += comp.position.rowSpan; // 다음 행으로 이동
return {
id: comp.id,
position: wrappedPosition,
};
}); });
// 5단계: 정상 + 줄바꿈 컴포넌트 병합 const placed: Array<{ id: string; position: PopGridPosition }> = [];
const adjusted = [ let outputRow = 1;
...normalComponents.map(c => ({ id: c.id, position: c.position })),
...wrappedComponents,
];
// 6단계: 겹침 해결 (아래로 밀기) // 2. 각 행 그룹을 순서대로 처리
return resolveOverlaps(adjusted, targetColumns); const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
for (const rowKey of sortedRows) {
const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col);
let currentCol = 1;
let maxRowSpanInLine = 0;
for (const comp of group) {
const pos = comp.position;
const isMainContent = pos.colSpan >= designColumns * 0.5;
let scaledSpan = isMainContent
? targetColumns
: Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio));
scaledSpan = Math.min(scaledSpan, targetColumns);
const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan);
// 현재 줄에 안 들어가면 줄바꿈
if (currentCol + scaledSpan - 1 > targetColumns) {
outputRow += Math.max(1, maxRowSpanInLine);
currentCol = 1;
maxRowSpanInLine = 0;
}
placed.push({
id: comp.id,
position: {
col: currentCol,
row: outputRow,
colSpan: scaledSpan,
rowSpan: scaledRowSpan,
},
});
maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan);
currentCol += scaledSpan;
}
outputRow += Math.max(1, maxRowSpanInLine);
}
// 3. 겹침 해결 (행 그룹 간 rowSpan 충돌 처리)
return resolveOverlaps(placed, targetColumns);
} }
// ======================================== // ========================================
// 검토 필요 판별 // 검토 필요 판별 (V6: 자동 줄바꿈이므로 검토 필요 없음)
// ======================================== // ========================================
/** /**
* "검토 필요" * V6: 단일 +
* * false
* v5.1 :
* - 12 ( )
* - ( )
*
* @param currentMode
* @param hasOverride
* @returns true = , false =
*/ */
export function needsReview( export function needsReview(
currentMode: GridMode, currentMode: GridMode,
hasOverride: boolean hasOverride: boolean
): boolean { ): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns; return false;
// 12칸 모드는 기본 모드이므로 검토 불필요
if (targetColumns === 12) {
return false;
}
// 오버라이드가 있으면 이미 편집함 → 검토 완료
if (hasOverride) {
return false;
}
// 오버라이드 없으면 → 검토 필요
return true;
} }
/** /**
* @deprecated v5.1 needsReview() * @deprecated V6에서는
*
* isOutOfBounds는 "화면 밖" ,
* v5.1 .
* needsReview() "검토 필요" .
*/ */
export function isOutOfBounds( export function isOutOfBounds(
originalPosition: PopGridPosition, originalPosition: PopGridPosition,
currentMode: GridMode, currentMode: GridMode,
overridePosition?: PopGridPosition | null overridePosition?: PopGridPosition | null
): boolean { ): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns; return false;
// 12칸 모드면 초과 불가
if (targetColumns === 12) {
return false;
}
// 오버라이드가 있으면 오버라이드 위치로 판단
if (overridePosition) {
return overridePosition.col > targetColumns;
}
// 오버라이드 없으면 원본 col로 판단
return originalPosition.col > targetColumns;
} }
// ======================================== // ========================================
@ -269,12 +221,8 @@ export function resolveOverlaps(
// ======================================== // ========================================
/** /**
* * V6: 마우스
* * (BLOCK_SIZE)
* CSS Grid :
* - = - *2 - gap*(columns-1)
* - = / columns
* - N의 X = padding + (N-1) * ( + gap)
*/ */
export function mouseToGridPosition( export function mouseToGridPosition(
mouseX: number, mouseX: number,
@ -285,28 +233,19 @@ export function mouseToGridPosition(
gap: number, gap: number,
padding: number padding: number
): { col: number; row: number } { ): { col: number; row: number } {
// 캔버스 내 상대 위치 (패딩 영역 포함)
const relX = mouseX - canvasRect.left - padding; const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding; const relY = mouseY - canvasRect.top - padding;
// CSS Grid 1fr 계산과 동일하게 const cellStride = BLOCK_SIZE + gap;
// 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap)
const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1);
const colWidth = availableWidth / columns;
// 각 셀의 실제 간격 (셀 너비 + gap)
const cellStride = colWidth + gap;
// 그리드 좌표 계산 (1부터 시작)
// relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음
const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1));
const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); const row = Math.max(1, Math.floor(relY / cellStride) + 1);
return { col, row }; return { col, row };
} }
/** /**
* * V6: 블록
*/ */
export function gridToPixelPosition( export function gridToPixelPosition(
col: number, col: number,
@ -319,14 +258,13 @@ export function gridToPixelPosition(
gap: number, gap: number,
padding: number padding: number
): { x: number; y: number; width: number; height: number } { ): { x: number; y: number; width: number; height: number } {
const totalGap = gap * (columns - 1); const cellStride = BLOCK_SIZE + gap;
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
return { return {
x: padding + (col - 1) * (colWidth + gap), x: padding + (col - 1) * cellStride,
y: padding + (row - 1) * (rowHeight + gap), y: padding + (row - 1) * cellStride,
width: colWidth * colSpan + gap * (colSpan - 1), width: BLOCK_SIZE * colSpan + gap * (colSpan - 1),
height: rowHeight * rowSpan + gap * (rowSpan - 1), height: BLOCK_SIZE * rowSpan + gap * (rowSpan - 1),
}; };
} }
@ -560,3 +498,126 @@ export function getAllEffectivePositions(
return result; return result;
} }
// ========================================
// V5 → V6 런타임 변환 (DB 미수정, 로드 시 변환)
// ========================================
const V5_BASE_COLUMNS = 12;
const V5_BASE_ROW_HEIGHT = 48;
const V5_BASE_GAP = 16;
const V5_DESIGN_WIDTH = 1024;
/**
* V5 판별: gridConfig.rowHeight가 V5 (48)
* 12 V5로
*/
function isV5GridConfig(layout: PopLayoutDataV5): boolean {
if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false;
const maxCol = Object.values(layout.components).reduce((max, comp) => {
const end = comp.position.col + comp.position.colSpan - 1;
return Math.max(max, end);
}, 0);
return maxCol <= V5_BASE_COLUMNS;
}
function convertV5PositionToV6(
pos: PopGridPosition,
v6DesignColumns: number,
): PopGridPosition {
const colRatio = v6DesignColumns / V5_BASE_COLUMNS;
const rowRatio = (V5_BASE_ROW_HEIGHT + V5_BASE_GAP) / (BLOCK_SIZE + BLOCK_GAP);
const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1);
let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio));
const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio));
if (newCol + newColSpan - 1 > v6DesignColumns) {
newColSpan = v6DesignColumns - newCol + 1;
}
return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan };
}
/**
* V5 V6
* - (tablet_landscape)
* - overrides ( )
* - DB ( )
*/
export function convertV5LayoutToV6(layout: PopLayoutDataV5): PopLayoutDataV5 {
// V5 오버라이드는 V6에서 무효 (4/6/8칸용 좌표가 13/22/31칸에 맞지 않음)
// 좌표 변환 필요 여부와 무관하게 항상 제거
if (!isV5GridConfig(layout)) {
return {
...layout,
gridConfig: {
rowHeight: BLOCK_SIZE,
gap: BLOCK_GAP,
padding: BLOCK_PADDING,
},
overrides: undefined,
};
}
const v6Columns = getBlockColumns(V5_DESIGN_WIDTH);
const rowGroups: Record<number, string[]> = {};
Object.entries(layout.components).forEach(([id, comp]) => {
const r = comp.position.row;
if (!rowGroups[r]) rowGroups[r] = [];
rowGroups[r].push(id);
});
const convertedPositions: Record<string, PopGridPosition> = {};
Object.entries(layout.components).forEach(([id, comp]) => {
convertedPositions[id] = convertV5PositionToV6(comp.position, v6Columns);
});
const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
const rowMapping: Record<number, number> = {};
let v6Row = 1;
for (const v5Row of sortedRows) {
rowMapping[v5Row] = v6Row;
const maxSpan = Math.max(
...rowGroups[v5Row].map(id => convertedPositions[id].rowSpan)
);
v6Row += maxSpan;
}
const newComponents = { ...layout.components };
Object.entries(newComponents).forEach(([id, comp]) => {
const converted = convertedPositions[id];
const mappedRow = rowMapping[comp.position.row] ?? converted.row;
newComponents[id] = {
...comp,
position: { ...converted, row: mappedRow },
};
});
const newModals = layout.modals?.map(modal => {
const modalComps = { ...modal.components };
Object.entries(modalComps).forEach(([id, comp]) => {
modalComps[id] = {
...comp,
position: convertV5PositionToV6(comp.position, v6Columns),
};
});
return {
...modal,
gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING },
components: modalComps,
overrides: undefined,
};
});
return {
...layout,
gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING },
components: newComponents,
overrides: undefined,
modals: newModals,
};
}

View File

@ -295,8 +295,8 @@ function BasicSettingsTab({
const recommendation = useMemo(() => { const recommendation = useMemo(() => {
if (!currentMode) return null; if (!currentMode) return null;
const cols = GRID_BREAKPOINTS[currentMode].columns; const cols = GRID_BREAKPOINTS[currentMode].columns;
if (cols >= 8) return { rows: 3, cols: 2 }; if (cols >= 25) return { rows: 3, cols: 2 };
if (cols >= 6) return { rows: 3, cols: 1 }; if (cols >= 18) return { rows: 3, cols: 1 };
return { rows: 2, cols: 1 }; return { rows: 2, cols: 1 };
}, [currentMode]); }, [currentMode]);