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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,196 +6,148 @@ import {
GapPreset,
GAP_PRESETS,
PopLayoutDataV5,
PopComponentDefinitionV5,
PopComponentDefinitionV5,
BLOCK_SIZE,
BLOCK_GAP,
BLOCK_PADDING,
getBlockColumns,
} 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(
base: GridBreakpoint,
preset: GapPreset
): GridBreakpoint {
const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0;
return {
...base,
gap: Math.round(base.gap * multiplier),
padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px
};
return { ...base };
}
// ========================================
// 그리드 위치 변환
// 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요)
// ========================================
/**
* 12
* V6: 단일
* @deprecated V6에서는
*/
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,
};
return position;
}
/**
*
*
* v5.1 :
* - col > targetColumns인
* - 방지: 모든
* V6 ( F)
*
* 원리: CSS Flexbox wrap과 .
* 1.
* 2. 2x2칸 ( )
* 3. ( )
* 4. 50%
* 5. (resolveOverlaps)
*/
export function convertAndResolvePositions(
components: Array<{ id: string; position: PopGridPosition }>,
targetMode: GridMode
): Array<{ id: string; position: PopGridPosition }> {
// 엣지 케이스: 빈 배열
if (components.length === 0) {
return [];
}
if (components.length === 0) return [];
const targetColumns = GRID_BREAKPOINTS[targetMode].columns;
const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns;
// 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존)
const converted = components.map(comp => ({
id: comp.id,
position: convertPositionToMode(comp.position, targetMode),
originalCol: comp.position.col, // 원본 col 보존
}));
if (targetColumns >= designColumns) {
return components.map(c => ({ id: c.id, position: { ...c.position } }));
}
// 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리
const normalComponents = converted.filter(c => c.originalCol <= targetColumns);
const overflowComponents = converted.filter(c => c.originalCol > targetColumns);
const ratio = targetColumns / designColumns;
const MIN_COL_SPAN = 2;
const MIN_ROW_SPAN = 2;
// 3단계: 정상 컴포넌트의 최대 row 계산
const maxRow = normalComponents.length > 0
? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1))
: 0;
// 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치
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,
};
// 1. 원본 row 기준 그룹핑
const rowGroups: Record<number, Array<{ id: string; position: PopGridPosition }>> = {};
components.forEach(comp => {
const r = comp.position.row;
if (!rowGroups[r]) rowGroups[r] = [];
rowGroups[r].push(comp);
});
// 5단계: 정상 + 줄바꿈 컴포넌트 병합
const adjusted = [
...normalComponents.map(c => ({ id: c.id, position: c.position })),
...wrappedComponents,
];
const placed: Array<{ id: string; position: PopGridPosition }> = [];
let outputRow = 1;
// 6단계: 겹침 해결 (아래로 밀기)
return resolveOverlaps(adjusted, targetColumns);
// 2. 각 행 그룹을 순서대로 처리
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: 자동 줄바꿈이므로 검토 필요 없음)
// ========================================
/**
* "검토 필요"
*
* v5.1 :
* - 12 ( )
* - ( )
*
* @param currentMode
* @param hasOverride
* @returns true = , false =
* V6: 단일 +
* false
*/
export function needsReview(
currentMode: GridMode,
hasOverride: boolean
): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
// 12칸 모드는 기본 모드이므로 검토 불필요
if (targetColumns === 12) {
return false;
}
// 오버라이드가 있으면 이미 편집함 → 검토 완료
if (hasOverride) {
return false;
}
// 오버라이드 없으면 → 검토 필요
return true;
return false;
}
/**
* @deprecated v5.1 needsReview()
*
* isOutOfBounds는 "화면 밖" ,
* v5.1 .
* needsReview() "검토 필요" .
* @deprecated V6에서는
*/
export function isOutOfBounds(
originalPosition: PopGridPosition,
currentMode: GridMode,
overridePosition?: PopGridPosition | null
): boolean {
const targetColumns = GRID_BREAKPOINTS[currentMode].columns;
// 12칸 모드면 초과 불가
if (targetColumns === 12) {
return false;
}
// 오버라이드가 있으면 오버라이드 위치로 판단
if (overridePosition) {
return overridePosition.col > targetColumns;
}
// 오버라이드 없으면 원본 col로 판단
return originalPosition.col > targetColumns;
return false;
}
// ========================================
@ -269,12 +221,8 @@ export function resolveOverlaps(
// ========================================
/**
*
*
* CSS Grid :
* - = - *2 - gap*(columns-1)
* - = / columns
* - N의 X = padding + (N-1) * ( + gap)
* V6: 마우스
* (BLOCK_SIZE)
*/
export function mouseToGridPosition(
mouseX: number,
@ -285,28 +233,19 @@ export function mouseToGridPosition(
gap: number,
padding: number
): { col: number; row: number } {
// 캔버스 내 상대 위치 (패딩 영역 포함)
const relX = mouseX - canvasRect.left - padding;
const relY = mouseY - canvasRect.top - padding;
// CSS Grid 1fr 계산과 동일하게
// 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap)
const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1);
const colWidth = availableWidth / columns;
const cellStride = BLOCK_SIZE + gap;
// 각 셀의 실제 간격 (셀 너비 + gap)
const cellStride = colWidth + gap;
// 그리드 좌표 계산 (1부터 시작)
// relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음
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 };
}
/**
*
* V6: 블록
*/
export function gridToPixelPosition(
col: number,
@ -319,14 +258,13 @@ export function gridToPixelPosition(
gap: number,
padding: number
): { x: number; y: number; width: number; height: number } {
const totalGap = gap * (columns - 1);
const colWidth = (canvasWidth - padding * 2 - totalGap) / columns;
const cellStride = BLOCK_SIZE + gap;
return {
x: padding + (col - 1) * (colWidth + gap),
y: padding + (row - 1) * (rowHeight + gap),
width: colWidth * colSpan + gap * (colSpan - 1),
height: rowHeight * rowSpan + gap * (rowSpan - 1),
x: padding + (col - 1) * cellStride,
y: padding + (row - 1) * cellStride,
width: BLOCK_SIZE * colSpan + gap * (colSpan - 1),
height: BLOCK_SIZE * rowSpan + gap * (rowSpan - 1),
};
}
@ -560,3 +498,126 @@ export function getAllEffectivePositions(
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(() => {
if (!currentMode) return null;
const cols = GRID_BREAKPOINTS[currentMode].columns;
if (cols >= 8) return { rows: 3, cols: 2 };
if (cols >= 6) return { rows: 3, cols: 1 };
if (cols >= 25) return { rows: 3, cols: 2 };
if (cols >= 18) return { rows: 3, cols: 1 };
return { rows: 2, cols: 1 };
}, [currentMode]);