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:
parent
29b9cbdc90
commit
842ac27d60
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 + 휠: 줌
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
||||
// 이미 숨겨져 있으면 무시
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue