자유배치 -> 라인선 레이아웃자유형 반응구현
Build & Deploy to K8s / build-and-deploy (push) Successful in 3m58s

This commit is contained in:
DDD1542
2026-04-20 16:51:33 +09:00
parent aab894b553
commit 5fc1e7ede2
5 changed files with 2330 additions and 7 deletions
+487 -4
View File
@@ -1,16 +1,29 @@
'use client';
import { useMemo } from 'react';
import {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import type { CSSProperties } from 'react';
import type {
BlockRole,
BlockV2,
CanvasV2,
FieldConfig,
ResponsivePolicy,
Template,
ViewV2,
} from '@/types/invyone-component';
import { ComponentRegistry } from '@/lib/registry/ComponentRegistry';
import {
allocateRowHeightsPx,
classifyRows,
computeLayout,
toTrackTemplate,
} from '@/lib/layout/lineLayout';
import { ensureV2Views } from '@/lib/utils/templateMigrate';
// side-effect: 컴포넌트 레지스트리 등록
import '@/lib/registry/components';
@@ -78,6 +91,45 @@ interface TemplateRendererProps {
view?: ViewKey;
}
// ─────────────────────────────────────────────────────────────────────────────
// Feature flag — line 엔진 활성화 (Step 2, 2026-04-20)
// 우선순위:
// 1. URL query ?layout=line | ?layout=band
// 2. localStorage.INVYONE_LAYOUT === 'line' | 'band'
// 3. process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line'
// 기본값은 'band' (env 가 'line' 이 아니면 false). SSR 초기 렌더는 env 기반
// 으로 결정되며 클라이언트 초기 렌더도 동일 → useEffect 에서 URL/LS 반영해
// 재렌더한다 (hydration mismatch 방지). 기존 band 경로는 손대지 않는다.
// ─────────────────────────────────────────────────────────────────────────────
function readEngineFromRuntime(): boolean {
if (typeof window === 'undefined') {
return process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line';
}
try {
const qs = new URLSearchParams(window.location.search);
const q = qs.get('layout');
if (q === 'line') return true;
if (q === 'band') return false;
const ls = window.localStorage?.getItem('INVYONE_LAYOUT');
if (ls === 'line') return true;
if (ls === 'band') return false;
} catch {
// 접근 제한 환경(iframe 등) 에서는 env 기본값으로 폴백
}
return process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line';
}
function useLineEngineFlag(): boolean {
const [enabled, setEnabled] = useState<boolean>(
() => process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line',
);
useEffect(() => {
setEnabled(readEngineFromRuntime());
}, []);
return enabled;
}
// ─────────────────────────────────────────────────────────────────────────────
// Band 구조 — role 기반 분류. 추측 없음, bandId 그대로 or 단순 fallback.
// ─────────────────────────────────────────────────────────────────────────────
@@ -224,6 +276,13 @@ export function TemplateRenderer({
const blocks = currentView?.blocks ?? [];
const bands = useMemo(() => buildBands(blocks), [blocks]);
const useLine = useLineEngineFlag();
const canvas: CanvasV2 = v2Views?.canvas ?? {
baseWidth: 1920,
baseHeight: 1080,
aspectPolicy: 'preserve',
};
// 디버그 덤프
if (typeof window !== 'undefined') {
const qs = new URLSearchParams(window.location.search);
@@ -235,7 +294,7 @@ export function TemplateRenderer({
console.groupCollapsed(
'%c[TemplateRenderer]',
'color:#6c5ce7;font-weight:bold',
`view=${view} blocks=${blocks.length} bands=${bands.length}`,
`engine=${useLine ? 'line' : 'band'} view=${view} blocks=${blocks.length} bands=${bands.length}`,
);
console.table(
blocks.map((b) => ({
@@ -278,6 +337,17 @@ export function TemplateRenderer({
);
}
if (useLine) {
return (
<LineGridView
blocks={blocks}
context={context}
view={view}
canvas={canvas}
/>
);
}
return (
<div className="itpl-wrapper">
<style>{`
@@ -491,6 +561,400 @@ function OverlaySlot({
);
}
// ─────────────────────────────────────────────────────────────────────────────
// LineGridView — feature flag 'line' 활성 시 사용되는 렌더러 (Step 2)
// 좌표에서 variable line grid 를 추출해 CSS Grid 로 렌더. role 은 semantic
// marker 로만 남고 레이아웃 계산에 개입하지 않는다. overlay 는 예외 경로만.
// 기존 band 경로(TemplateRenderer 본체 return) 와 공존하며, feature flag
// OFF 일 때 전혀 호출되지 않는다.
// ─────────────────────────────────────────────────────────────────────────────
function LineGridView({
blocks,
context,
view,
canvas,
}: {
blocks: BlockV2[];
context: TemplateRenderContext;
view: ViewKey;
canvas: CanvasV2;
}) {
const aspectPolicy = canvas.aspectPolicy;
const layout = useMemo(
() =>
computeLayout(
blocks.map((b) => ({
id: b.id,
xPct: b.xPct,
yPct: b.yPct,
wPct: b.wPct,
hPct: b.hPct,
forceOverlay: b.role === 'overlay',
})),
{ rowSizing: aspectPolicy === 'preserve' ? 'fr' : 'auto' },
),
[blocks, aspectPolicy],
);
const gridTemplateColumns = toTrackTemplate(layout.cols, 'fr');
const byId = useMemo(() => {
const m = new Map<string, BlockV2>();
for (const b of blocks) m.set(b.id, b);
return m;
}, [blocks]);
// ── row type 분류 + fit-to-height allocator (Step 2.7) ──────────────────
const plans = useMemo(
() =>
classifyRows(
layout,
blocks.map((b) => ({
id: b.id,
hPct: b.hPct,
role: b.role,
componentId: b.componentId,
})),
canvas.baseHeight,
),
[layout, blocks, canvas.baseHeight],
);
// wrapper 실제 세로 크기 측정 — ResizeObserver 로 반응형 추적
const wrapperRef = useRef<HTMLDivElement>(null);
const [containerH, setContainerH] = useState<number | null>(null);
useLayoutEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const update = () => {
const h = el.clientHeight;
if (h > 0) setContainerH(h);
};
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
// finalPx: containerH 측정 전이면 preferred 그대로(첫 프레임 fallback).
// 측정된 뒤엔 available 에 정확히 맞도록 재분배.
const finalPx = useMemo(() => {
if (containerH == null) return plans.map((p) => p.preferredPx);
return allocateRowHeightsPx(plans, containerH);
}, [plans, containerH]);
const gridTemplateRows =
aspectPolicy === 'free'
? layout.rows.map(() => 'auto').join(' ')
: finalPx.map((px) => `${px}px`).join(' ');
// 각 블록 cell 의 final 높이가 preferred 대비 많이 줄어든 경우 compact 클래스.
// 기준: finalH < preferredH * 0.9 (10% 이상 축소)
const blockCompact = useMemo(() => {
const m = new Map<string, boolean>();
for (const bl of layout.blocks) {
if (bl.mode !== 'grid') continue;
const b = byId.get(bl.blockId);
if (!b) continue;
const rs = (bl.rowStart ?? 1) - 1;
const re = (bl.rowEnd ?? 1) - 1;
if (re <= rs) continue;
let finalH = 0;
for (let i = rs; i < re; i++) finalH += finalPx[i] ?? 0;
const prefH = b.hPct * canvas.baseHeight;
if (prefH > 0 && finalH < prefH * 0.9) m.set(bl.blockId, true);
}
return m;
}, [layout, byId, finalPx, canvas.baseHeight]);
// 디버그 — row type / preferred / final / container / deficit·surplus
useEffect(() => {
if (typeof window === 'undefined' || blocks.length === 0) return;
try {
const qs = new URLSearchParams(window.location.search);
const enabled =
qs.has('debug') ||
window.localStorage?.getItem('INVYONE_DEBUG') === '1';
if (!enabled) return;
const totalPref = plans.reduce((s, p) => s + p.preferredPx, 0);
const totalFinal = finalPx.reduce((s, v) => s + v, 0);
const ch = containerH ?? 0;
const delta = ch > 0 ? ch - totalPref : 0;
/* eslint-disable no-console */
console.log(
'%c[TemplateRenderer/line rows]',
'color:#00cec9;font-weight:bold',
{
containerHeight: ch,
totalPreferred: Math.round(totalPref),
totalFinal: Math.round(totalFinal),
deficit: delta < 0 ? Math.round(-delta) : 0,
surplus: delta > 0 ? Math.round(delta) : 0,
template: gridTemplateRows,
},
);
console.table(
plans.map((p, i) => ({
i,
type: p.type,
preferredPx: p.preferredPx,
finalPx: Math.round(finalPx[i] ?? 0),
floorPx: p.floorPx,
occupiedBlockCount: p.occupiedBlockCount,
})),
);
/* eslint-enable no-console */
} catch {
// ignore
}
}, [plans, finalPx, containerH, gridTemplateRows, blocks.length]);
// 디버그 덤프 — 엔진/블록수/grid/overlay/cols/rows/tolerance + 블록별 box
if (typeof window !== 'undefined' && blocks.length > 0) {
try {
const qs = new URLSearchParams(window.location.search);
const enabled =
qs.has('debug') ||
window.localStorage?.getItem('INVYONE_DEBUG') === '1';
if (enabled) {
const gridCount = layout.blocks.filter((b) => b.mode === 'grid').length;
const overlayCount = layout.blocks.length - gridCount;
/* eslint-disable no-console */
console.log(
'%c[TemplateRenderer/line]',
'color:#00cec9;font-weight:bold',
`engine=line view=${view} blocks=${blocks.length} grid=${gridCount} overlay=${overlayCount} cols=${layout.cols.length} rows=${layout.rows.length} tolX=${layout.xToleranceUsed.toFixed(4)} tolY=${layout.yToleranceUsed.toFixed(4)} canvas=${canvas.baseWidth}x${canvas.baseHeight}`,
);
console.table(
layout.blocks.map((bl) => {
const b = byId.get(bl.blockId);
return {
id: bl.blockId,
cid: b?.componentId ?? '',
role: b?.role ?? '',
policy: b?.responsivePolicy ?? '',
wPct: b ? +b.wPct.toFixed(3) : '',
hPct: b ? +b.hPct.toFixed(3) : '',
px_w: b ? Math.round(b.wPct * canvas.baseWidth) : '',
px_h: b ? Math.round(b.hPct * canvas.baseHeight) : '',
mode: bl.mode,
span:
bl.mode === 'grid'
? `c${bl.colStart}-${bl.colEnd} r${bl.rowStart}-${bl.rowEnd}`
: '',
overlay: bl.overlayReason ?? '',
snap: bl.snapError !== undefined ? +bl.snapError.toFixed(4) : '',
};
}),
);
/* eslint-enable no-console */
}
} catch {
// ignore
}
}
return (
<div className="itpl-wrapper itpl-line" ref={wrapperRef}>
<style>{LINE_CSS}</style>
<div
className="itpl-grid"
style={{ gridTemplateColumns, gridTemplateRows }}
>
{layout.blocks.map((bl) => {
const block = byId.get(bl.blockId);
if (!block) return null;
const compact = blockCompact.get(block.id) ? ' compact' : '';
if (bl.mode === 'grid') {
return (
<div
key={block.id}
className={`itpl-slot role-${block.role} policy-${block.responsivePolicy}${compact}`}
data-comp={block.componentId}
data-col={`${bl.colStart}/${bl.colEnd}`}
data-row={`${bl.rowStart}/${bl.rowEnd}`}
style={{
gridColumn: `${bl.colStart} / ${bl.colEnd}`,
gridRow: `${bl.rowStart} / ${bl.rowEnd}`,
}}
>
<BlockRenderer
block={block}
context={context}
view={view}
canvas={canvas}
/>
</div>
);
}
return (
<div
key={block.id}
className={`itpl-overlay role-${block.role} policy-${block.responsivePolicy}${compact}`}
data-comp={block.componentId}
data-overlay-reason={bl.overlayReason}
style={{
left: `${(bl.leftPct ?? 0) * 100}%`,
top: `${(bl.topPct ?? 0) * 100}%`,
width: `${(bl.wPct ?? 0) * 100}%`,
height: `${(bl.hPct ?? 0) * 100}%`,
}}
>
<BlockRenderer
block={block}
context={context}
view={view}
canvas={canvas}
/>
</div>
);
})}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// line 전용 CSS — responsivePolicy / role 별 cell 스타일
//
// cell (itpl-slot) 은 grid track 이 이미 크기를 결정한다. 내부 컴포넌트가
// 그 크기를 따라가도록 flex-column 1개 자식 레이아웃을 기본값으로 둔다.
// policy 와 role 에 따라 overflow / content-size 를 재정의한다.
//
// 매트릭스 요약:
// default (main/companion + fixed) → cell 채움, overflow visible
// policy-scroll → cell 채움, overflow hidden (내부 auto)
// policy-reflow → cell 채움, overflow hidden
// policy-wrap → flex row wrap, content-size 자식
// role-action → flex row, content-size 자식 (버튼 잘림 방지)
// overlay → absolute, visible 기본
// ─────────────────────────────────────────────────────────────────────────────
const LINE_CSS = `
.itpl-wrapper.itpl-line {
position: relative;
width: 100%;
height: 100%;
container-type: inline-size;
container-name: tpl;
box-sizing: border-box;
overflow: hidden;
}
.itpl-line .itpl-grid {
position: relative;
display: grid;
width: 100%;
height: 100%;
box-sizing: border-box;
}
/* 기본 slot — cell 을 채우는 flex column */
.itpl-line .itpl-slot {
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: 0;
min-height: 0;
overflow: visible;
}
.itpl-line .itpl-slot > * {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
}
/* scroll / reflow: cell 내부에서 overflow 처리 — 내부 컴포넌트의 auto 스크롤
을 막지 않도록 cell 에 hidden 만 걸어 child min-size 0 을 보장한다 */
.itpl-line .itpl-slot.policy-scroll,
.itpl-line .itpl-slot.policy-reflow {
overflow: hidden;
}
/* wrap: 행 방향 flex-wrap, 자식은 content-size 유지 */
.itpl-line .itpl-slot.policy-wrap {
flex-direction: row;
flex-wrap: wrap;
align-content: flex-start;
align-items: center;
overflow: visible;
}
.itpl-line .itpl-slot.policy-wrap > * {
flex: 0 0 auto;
width: auto;
height: auto;
}
/* action: 버튼/입력 등이 cell 에 맞춰 늘어나지 않고 content-size 유지.
단 세로로는 row 경계를 넘지 못하게 clip — table 위 침범 방지. 가로는
CSS 규약상 x/y 중 하나만 visible 로 두면 다른 축이 auto 가 되므로 양쪽
hidden 으로 간다. cell 폭이 충분하도록 저작자가 wPct 를 부여한다는 전제. */
.itpl-line .itpl-slot.role-action {
flex-direction: row;
align-items: center;
overflow: hidden;
}
.itpl-line .itpl-slot.role-action > * {
flex: 0 0 auto;
width: auto;
height: auto;
}
/* button / button-bar / pagination — 컴포넌트가 inline style 로 주입하는
design px (예: 120x40) 를 존중한다. wrapper 는 자식의 폭/높이를 강제하지
않고 좌측 정렬로 감싸기만 한다. 기본 .itpl-slot > * 의 flex:1 1 auto 와
width/height:100% 를 여기서 완화. !important 는 사용하지 않는다 — 그래야
ButtonComponent 의 inline 스타일(width:120px 등)이 최종 우승한다. */
.itpl-line .itpl-slot[data-comp="button"],
.itpl-line .itpl-slot[data-comp="button-bar"],
.itpl-line .itpl-slot[data-comp="pagination"] {
flex-direction: row;
align-items: center;
justify-content: flex-start;
overflow: hidden;
}
.itpl-line .itpl-slot[data-comp="button"] > *,
.itpl-line .itpl-slot[data-comp="button-bar"] > *,
.itpl-line .itpl-slot[data-comp="pagination"] > * {
flex: 0 0 auto;
min-width: 0;
min-height: 0;
align-self: center;
}
/* compact: cell final 높이가 preferred 대비 10% 이상 축소된 경우 부여.
폰트/padding 을 소폭 줄여 잘림을 완화. outer scroll 은 만들지 않는다. */
.itpl-line .itpl-slot.compact,
.itpl-line .itpl-overlay.compact {
font-size: 0.92em;
line-height: 1.15;
}
.itpl-line .itpl-slot.compact > * {
gap: 4px;
}
/* overlay: absolute, 기본 visible. policy-scroll 인 overlay 는 내부 hidden. */
.itpl-line .itpl-overlay {
position: absolute;
z-index: 2;
box-sizing: border-box;
overflow: visible;
}
.itpl-line .itpl-overlay.policy-scroll,
.itpl-line .itpl-overlay.policy-reflow {
overflow: hidden;
}
.itpl-line .itpl-overlay > * {
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
}
`;
// ─────────────────────────────────────────────────────────────────────────────
// BlockRenderer — ComponentRegistry 위임
// ─────────────────────────────────────────────────────────────────────────────
@@ -499,10 +963,16 @@ function BlockRenderer({
block,
context,
view,
canvas,
}: {
block: BlockV2;
context: TemplateRenderContext;
view: ViewKey;
/**
* 선택: 제공되면 block 의 %좌표를 px 로 환산해 컴포넌트에 전달한다.
* line 경로에서만 넘어오고, band 경로는 기존 동작(size 0) 유지 — 회귀 방지.
*/
canvas?: CanvasV2;
}) {
const def = ComponentRegistry.getComponent(block.componentId);
if (!def?.component) {
@@ -518,13 +988,26 @@ function BlockRenderer({
);
}
const Cmp = def.component as React.ComponentType<any>;
const bw = canvas?.baseWidth ?? 0;
const bh = canvas?.baseHeight ?? 0;
const position = {
x: bw > 0 ? block.xPct * bw : 0,
y: bh > 0 ? block.yPct * bh : 0,
z: 1,
};
const size = {
width: bw > 0 ? block.wPct * bw : 0,
height: bh > 0 ? block.hPct * bh : 0,
};
return (
<Cmp
component={{
id: block.id,
componentType: block.componentId,
position: { x: 0, y: 0, z: 1 },
size: { width: 0, height: 0 },
position,
size,
componentConfig: block.config,
component_config: block.config,
style: {},
@@ -0,0 +1,313 @@
/**
* lineLayout unit tests
*
* 실행: jest lib/layout/__tests__/lineLayout.test.ts
*
* jest 설정이 프로젝트에 정식으로 추가되면 바로 실행 가능.
* 순수 함수만 테스트하므로 DOM / next 의존성 없음.
*/
import {
computeLayout,
extractLines,
mergeThinTracks,
fitLines,
nearestLine,
toTracks,
toTrackTemplate,
type LayoutBlockInput,
LAYOUT_CONSTANTS,
} from '../lineLayout';
const blk = (
id: string,
x: number,
y: number,
w: number,
h: number,
extra: Partial<LayoutBlockInput> = {},
): LayoutBlockInput => ({ id, xPct: x, yPct: y, wPct: w, hPct: h, ...extra });
const approx = (a: number, b: number, tol = 1e-6) => Math.abs(a - b) <= tol;
describe('extractLines', () => {
test('항상 0 과 1 을 포함한다', () => {
expect(extractLines([0.3, 0.6], 0.004)).toEqual([0, 0.3, 0.6, 1]);
});
test('tolerance 이내 값은 평균 클러스터로 병합된다', () => {
const out = extractLines([0.3, 0.303], 0.004);
expect(out.length).toBe(3); // [0, ~0.3015, 1]
expect(approx(out[1], 0.3015, 1e-3)).toBe(true);
});
test('0 과 가까운 값(<=tol) 은 0 에 흡수된다', () => {
const out = extractLines([0.002, 0.5], 0.004);
expect(out).toEqual([0, 0.5, 1]);
});
test('1 과 가까운 값(>=1-tol) 은 1 에 흡수된다', () => {
const out = extractLines([0.5, 0.998], 0.004);
expect(out).toEqual([0, 0.5, 1]);
});
test('빈 입력 → [0,1]', () => {
expect(extractLines([], 0.004)).toEqual([0, 1]);
});
});
describe('mergeThinTracks', () => {
test('threshold 미만 내부 트랙은 병합된다', () => {
const merged = mergeThinTracks([0, 0.499, 0.5, 1], 0.0025);
expect(merged.length).toBe(3);
expect(merged[0]).toBe(0);
expect(merged[2]).toBe(1);
});
test('첫 트랙이 얇으면 두 번째 선을 제거한다', () => {
const merged = mergeThinTracks([0, 0.001, 0.5, 1], 0.0025);
expect(merged).toEqual([0, 0.5, 1]);
});
test('마지막 트랙이 얇으면 뒤에서 두 번째 선을 제거한다', () => {
const merged = mergeThinTracks([0, 0.5, 0.999, 1], 0.0025);
expect(merged).toEqual([0, 0.5, 1]);
});
test('양 끝 0/1 은 절대 제거되지 않는다', () => {
const merged = mergeThinTracks([0, 1], 0.5);
expect(merged).toEqual([0, 1]);
});
});
describe('nearestLine', () => {
test('정확히 매칭되는 선을 찾는다', () => {
const r = nearestLine(0.5, [0, 0.5, 1]);
expect(r.idx).toBe(1);
expect(r.err).toBe(0);
});
test('가장 가까운 인덱스를 반환한다', () => {
const r = nearestLine(0.48, [0, 0.5, 1]);
expect(r.idx).toBe(1);
expect(approx(r.err, 0.02)).toBe(true);
});
});
describe('toTracks / toTrackTemplate', () => {
test('선 [0, 0.3, 1] → cols 2개, weight 0.3 / 0.7', () => {
const t = toTracks([0, 0.3, 1]);
expect(t.length).toBe(2);
expect(approx(t[0].weight, 0.3)).toBe(true);
expect(approx(t[1].weight, 0.7)).toBe(true);
});
test('fr 모드는 minmax(0, Xfr) 형식', () => {
const t = toTracks([0, 0.3, 1]);
const tpl = toTrackTemplate(t, 'fr');
expect(tpl).toContain('minmax(0,');
expect(tpl).toContain('fr)');
expect(tpl.split(' ').length).toBeGreaterThanOrEqual(2);
});
test('auto 모드는 auto 만 반복', () => {
const t = toTracks([0, 0.5, 1]);
expect(toTrackTemplate(t, 'auto')).toBe('auto auto');
});
});
describe('fitLines — 과분할 재병합', () => {
test('경계가 너무 많으면 tolerance 를 증가시켜 재추출', () => {
// 블록 3개인데 서로 0.001 간격의 경계값 10개
const xs = [0, 0.001, 0.002, 0.003, 0.1, 0.101, 0.5, 0.501, 0.502, 1];
const r = fitLines(xs, 3, 0.004, 0.02, 1.35, 0.0025);
// target = 3*2+2 = 8. 원래 선은 최소 ~6~8 개 정도일 것이므로
// 결과가 target 이하여야 함.
expect(r.lines.length).toBeLessThanOrEqual(8);
expect(r.lines[0]).toBe(0);
expect(r.lines[r.lines.length - 1]).toBe(1);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// computeLayout 통합 테스트 (T1 ~ T15)
// ─────────────────────────────────────────────────────────────────────────────
describe('computeLayout — T1 2×2 균일', () => {
test('모든 블록이 grid 에 배치되고 cols/rows 가 3/3', () => {
const r = computeLayout([
blk('a', 0, 0, 0.5, 0.5),
blk('b', 0.5, 0, 0.5, 0.5),
blk('c', 0, 0.5, 0.5, 0.5),
blk('d', 0.5, 0.5, 0.5, 0.5),
]);
expect(r.xLines).toEqual([0, 0.5, 1]);
expect(r.yLines).toEqual([0, 0.5, 1]);
expect(r.cols.length).toBe(2);
expect(r.rows.length).toBe(2);
for (const b of r.blocks) expect(b.mode).toBe('grid');
const a = r.blocks.find((x) => x.blockId === 'a')!;
expect(a.colStart).toBe(1);
expect(a.colEnd).toBe(2);
expect(a.rowStart).toBe(1);
expect(a.rowEnd).toBe(2);
});
});
describe('computeLayout — T2 3열 1행', () => {
test('xLines 가 4개 (0, 1/3, 2/3, 1), 모두 grid', () => {
const third = 1 / 3;
const r = computeLayout([
blk('a', 0, 0, third, 1),
blk('b', third, 0, third, 1),
blk('c', 2 * third, 0, third, 1),
]);
expect(r.xLines.length).toBe(4);
expect(r.yLines).toEqual([0, 1]);
for (const b of r.blocks) expect(b.mode).toBe('grid');
});
});
describe('computeLayout — T3 세로 스택', () => {
test('rows 5개, 모든 블록 grid', () => {
const blocks = [0, 0.2, 0.4, 0.6, 0.8].map((y, i) =>
blk(`b${i}`, 0, y, 1, 0.2),
);
const r = computeLayout(blocks);
expect(r.xLines).toEqual([0, 1]);
expect(r.yLines.length).toBe(6);
expect(r.rows.length).toBe(5);
for (const b of r.blocks) expect(b.mode).toBe('grid');
});
});
describe('computeLayout — T4 0.3% 오차 병합', () => {
test('0.3% 오차 블록들이 동일한 선으로 병합된다', () => {
const r = computeLayout([
blk('a', 0, 0, 0.5, 1),
blk('b', 0.500, 0, 0.5, 1),
blk('c', 0.503, 0, 0.5, 1), // 0.3% 차이 → 같은 선
]);
// xLines 는 최소한 0, ~0.5, 1 셋 + 전체 폭을 넘는 값 정도면 OK
// 핵심: 블록 b 와 c 가 같은 colStart 를 가져야 함
const b = r.blocks.find((x) => x.blockId === 'b')!;
const c = r.blocks.find((x) => x.blockId === 'c')!;
expect(b.mode).toBe('grid');
expect(c.mode).toBe('grid');
// 0.003 < baseXTol(0.004) 이라 병합 → 같은 colStart
expect(c.colStart).toBe(b.colStart);
});
});
describe('computeLayout — T6 forced overlay', () => {
test('role=overlay 는 grid 선에 기여하지 않는다', () => {
const r = computeLayout([
blk('bg', 0, 0, 1, 1),
blk('fl', 0.1, 0.1, 0.2, 0.2, { forceOverlay: true }),
]);
// fl 의 경계 0.1, 0.3 은 xLines 에 들어가면 안 됨
expect(r.xLines).toEqual([0, 1]);
expect(r.yLines).toEqual([0, 1]);
const fl = r.blocks.find((x) => x.blockId === 'fl')!;
expect(fl.mode).toBe('overlay');
expect(fl.overlayReason).toBe('forced');
});
});
describe('computeLayout — T7/T8 겹침 15% 경계', () => {
test('10% 겹침 → 둘 다 grid 유지', () => {
// 1x1 캔버스, A 가 0~0.5, B 가 0.45~0.95 로 가로 10% 만 겹침, 세로도 10%
// overlap = 0.05 * 0.05 = 0.0025, 작은 블록 면적 0.25 → 1% → threshold 미만
const r = computeLayout([
blk('a', 0, 0, 0.5, 0.5),
blk('b', 0.45, 0.45, 0.5, 0.5),
]);
// baseTol=0.004 면 0.45/0.5 경계는 별개라 xLines/yLines 가 4개씩 생길 것
// 두 블록 모두 grid 에 남아야 함. overlap 강등 발생하지 않음.
const a = r.blocks.find((x) => x.blockId === 'a')!;
const b = r.blocks.find((x) => x.blockId === 'b')!;
// a 가 overlap 으로 강등되지 않을 것이라는 점만 보장
expect(a.mode === 'grid' && b.mode === 'grid').toBe(true);
});
test('큰 겹침 → 뒤 블록이 overlay 로 강등', () => {
// A 와 B 가 거의 동일한 영역 (80% 이상 겹침)
const r = computeLayout([
blk('a', 0, 0, 0.5, 0.5),
blk('b', 0, 0, 0.5, 0.5),
]);
const a = r.blocks.find((x) => x.blockId === 'a')!;
const b = r.blocks.find((x) => x.blockId === 'b')!;
expect(a.mode).toBe('grid');
expect(b.mode).toBe('overlay');
expect(b.overlayReason).toBe('overlap');
});
});
describe('computeLayout — T9 invalid span', () => {
test('wPct=0 블록은 invalid-span overlay 로 빠진다', () => {
const r = computeLayout([
blk('a', 0, 0, 1, 1),
blk('zero', 0.5, 0.5, 0, 0),
]);
const z = r.blocks.find((x) => x.blockId === 'zero')!;
expect(z.mode).toBe('overlay');
expect(z.overlayReason).toBe('invalid-span');
});
});
describe('computeLayout — T13 rowSizing=auto', () => {
test('rows template 이 auto 들로 구성된다', () => {
const r = computeLayout(
[blk('a', 0, 0, 1, 0.5), blk('b', 0, 0.5, 1, 0.5)],
{ rowSizing: 'auto' },
);
const tpl = toTrackTemplate(r.rows, 'auto');
expect(tpl.split(' ').every((s) => s === 'auto')).toBe(true);
});
});
describe('computeLayout — T14 경계 보장', () => {
test('블록이 중앙에만 있어도 xLines/yLines 양 끝이 0, 1', () => {
const r = computeLayout([blk('a', 0.25, 0.25, 0.5, 0.5)]);
expect(r.xLines[0]).toBe(0);
expect(r.xLines[r.xLines.length - 1]).toBe(1);
expect(r.yLines[0]).toBe(0);
expect(r.yLines[r.yLines.length - 1]).toBe(1);
});
});
describe('computeLayout — T15 fr weight 비례', () => {
test('cols weight 가 인접 선 간 거리 비율과 같다', () => {
const r = computeLayout([
blk('a', 0, 0, 0.3, 1),
blk('b', 0.3, 0, 0.7, 1),
]);
expect(r.cols.length).toBe(2);
expect(approx(r.cols[0].weight, 0.3)).toBe(true);
expect(approx(r.cols[1].weight, 0.7)).toBe(true);
});
});
describe('computeLayout — 빈 입력', () => {
test('blocks 가 빈 배열이면 기본 1x1 grid 를 돌려준다', () => {
const r = computeLayout([]);
expect(r.xLines).toEqual([0, 1]);
expect(r.yLines).toEqual([0, 1]);
expect(r.cols.length).toBe(1);
expect(r.rows.length).toBe(1);
expect(r.blocks.length).toBe(0);
});
});
describe('LAYOUT_CONSTANTS — 기본값 잠금', () => {
test('상수 기본값이 설계안과 일치', () => {
expect(LAYOUT_CONSTANTS.BASE_X_TOLERANCE).toBe(0.004);
expect(LAYOUT_CONSTANTS.BASE_Y_TOLERANCE).toBe(0.004);
expect(LAYOUT_CONSTANTS.MAX_TOLERANCE).toBe(0.02);
expect(LAYOUT_CONSTANTS.TOLERANCE_GROWTH).toBe(1.35);
expect(LAYOUT_CONSTANTS.THIN_TRACK_THRESHOLD).toBe(0.0025);
expect(LAYOUT_CONSTANTS.OVERLAP_THRESHOLD).toBe(0.15);
expect(LAYOUT_CONSTANTS.SNAP_ERROR_MULT).toBe(2);
});
});
+733
View File
@@ -0,0 +1,733 @@
/**
* lineLayout — 자유배치 좌표에서 variable line grid 를 계산한다.
* ============================================================================
*
* 설계: notes/gbpark/2026-04-20-line-based-layout.md
*
* 원칙
* - 블록의 xPct/yPct/wPct/hPct 만 사용. role/bandId 로 재조립하지 않는다.
* - 가로/세로 경계선에서 xLines/yLines 를 추출 → 인접 선 간 거리 비율을
* 그대로 CSS Grid track weight 로 사용.
* - 각 블록은 1-based colStart/colEnd, rowStart/rowEnd span 으로 렌더.
* - overlay 는 예외 경로(forced, snap-error, invalid-span, overlap≥15%).
* - 편집기 / 미리보기 / 런타임이 이 함수 하나를 공유한다.
*
* 이 모듈은 순수함수다. DOM/React 의존성 없음.
* ============================================================================
*/
// ─────────────────────────────────────────────────────────────────────────────
// 상수
// ─────────────────────────────────────────────────────────────────────────────
export const LAYOUT_CONSTANTS = {
/** x 축 선 병합 기본 허용 오차 (캔버스 폭 대비 비율, 0~1) */
BASE_X_TOLERANCE: 0.004,
/** y 축 선 병합 기본 허용 오차 */
BASE_Y_TOLERANCE: 0.004,
/** 과분할 재병합 상한 */
MAX_TOLERANCE: 0.02,
/** 재시도마다 tolerance 에 곱할 성장률 */
TOLERANCE_GROWTH: 1.35,
/** 전체 폭/높이 대비 이 비율 미만 트랙은 인접 트랙과 병합 */
THIN_TRACK_THRESHOLD: 0.0025,
/** 작은 블록 면적 대비 이 비율 이상 겹치면 overlay 로 강등 */
OVERLAP_THRESHOLD: 0.15,
/** snap 오차 > tolerance * 이 값 이면 overlay */
SNAP_ERROR_MULT: 2,
} as const;
// ─────────────────────────────────────────────────────────────────────────────
// 입출력 타입
// ─────────────────────────────────────────────────────────────────────────────
export interface LayoutBlockInput {
id: string;
xPct: number;
yPct: number;
wPct: number;
hPct: number;
/** 좌표 무관하게 absolute 로 강제 렌더 (BlockV2.role==='overlay' 등) */
forceOverlay?: boolean;
/** z-order hint. 생략 시 입력 배열 순서 */
z?: number;
}
export interface LayoutOptions {
baseXTolerance?: number;
baseYTolerance?: number;
maxTolerance?: number;
toleranceGrowth?: number;
thinTrackThreshold?: number;
overlapThreshold?: number;
snapErrorMult?: number;
/** 세로 축 사이징 — 'fr' 는 비율 grid, 'auto' 는 content-size */
rowSizing?: 'fr' | 'auto';
}
export interface GridTrack {
index: number;
startPct: number;
endPct: number;
/** 0~1 — 전체 대비 폭 비율이자 fr weight */
weight: number;
}
export type OverlayReason = 'forced' | 'overlap' | 'invalid-span' | 'snap-error';
export interface BlockLayout {
blockId: string;
mode: 'grid' | 'overlay';
// grid 모드
colStart?: number;
colEnd?: number;
rowStart?: number;
rowEnd?: number;
// overlay 모드 (원본 좌표 그대로 복제)
leftPct?: number;
topPct?: number;
wPct?: number;
hPct?: number;
// 디버그
snapError?: number;
overlayReason?: OverlayReason;
}
export interface LayoutResult {
xLines: number[];
yLines: number[];
cols: GridTrack[];
rows: GridTrack[];
/** 입력 순서 보존 */
blocks: BlockLayout[];
xToleranceUsed: number;
yToleranceUsed: number;
}
// ─────────────────────────────────────────────────────────────────────────────
// 선 추출 / 병합
// ─────────────────────────────────────────────────────────────────────────────
/**
* 경계값 배열을 tolerance 로 클러스터링 해 정렬된 선 배열을 반환한다.
* 결과는 항상 `[0, ..., 1]` 형태 — 양쪽 경계 강제 포함.
*
* tolerance 이내의 클러스터는 평균값으로 합쳐지며, 0/1 과 가까운 클러스터
* (<= tolerance 또는 >= 1 - tolerance) 는 0/1 로 흡수된다.
*/
export function extractLines(values: number[], tolerance: number): number[] {
const sorted = values
.filter((v) => Number.isFinite(v) && v >= 0 && v <= 1)
.slice()
.sort((a, b) => a - b);
const clusters: number[] = [];
let bucket: number[] = [];
for (const v of sorted) {
if (bucket.length === 0 || v - bucket[bucket.length - 1] <= tolerance) {
bucket.push(v);
} else {
clusters.push(avg(bucket));
bucket = [v];
}
}
if (bucket.length) clusters.push(avg(bucket));
const lines: number[] = [0];
for (const c of clusters) {
if (c <= tolerance) continue; // 0 에 흡수
if (c >= 1 - tolerance) continue; // 1 에 흡수 (아래 push)
lines.push(c);
}
lines.push(1);
return lines;
}
/**
* 전체 폭/높이 대비 `threshold` 미만의 얇은 트랙을 인접 트랙과 병합한다.
* 첫 선(0) 과 마지막 선(1) 은 절대 제거하지 않는다.
*/
export function mergeThinTracks(lines: number[], threshold: number): number[] {
if (lines.length <= 2) return [0, 1];
const cur = lines.slice();
let changed = true;
let guard = 0;
while (changed && cur.length > 2 && guard++ < 64) {
changed = false;
for (let i = 0; i < cur.length - 1; i++) {
const gap = cur[i + 1] - cur[i];
if (gap >= threshold) continue;
if (i === 0) {
// 첫 트랙이 얇음 → 두 번째 선(idx 1) 제거, 0 은 유지
cur.splice(1, 1);
} else if (i === cur.length - 2) {
// 마지막 트랙이 얇음 → 뒤에서 두 번째 선 제거, 1 은 유지
cur.splice(cur.length - 2, 1);
} else {
// 내부: 인접 두 선을 평균으로 합치고 하나 제거
cur[i] = (cur[i] + cur[i + 1]) / 2;
cur.splice(i + 1, 1);
}
changed = true;
break;
}
}
cur[0] = 0;
cur[cur.length - 1] = 1;
return cur;
}
/**
* 선 개수 상한(`blockCount*2 + 2`) 을 넘지 않을 때까지 tolerance 를
* `growth` 배씩 키우며 재추출한다. 각 반복 결과에 thin-track merge 도 적용.
*/
export function fitLines(
values: number[],
blockCount: number,
baseTol: number,
maxTol: number,
growth: number,
thinThreshold: number,
): { lines: number[]; tolerance: number } {
const target = Math.max(4, blockCount * 2 + 2);
let tol = baseTol;
let lines = mergeThinTracks(extractLines(values, tol), thinThreshold);
let guard = 0;
while (lines.length > target && tol < maxTol && guard++ < 32) {
tol = Math.min(maxTol, tol * growth);
lines = mergeThinTracks(extractLines(values, tol), thinThreshold);
}
return { lines, tolerance: tol };
}
// ─────────────────────────────────────────────────────────────────────────────
// span 매핑 헬퍼
// ─────────────────────────────────────────────────────────────────────────────
/** 선 배열 중 value 에 가장 가까운 선의 인덱스 + 오차. */
export function nearestLine(value: number, lines: number[]): { idx: number; err: number } {
let best = 0;
let bestErr = Infinity;
for (let i = 0; i < lines.length; i++) {
const e = Math.abs(lines[i] - value);
if (e < bestErr) {
best = i;
bestErr = e;
}
}
return { idx: best, err: bestErr };
}
// ─────────────────────────────────────────────────────────────────────────────
// 트랙 / 템플릿 생성
// ─────────────────────────────────────────────────────────────────────────────
/** 선 배열에서 인접 간격별 트랙 객체 배열 생성. weight = 폭 비율. */
export function toTracks(lines: number[]): GridTrack[] {
const out: GridTrack[] = [];
for (let i = 0; i < lines.length - 1; i++) {
const start = lines[i];
const end = lines[i + 1];
out.push({
index: i,
startPct: start,
endPct: end,
weight: Math.max(0.0001, end - start),
});
}
return out;
}
/**
* grid-template-columns/rows 문자열 생성.
* - 'fr' → `minmax(0, <weight>fr)` — 카드 폭이 줄어도 0 까지 축소 허용
* - 'auto' → `auto auto ...` — content 세로 확장 허용
*/
export function toTrackTemplate(tracks: GridTrack[], mode: 'fr' | 'auto'): string {
if (mode === 'auto') return tracks.map(() => 'auto').join(' ');
return tracks.map((t) => `minmax(0, ${formatFr(t.weight)}fr)`).join(' ');
}
// ─────────────────────────────────────────────────────────────────────────────
// overlay 헬퍼
// ─────────────────────────────────────────────────────────────────────────────
function overlapRatio(a: LayoutBlockInput, b: LayoutBlockInput): number {
const xOv = Math.max(
0,
Math.min(a.xPct + a.wPct, b.xPct + b.wPct) - Math.max(a.xPct, b.xPct),
);
const yOv = Math.max(
0,
Math.min(a.yPct + a.hPct, b.yPct + b.hPct) - Math.max(a.yPct, b.yPct),
);
const inter = xOv * yOv;
const areaA = Math.max(0, a.wPct) * Math.max(0, a.hPct);
const areaB = Math.max(0, b.wPct) * Math.max(0, b.hPct);
const minArea = Math.max(1e-9, Math.min(areaA, areaB));
return inter / minArea;
}
function makeOverlay(
b: LayoutBlockInput,
snapError: number,
reason: OverlayReason,
): BlockLayout {
return {
blockId: b.id,
mode: 'overlay',
leftPct: b.xPct,
topPct: b.yPct,
wPct: b.wPct,
hPct: b.hPct,
snapError,
overlayReason: reason,
};
}
function demoteToOverlay(layout: BlockLayout, src: LayoutBlockInput, reason: OverlayReason) {
layout.mode = 'overlay';
layout.overlayReason = reason;
layout.leftPct = src.xPct;
layout.topPct = src.yPct;
layout.wPct = src.wPct;
layout.hPct = src.hPct;
delete layout.colStart;
delete layout.colEnd;
delete layout.rowStart;
delete layout.rowEnd;
}
// ─────────────────────────────────────────────────────────────────────────────
// 메인
// ─────────────────────────────────────────────────────────────────────────────
/**
* 블록 배치로부터 variable line grid 를 계산한다.
*
* 반환된 `blocks` 는 입력 순서를 그대로 유지한다 (overlay 포함).
*/
export function computeLayout(
blocks: LayoutBlockInput[],
opts: LayoutOptions = {},
): LayoutResult {
const baseXTol = opts.baseXTolerance ?? LAYOUT_CONSTANTS.BASE_X_TOLERANCE;
const baseYTol = opts.baseYTolerance ?? LAYOUT_CONSTANTS.BASE_Y_TOLERANCE;
const maxTol = opts.maxTolerance ?? LAYOUT_CONSTANTS.MAX_TOLERANCE;
const growth = opts.toleranceGrowth ?? LAYOUT_CONSTANTS.TOLERANCE_GROWTH;
const thinTh = opts.thinTrackThreshold ?? LAYOUT_CONSTANTS.THIN_TRACK_THRESHOLD;
const overlapTh = opts.overlapThreshold ?? LAYOUT_CONSTANTS.OVERLAP_THRESHOLD;
const snapMult = opts.snapErrorMult ?? LAYOUT_CONSTANTS.SNAP_ERROR_MULT;
// 0. 빈 입력 처리
if (!Array.isArray(blocks) || blocks.length === 0) {
return {
xLines: [0, 1],
yLines: [0, 1],
cols: [{ index: 0, startPct: 0, endPct: 1, weight: 1 }],
rows: [{ index: 0, startPct: 0, endPct: 1, weight: 1 }],
blocks: [],
xToleranceUsed: baseXTol,
yToleranceUsed: baseYTol,
};
}
// 1. forced overlay 분리 (grid 선 추출에서 제외)
const candidates: LayoutBlockInput[] = [];
const forced: LayoutBlockInput[] = [];
for (const b of blocks) {
if (b.forceOverlay) forced.push(b);
else candidates.push(b);
}
// 2. 경계값 수집
const xs: number[] = [];
const ys: number[] = [];
for (const b of candidates) {
xs.push(b.xPct, b.xPct + b.wPct);
ys.push(b.yPct, b.yPct + b.hPct);
}
// 3. x / y 선 추출 (각자 tolerance)
const fx = fitLines(xs, candidates.length, baseXTol, maxTol, growth, thinTh);
const fy = fitLines(ys, candidates.length, baseYTol, maxTol, growth, thinTh);
const xLines = fx.lines;
const yLines = fy.lines;
const tolX = fx.tolerance;
const tolY = fy.tolerance;
const effTol = Math.max(tolX, tolY);
const cols = toTracks(xLines);
const rows = toTracks(yLines);
// 4. 입력 순서를 그대로 유지하며 span 매핑
// grid 확정된 블록은 gridOk 에 따로 쌓아두고, overlap 판정에 사용.
const byId = new Map<string, BlockLayout>();
const ordered: BlockLayout[] = new Array(blocks.length);
const gridOk: { input: LayoutBlockInput; layout: BlockLayout; inputIdx: number }[] = [];
for (let i = 0; i < blocks.length; i++) {
const b = blocks[i];
if (b.forceOverlay) {
const layout = makeOverlay(b, 0, 'forced');
ordered[i] = layout;
byId.set(b.id, layout);
continue;
}
const l = nearestLine(b.xPct, xLines);
const r = nearestLine(b.xPct + b.wPct, xLines);
const t = nearestLine(b.yPct, yLines);
const bt = nearestLine(b.yPct + b.hPct, yLines);
const snapError = Math.max(l.err, r.err, t.err, bt.err);
if (snapError > effTol * snapMult) {
const layout = makeOverlay(b, snapError, 'snap-error');
ordered[i] = layout;
byId.set(b.id, layout);
continue;
}
const colStart = l.idx + 1;
const colEnd = r.idx + 1;
const rowStart = t.idx + 1;
const rowEnd = bt.idx + 1;
if (colEnd <= colStart || rowEnd <= rowStart) {
const layout = makeOverlay(b, snapError, 'invalid-span');
ordered[i] = layout;
byId.set(b.id, layout);
continue;
}
const layout: BlockLayout = {
blockId: b.id,
mode: 'grid',
colStart,
colEnd,
rowStart,
rowEnd,
snapError,
};
ordered[i] = layout;
byId.set(b.id, layout);
gridOk.push({ input: b, layout, inputIdx: i });
}
// 5. overlap 판정 — grid 확정된 블록 쌍 중 overlap >= 임계면, z 또는 입력
// 순서상 뒤에 오는 블록을 overlay 로 강등.
for (let i = 0; i < gridOk.length; i++) {
for (let j = i + 1; j < gridOk.length; j++) {
const A = gridOk[i];
const B = gridOk[j];
if (A.layout.mode !== 'grid' || B.layout.mode !== 'grid') continue;
if (overlapRatio(A.input, B.input) < overlapTh) continue;
// 강등 대상 선정: z 큰쪽 우선, 같으면 입력 순서상 뒤(=inputIdx 큰쪽)
const zA = A.input.z ?? A.inputIdx;
const zB = B.input.z ?? B.inputIdx;
const demote = zA === zB ? (A.inputIdx > B.inputIdx ? A : B) : zA > zB ? A : B;
demoteToOverlay(demote.layout, demote.input, 'overlap');
}
}
return {
xLines,
yLines,
cols,
rows,
blocks: ordered,
xToleranceUsed: tolX,
yToleranceUsed: tolY,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────────────
function avg(arr: number[]): number {
let sum = 0;
for (const v of arr) sum += v;
return sum / arr.length;
}
function round3(v: number): number {
return Math.round(v * 1000) / 1000;
}
// ─────────────────────────────────────────────────────────────────────────────
// Row classification + fit-to-height allocator (Step 2.7, 2026-04-20)
//
// preferred row heights 를 산출하고, 사용 가능한 container height 에 정확히
// 맞도록 재분배한다. outer scroll 생성 금지 — row 가 바깥 스크롤을 유발하지
// 않도록, 공간이 부족하면 elastic → semi-rigid → rigid 순으로 압축한다.
// rigid 는 floor 까지만 줄이고, 그래도 부족하면 그대로 clip (wrapper overflow
// hidden 이 외부로의 노출을 차단).
// ─────────────────────────────────────────────────────────────────────────────
export type RowType = 'gap' | 'rigid' | 'semi-rigid' | 'elastic';
export interface RowClassifyBlockInfo {
id: string;
hPct: number;
role?: string;
componentId?: string;
}
export interface RowClassifyOptions {
/** rigid 판정 role (기본 ['action']) */
rigidRoles?: string[];
/** rigid 판정 componentId (기본 ['button','button-bar','pagination']) */
rigidComponentIds?: string[];
/** elastic 판정 componentId (기본 table/container/form/search/tabs/split-panel/accordion) */
elasticComponentIds?: string[];
/** floor (px) */
rigidFloor?: number; // default 40
semiFloor?: number; // default 56
elasticFloor?: number; // default 80
/** gap row 의 floor (px). 기본 0 (거의 0 까지 줄일 수 있다). */
gapFloor?: number;
}
export interface RowPlan {
type: RowType;
/** design 기준 권장 px (max share within row, floor 적용) */
preferredPx: number;
/** 해당 type 의 최소 허용 px */
floorPx: number;
/** 이 row 에 걸친 grid 블록의 수 (debug/디버그용). gap row 는 0. */
occupiedBlockCount: number;
}
/**
* 각 row 의 type 과 preferred/floor px 를 산출한다.
* row 에 걸친 블록들의 가장 elastic 한 성향을 row type 으로 승격 (elastic >
* semi-rigid > rigid). preferred = rowSpan 분배 후 blocks max share, floor 적용.
*/
export function classifyRows(
layout: Pick<LayoutResult, 'blocks' | 'rows'>,
blocks: RowClassifyBlockInfo[],
baseHeight: number,
opts: RowClassifyOptions = {},
): RowPlan[] {
const rowCount = layout.rows.length;
if (rowCount === 0) return [];
const rigidRoles = new Set(opts.rigidRoles ?? ['action']);
const rigidIds = new Set(
opts.rigidComponentIds ?? ['button', 'button-bar', 'pagination'],
);
const elasticIds = new Set(
opts.elasticComponentIds ?? [
'table',
'container',
'form',
'search',
'tabs',
'split-panel',
'accordion',
],
);
const rigidFloor = opts.rigidFloor ?? 40;
const semiFloor = opts.semiFloor ?? 56;
const elasticFloor = opts.elasticFloor ?? 80;
const gapFloor = opts.gapFloor ?? 0;
const byId = new Map<string, RowClassifyBlockInfo>();
for (const b of blocks) byId.set(b.id, b);
// block kind 는 elastic / rigid / semi-rigid 중 하나 (gap 은 block 이 없는
// row 에만 해당). 그래서 kinds Set 에는 gap 이 들어가지 않는다.
type BlockKind = Exclude<RowType, 'gap'>;
const rowInfo: {
maxShare: number;
kinds: Set<BlockKind>;
occupiedCount: number;
}[] = [];
for (let i = 0; i < rowCount; i++) {
rowInfo.push({ maxShare: 0, kinds: new Set<BlockKind>(), occupiedCount: 0 });
}
for (const bl of layout.blocks) {
if (bl.mode !== 'grid') continue;
const b = byId.get(bl.blockId);
if (!b) continue;
const rs = (bl.rowStart ?? 1) - 1;
const re = (bl.rowEnd ?? 1) - 1;
const span = re - rs;
if (span <= 0) continue;
const designH = Math.max(0, b.hPct * baseHeight);
const share = designH / span;
let kind: BlockKind;
if (b.componentId && elasticIds.has(b.componentId)) kind = 'elastic';
else if (
(b.role && rigidRoles.has(b.role)) ||
(b.componentId && rigidIds.has(b.componentId))
)
kind = 'rigid';
else kind = 'semi-rigid';
for (let i = rs; i < re; i++) {
if (i < 0 || i >= rowCount) continue;
if (share > rowInfo[i].maxShare) rowInfo[i].maxShare = share;
rowInfo[i].kinds.add(kind);
rowInfo[i].occupiedCount++;
}
}
const plans: RowPlan[] = [];
for (let i = 0; i < rowCount; i++) {
const { maxShare, kinds, occupiedCount } = rowInfo[i];
let type: RowType;
if (occupiedCount === 0 || kinds.size === 0) type = 'gap';
else if (kinds.has('elastic')) type = 'elastic';
else if (kinds.has('semi-rigid')) type = 'semi-rigid';
else type = 'rigid';
let floorPx: number;
let preferredPx: number;
if (type === 'gap') {
floorPx = gapFloor;
// gap 의 preferred 는 디자인 원본 yLines 간격 (= weight * baseHeight).
// 블록이 없으니 maxShare 는 0 이다.
const designH = Math.max(0, layout.rows[i].weight * baseHeight);
preferredPx = Math.max(floorPx, Math.round(designH));
} else {
floorPx =
type === 'rigid'
? rigidFloor
: type === 'semi-rigid'
? semiFloor
: elasticFloor;
preferredPx = Math.max(floorPx, Math.round(maxShare));
}
plans.push({ type, preferredPx, floorPx, occupiedBlockCount: occupiedCount });
}
return plans;
}
/**
* preferred row heights 를 availableHeight 에 정확히 맞도록 조정.
*
* total === available : 그대로
* total < available : surplus 를 elastic 우선 비율 분배 (없으면 semi-rigid,
* 그것도 없으면 rigid)
* total > available : deficit 를 elastic 먼저 floor 까지 압축 → semi-rigid
* → rigid 순으로. floor 합이 available 을 넘으면 그대로
* 반환 (wrapper hidden 이 clip).
*
* 반환 배열 합은 가능하면 availableHeight 와 일치 (부동소수 오차는 마지막
* row 에 반영).
*/
export function allocateRowHeightsPx(
plans: RowPlan[],
availableHeight: number,
): number[] {
const n = plans.length;
if (n === 0) return [];
if (!Number.isFinite(availableHeight) || availableHeight <= 0) {
return plans.map((p) => p.preferredPx);
}
const final = plans.map((p) => p.preferredPx);
const total = final.reduce((s, v) => s + v, 0);
if (Math.abs(total - availableHeight) < 0.5) {
return final.map((v) => round3(v));
}
const idxByType = (t: RowType) =>
plans.map((p, i) => (p.type === t ? i : -1)).filter((i) => i >= 0);
/** gap 은 preferredPx 의 110% 또는 +8px (큰 쪽) 까지만 늘어날 수 있다. */
const capOf = (i: number): number => {
const p = plans[i];
if (p.type === 'gap') {
return Math.max(p.preferredPx * 1.1, p.preferredPx + 8);
}
return Number.POSITIVE_INFINITY;
};
if (total < availableHeight) {
let surplus = availableHeight - total;
/**
* cap 제약 하에서 비율 분배. cap 에 막힌 row 는 나머지 surplus 를
* 받지 못하고 남은 몫은 호출자가 다음 type 으로 넘긴다.
*/
const distribute = (idxs: number[]): void => {
if (idxs.length === 0 || surplus < 0.5) return;
let active = idxs.slice();
let guard = 0;
while (active.length > 0 && surplus > 0.5 && guard++ < 8) {
const sumP = active.reduce((s, i) => s + final[i], 0);
const isUniform = sumP === 0;
const next: number[] = [];
let given = 0;
for (const i of active) {
const want = isUniform
? surplus / active.length
: (final[i] / sumP) * surplus;
const headroom = Math.max(0, capOf(i) - final[i]);
const take = Math.min(want, headroom);
if (take > 0) {
final[i] += take;
given += take;
}
if (final[i] + 0.5 < capOf(i)) next.push(i);
}
surplus -= given;
if (next.length === active.length && given < 0.5) break; // 정체 — 탈출
active = next;
}
};
// 우선순위: elastic → semi-rigid → rigid → gap (gap 은 cap 으로 최소한만)
distribute(idxByType('elastic'));
distribute(idxByType('semi-rigid'));
distribute(idxByType('rigid'));
distribute(idxByType('gap'));
} else {
let deficit = total - availableHeight;
const floorOf = (i: number) => plans[i].floorPx;
const compress = (idxs: number[]) => {
if (deficit < 0.5 || idxs.length === 0) return;
let reducible = 0;
for (const i of idxs) reducible += Math.max(0, final[i] - floorOf(i));
if (reducible <= 0) return;
const take = Math.min(deficit, reducible);
const ratio = take / reducible;
let absorbed = 0;
for (const i of idxs) {
const slack = Math.max(0, final[i] - floorOf(i));
const reduce = slack * ratio;
final[i] -= reduce;
absorbed += reduce;
}
deficit -= absorbed;
};
// 우선순위: gap → elastic → semi-rigid → rigid
compress(idxByType('gap'));
compress(idxByType('elastic'));
compress(idxByType('semi-rigid'));
compress(idxByType('rigid'));
}
const diff = availableHeight - final.reduce((s, v) => s + v, 0);
if (Math.abs(diff) > 0.5 && final.length > 0) {
final[final.length - 1] += diff;
}
return final.map((v) => Math.max(0, round3(v)));
}
function formatFr(w: number): string {
// fr 가중치는 0 초과. 너무 작은 값은 0.0001 로 clamp.
const v = Math.max(0.0001, w);
// 소수점 6자리로 고정, trailing zero 는 유지 (문자열 안정성).
return v.toFixed(6);
}
@@ -129,10 +129,22 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
const sizeStyle = SIZE_PRESETS[sizeKey] ?? SIZE_PRESETS.md;
// 디자인 모드에서는 wrapper 박스 크기(리사이즈한 값)를 그대로 채워야
// 디자이너 캔버스에서 박스 크기 = 시각 크기가 된다. 런타임(대시보드 카드)
// 에서는 content size(sizePreset) 로 렌더되어야 wrapper 가 박스를 키워놨
// 어도 버튼이 과도하게 팽창하지 않는다.
// 디자이너 캔버스에서 박스 크기 = 시각 크기가 된다.
//
// 런타임에서는 두 경로가 있다:
// (A) component.size.{width,height} 가 유효한 px 로 넘어온 경우 (line 엔진)
// → design px (예: 120x40) 를 root 에 직접 적용. 버튼이 wrapper cell
// 전체로 stretch 되지 않고 디자이너가 준 크기 그대로 렌더.
// (B) 그 외 (기존 band 경로 등)
// → content size (sizePreset 기반). 기존 회귀 방지용 기본 동작.
const fillWrapper = isDesignMode === true;
const rawSize = (component as any)?.size;
const sizeW =
typeof rawSize?.width === "number" && rawSize.width > 0 ? rawSize.width : 0;
const sizeH =
typeof rawSize?.height === "number" && rawSize.height > 0 ? rawSize.height : 0;
const useDesignPx = !fillWrapper && sizeW > 0 && sizeH > 0;
const buttonStyle: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
@@ -154,6 +166,15 @@ export const ButtonComponent: React.FC<ButtonComponentProps> = ({
...(fillWrapper ? { width: "100%", height: "100%" } : {}),
...(component as any).style,
...style,
// design px 는 외부 style 보다 뒤에 spread 해서 최종 승자가 되게 한다
...(useDesignPx
? {
width: `${sizeW}px`,
height: `${sizeH}px`,
minHeight: `${sizeH}px`,
flex: "0 0 auto",
}
: {}),
};
if (isDesignMode && isSelected) {
@@ -0,0 +1,773 @@
# 가변 선 기반 레이아웃 시스템 설계 (v2, 2026-04-20)
> `band` 기반 TemplateRenderer 를 좌표 기반 variable line grid 로 전환하기 위
> 한 설계. 편집 모델은 자유배치(`xPct/yPct/wPct/hPct`) 유지. 렌더 시 블록
> 좌표에서 세로선/가로선을 뽑아내고, 그 사이 간격이 화면마다 다르게 형성되는
> variable track grid 를 구성한다. 편집기/미리보기/런타임이 동일
> `computeLayout()` 을 호출한다.
>
> 관련 파일 (기존):
> - `frontend/components/dash/TemplateRenderer.tsx`
> - `frontend/lib/utils/templateMigrate.ts`
> - `frontend/types/invyone-component.ts` (§7)
> - `frontend/components/dash/TemplateResponsivePreview.tsx`
> - `frontend/components/dash/DashboardCard.tsx`
>
> 신규:
> - `frontend/lib/layout/lineLayout.ts`
---
## 1. 설계 요약 (개정판)
### 1.1 원칙
1. **좌표가 진실**. role 은 semantic marker 로만 남고 레이아웃 계산에 개입하
지 않는다. `main/companion/action` 은 grid 상 동일 취급. `overlay`
absolute 강제.
2. **variable line grid**. 블록 경계선으로부터 xLines/yLines 를 추출하고, 각
트랙 폭은 **인접 선 간 거리 비율** 에 비례한다 (`minmax(0, <ratio>fr)`).
uniform grid (12칸/균일폭) 으로 후퇴하지 않는다.
3. **x / y tolerance 분리**. 세로축과 가로축은 서로 다른 밀도를 가질 수
있으므로 baseXTolerance / baseYTolerance 를 각각 관리한다.
4. **0 과 1 경계 항상 포함**. wrapper 의 좌단/상단/우단/하단은 반드시 트랙
경계로 보장한다.
5. **과분할 대응 — 점진 tolerance 증가 + thin track merge**. 기본 tol 은
작게 두고, 선 개수가 과다하면 `tolerance *= 1.35` 로 올리며 재추출. 결과
에서도 폭 비율이 너무 얇은 트랙은 인접 트랙으로 병합.
6. **overlay fallback 엄격화**. grid 에 안전하게 들어갈 수 없는 블록만
overlay 로 도피. 남발 금지.
7. **edit ↔ runtime parity**. `computeLayout()` 은 순수 함수로 lib 에 분리
되어 편집 미리보기·반응 미리보기·DashboardCard 런타임에서 동일 호출.
8. **feature flag 점진 전환**. band 와 line 을 한동안 병행, 롤백 가능.
### 1.2 파라미터 상수 (최종)
```ts
export const LAYOUT_CONSTANTS = {
BASE_X_TOLERANCE: 0.004, // 0.4% — 1920 캔버스에서 ~8px
BASE_Y_TOLERANCE: 0.004, // 0.4% — 1080 캔버스에서 ~4px
MAX_TOLERANCE: 0.02, // 2% — 과분할 재병합 상한
TOLERANCE_GROWTH: 1.35, // 1회 증가폭
THIN_TRACK_THRESHOLD: 0.0025, // 0.25% 미만 트랙은 병합 후보
OVERLAP_THRESHOLD: 0.15, // 작은 블록 면적 대비 15% 이상 겹치면 overlay
SNAP_ERROR_MULT: 2, // snap 오차 > tolerance * 2 면 overlay
};
```
### 1.3 현재 구조의 문제 재확인
- `TemplateRenderer#buildBands` 가 role+bandId 로 재조립 → 좌표 의미 왜곡.
- `.itpl-band-action``justify-content:flex-end` → 디자이너 좌표 무시.
- `templateMigrate#normalizeRoles` Stage 2 (button/input role 재분류) 가 매
로드마다 좌표 기반 재분류 → **사용자가 v2 에 저장한 role 이 덮어써진다**.
(templateMigrate.ts:359-399)
- `bandId` fallback 이 직전 main band 강제 합류 → 공간적으로 먼 action/
companion 이 틀린 band 에 붙는다.
이 모든 것은 "좌표 있는데 좌표 버리고 semantic band 쓴다" 는 구조적 오류에서
비롯. 좌표 하나로 단일화가 필요하다.
---
## 2. lineLayout.ts 핵심 타입 / 함수 시그니처
```ts
// frontend/lib/layout/lineLayout.ts
// ── 입출력 타입 ─────────────────────────────────────────────────────────
/** 레이아웃 계산 입력 — BlockV2 의 최소 subset */
export interface LayoutBlockInput {
id: string;
xPct: number; yPct: number;
wPct: number; hPct: number;
/** 좌표 무관 absolute 강제. BlockV2.role==='overlay' 매핑용 */
forceOverlay?: boolean;
/** z-order hint (생략 시 입력 순서) */
z?: number;
}
/** 레이아웃 옵션 (상수 오버라이드용, 대부분 기본값 사용) */
export interface LayoutOptions {
baseXTolerance?: number; // default 0.004
baseYTolerance?: number; // default 0.004
maxTolerance?: number; // default 0.02
toleranceGrowth?: number; // default 1.35
thinTrackThreshold?: number; // default 0.0025
overlapThreshold?: number; // default 0.15
snapErrorMult?: number; // default 2
/** 세로축 사이징 — 'fr'(비율) vs 'auto'(content-size) */
rowSizing?: 'fr' | 'auto'; // default 'fr'
}
/** 트랙 하나 = 인접 두 선 사이의 간격 */
export interface GridTrack {
index: number; // 0-based
startPct: number; // 0~1
endPct: number; // 0~1
/** 전체 대비 폭 비율(= endPct - startPct). fr weight 로 쓴다 */
weight: number;
}
/** 개별 블록의 레이아웃 결과 */
export interface BlockLayout {
blockId: string;
mode: 'grid' | 'overlay';
// grid 모드 전용 (1-based span)
colStart?: number; colEnd?: number;
rowStart?: number; rowEnd?: number;
// overlay 모드 전용 (원본 좌표)
leftPct?: number; topPct?: number; wPct?: number; hPct?: number;
// 디버그 필드
snapError?: number;
overlayReason?: 'forced' | 'overlap' | 'invalid-span' | 'snap-error';
}
/** 최종 레이아웃 결과 */
export interface LayoutResult {
xLines: number[]; // 정렬된 x 경계선, 첫=0 끝=1
yLines: number[]; // 정렬된 y 경계선, 첫=0 끝=1
cols: GridTrack[];
rows: GridTrack[];
blocks: BlockLayout[]; // 입력 순서 보존
xToleranceUsed: number;
yToleranceUsed: number;
}
// ── 공개 함수 ────────────────────────────────────────────────────────────
/** 메인 — blocks 에서 variable line grid 를 계산 */
export function computeLayout(
blocks: LayoutBlockInput[],
opts?: LayoutOptions,
): LayoutResult;
/** grid-template-columns/rows 문자열 생성 헬퍼 */
export function toTrackTemplate(
tracks: GridTrack[],
mode: 'fr' | 'auto',
): string;
// ── 내부 헬퍼 (export 해서 디버깅/테스트 가능) ──────────────────────────
/** 값들을 tolerance 로 클러스터링해 경계선 배열 생성. 0 과 1 포함. */
export function extractLines(values: number[], tolerance: number): number[];
/** thin track(< threshold) 을 인접 트랙과 병합. 0/1 경계는 유지. */
export function mergeThinTracks(lines: number[], threshold: number): number[];
/** 블록 수 기준 target 선 개수를 넘지 않게 tolerance 를 점진 증가시켜 재추출 */
export function fitLines(
values: number[],
blockCount: number,
baseTol: number,
maxTol: number,
growth: number,
thinThreshold: number,
): { lines: number[]; tolerance: number };
/** 선 배열에서 value 에 가장 가까운 인덱스 + 오차 */
export function nearestLine(value: number, lines: number[]): { idx: number; err: number };
```
---
## 3. 알고리즘
### 3.1 선 추출
```ts
export function extractLines(values: number[], tol: number): number[] {
const sorted = [...values].filter((v) => v >= 0 && v <= 1).sort((a, b) => a - b);
const clusters: number[] = [];
let bucket: number[] = [];
for (const v of sorted) {
if (bucket.length === 0 || v - bucket[bucket.length - 1] <= tol) {
bucket.push(v);
} else {
clusters.push(bucket.reduce((s, x) => s + x, 0) / bucket.length);
bucket = [v];
}
}
if (bucket.length) {
clusters.push(bucket.reduce((s, x) => s + x, 0) / bucket.length);
}
// 0 / 1 경계 강제 포함: tolerance 내 clusters 는 0/1 로 흡수
const lines: number[] = [0];
for (const c of clusters) {
if (c <= tol) continue; // 0 에 흡수됨
if (c >= 1 - tol) continue; // 1 에 흡수됨 (아래에서 push)
lines.push(c);
}
lines.push(1);
return lines;
}
```
### 3.2 Thin track 병합 (0.25%)
인접 선 사이 거리가 `thinTrackThreshold` 미만이면 내부 선 중 하나를 제거하
거나 평균으로 병합한다. 양 끝(0, 1) 은 보존.
```ts
export function mergeThinTracks(lines: number[], threshold: number): number[] {
if (lines.length <= 2) return [0, 1];
const cur = [...lines];
let changed = true;
// O(N^2) 이지만 선 개수는 수십개라 문제 없음
while (changed && cur.length > 2) {
changed = false;
for (let i = 0; i < cur.length - 1; i++) {
const gap = cur[i + 1] - cur[i];
if (gap >= threshold) continue;
if (i === 0) {
// 첫 트랙이 얇음 → 두 번째 선(인덱스 1) 제거
cur.splice(1, 1);
} else if (i === cur.length - 2) {
// 마지막 트랙이 얇음 → 뒤에서 두 번째 선 제거
cur.splice(cur.length - 2, 1);
} else {
// 내부: 두 선을 평균으로 합치고 한 개 제거
cur[i] = (cur[i] + cur[i + 1]) / 2;
cur.splice(i + 1, 1);
}
changed = true;
break;
}
}
cur[0] = 0;
cur[cur.length - 1] = 1;
return cur;
}
```
### 3.3 점진 tolerance 증가 (과분할 대응)
```ts
export function fitLines(
values: number[],
blockCount: number,
baseTol: number,
maxTol: number,
growth: number,
thinThreshold: number,
) {
const target = Math.max(4, blockCount * 2 + 2);
let tol = baseTol;
let lines = mergeThinTracks(extractLines(values, tol), thinThreshold);
while (lines.length > target && tol < maxTol) {
tol = Math.min(maxTol, tol * growth);
lines = mergeThinTracks(extractLines(values, tol), thinThreshold);
}
return { lines, tolerance: tol };
}
```
### 3.4 span 매핑
```ts
export function nearestLine(v: number, lines: number[]) {
let best = 0, bestErr = Infinity;
for (let i = 0; i < lines.length; i++) {
const e = Math.abs(lines[i] - v);
if (e < bestErr) { best = i; bestErr = e; }
}
return { idx: best, err: bestErr };
}
```
각 블록에 대해:
```
l = nearestLine(xPct, xLines)
r = nearestLine(xPct + wPct, xLines)
t = nearestLine(yPct, yLines)
btm= nearestLine(yPct + hPct, yLines)
snapError = max(l.err, r.err, t.err, btm.err)
colStart = l.idx + 1 // grid 1-based
colEnd = r.idx + 1
rowStart = t.idx + 1
rowEnd = btm.idx + 1
```
### 3.5 Overlay 판정 (엄격)
판정 순서 (먼저 hit 하는 것을 이유로 기록):
1. `forceOverlay === true``overlayReason='forced'`.
2. `snapError > effectiveTolerance * 2``overlayReason='snap-error'`.
- `effectiveTolerance = max(xToleranceUsed, yToleranceUsed)`
3. `colEnd <= colStart` 또는 `rowEnd <= rowStart``overlayReason='invalid-span'`.
4. 실제 bbox 교집합 면적이 두 블록 중 작은 블록 면적 대비 15% 이상 →
`overlayReason='overlap'`. z 값이 크거나 뒤에 들어온 블록을 overlay 로.
4번은 이미 grid 에 배치 확정된 블록들 사이에서만 계산 (overlay 확정된 블록
간의 겹침은 z-index 로 해결되므로 제외).
### 3.6 Track template 생성
```ts
export function toTrackTemplate(
tracks: GridTrack[],
mode: 'fr' | 'auto',
): string {
if (mode === 'auto') return tracks.map(() => 'auto').join(' ');
return tracks
.map((t) => `minmax(0, ${t.weight.toFixed(6)}fr)`)
.join(' ');
}
```
`minmax(0, <w>fr)` 는 트랙이 content 때문에 자랄 수는 있어도 최소값 0 까지
줄어들 수 있도록 허용 — 좁은 카드에서 테이블/입력이 wrapper 를 밀어내는 문
제 차단.
### 3.7 메인 함수 (의사코드)
```ts
export function computeLayout(
blocks: LayoutBlockInput[],
opts: LayoutOptions = {},
): LayoutResult {
const baseXTol = opts.baseXTolerance ?? 0.004;
const baseYTol = opts.baseYTolerance ?? 0.004;
const maxTol = opts.maxTolerance ?? 0.02;
const growth = opts.toleranceGrowth ?? 1.35;
const thinTh = opts.thinTrackThreshold ?? 0.0025;
const overlapTh = opts.overlapThreshold ?? 0.15;
const snapMult = opts.snapErrorMult ?? 2;
const rowSizing = opts.rowSizing ?? 'fr';
// 1. 명시적 overlay 분리
const forced = blocks.filter((b) => b.forceOverlay);
const candidates = blocks.filter((b) => !b.forceOverlay);
// 2. 경계값 수집 (grid 후보만)
const xs: number[] = [];
const ys: number[] = [];
for (const b of candidates) {
xs.push(b.xPct, b.xPct + b.wPct);
ys.push(b.yPct, b.yPct + b.hPct);
}
// 3. x/y 각각 선 추출 + thin merge + 과분할 재병합
const fx = fitLines(xs, candidates.length, baseXTol, maxTol, growth, thinTh);
const fy = fitLines(ys, candidates.length, baseYTol, maxTol, growth, thinTh);
const xLines = fx.lines, yLines = fy.lines;
const tolX = fx.tolerance, tolY = fy.tolerance;
const cols = toTracks(xLines);
const rows = toTracks(yLines);
// 4. span 매핑 + snap error 판정
const placed: BlockLayout[] = [];
const gridOk: { b: LayoutBlockInput; layout: BlockLayout }[] = [];
const effTol = Math.max(tolX, tolY);
for (const b of candidates) {
const l = nearestLine(b.xPct, xLines);
const r = nearestLine(b.xPct + b.wPct, xLines);
const t = nearestLine(b.yPct, yLines);
const bt = nearestLine(b.yPct + b.hPct, yLines);
const snapError = Math.max(l.err, r.err, t.err, bt.err);
const colStart = l.idx + 1, colEnd = r.idx + 1;
const rowStart = t.idx + 1, rowEnd = bt.idx + 1;
if (snapError > effTol * snapMult) {
placed.push(makeOverlay(b, snapError, 'snap-error'));
continue;
}
if (colEnd <= colStart || rowEnd <= rowStart) {
placed.push(makeOverlay(b, snapError, 'invalid-span'));
continue;
}
const layout: BlockLayout = {
blockId: b.id, mode: 'grid',
colStart, colEnd, rowStart, rowEnd, snapError,
};
placed.push(layout);
gridOk.push({ b, layout });
}
// 5. overlap 판정 — 이미 grid 확정된 블록 쌍에 한정
for (let i = 0; i < gridOk.length; i++) {
for (let j = i + 1; j < gridOk.length; j++) {
const a = gridOk[i].b, c = gridOk[j].b;
if (overlapRatio(a, c) >= overlapTh) {
// z 큰쪽 또는 뒤에 들어온쪽을 overlay 로 강등
const target = pickOverlayTarget(a, c, gridOk[i].layout, gridOk[j].layout);
demoteToOverlay(target, 'overlap');
}
}
}
// 6. 명시 overlay 는 항상 absolute 로 덧붙임
for (const b of forced) {
placed.push(makeOverlay(b, 0, 'forced'));
}
return {
xLines, yLines, cols, rows,
blocks: placed,
xToleranceUsed: tolX,
yToleranceUsed: tolY,
};
}
```
`makeOverlay`, `overlapRatio`, `toTracks` 는 §8 의 실제 구현에 정의.
---
## 4. 테스트 케이스 목록
`frontend/lib/layout/lineLayout.test.ts` 로 jest 테스트 작성. 최소 커버:
| # | 이름 | 입력 시나리오 | 기대 결과 |
|---|---|---|---|
| T1 | 2×2 균일 | 4개 블록 (0.5 폭·높이로 분할) | xLines=[0,0.5,1], yLines=[0,0.5,1], 모두 grid, snapError≈0 |
| T2 | 3열 1행 | wPct=1/3 씩 3개 | xLines=[0,0.333,0.667,1], rows=[0,1], 모두 grid |
| T3 | 세로 스택 5개 | wPct=1, hPct=0.2 씩 | xLines=[0,1], yLines=[0,0.2,0.4,0.6,0.8,1], 모두 grid |
| T4 | 0.3% 오차 병합 | 같은 열 2개 블록의 xPct 가 0.3 vs 0.303 | baseXTol=0.004 로 같은 선으로 병합, 과분할 없음 |
| T5 | 경계 흡수 | 블록 xPct=0.002 (≤ tol) | xLines 에 0.002 없음, 0 에 흡수됨 |
| T6 | 명시 overlay | role='overlay' 1개 + 일반 2개 | overlay 블록은 mode='overlay', overlayReason='forced', grid cols/rows 에 영향 없음 |
| T7 | 겹침 overlay | 50% 교차하는 두 블록 | 뒤 블록 mode='overlay', overlayReason='overlap' |
| T8 | 15% 경계 테스트 | 10% 겹침 2개 / 20% 겹침 2개 | 10% → 둘 다 grid, 20% → 뒤 블록 overlay |
| T9 | invalid span | wPct=0 블록 | overlay, overlayReason='invalid-span' |
| T10 | 얇은 트랙 병합 | 블록들이 0.1% 간격으로 거의 같은 선 | mergeThinTracks 로 병합되어 트랙 1개 |
| T11 | 과분할 재병합 | 블록 3개인데 경계 10개 (0.001 간격) | target=8 초과 → tolerance 1.35 배 증가 재추출 |
| T12 | snap error 초과 | tol 확정 후 블록 경계가 어느 선과도 멀어 err > tol*2 | mode='overlay', overlayReason='snap-error' |
| T13 | rowSizing='auto' | 일반 블록 2개 | rows template 에 `auto auto` 출력 |
| T14 | 경계 0,1 포함 | 블록 좌표가 중앙에만 있어도 | xLines[0]===0, xLines.at(-1)===1 |
| T15 | fr weight | xLines=[0,0.3,1] → cols.weight = [0.3, 0.7] | toTrackTemplate 출력이 `minmax(0, 0.3fr) minmax(0, 0.7fr)` |
---
## 5. debug=lines 시각화
활성 조건:
- URL 쿼리 `?debug=lines` 또는 `?debug=1`
- `localStorage.INVYONE_DEBUG_LINES = '1'`
표시:
- wrapper 에 `.itpl-dbg` 클래스 추가.
- `xLines.length` 개의 `<div className="itpl-line-v">` 를 wrapper 에 absolute
렌더, `left: ${x*100}%`.
- `yLines.length` 개의 `<div className="itpl-line-h">``top: ${y*100}%`.
- 각 grid cell (블록 slot) 에 `outline: 1px dashed rgba(108,92,231,.35)`.
- 블록 wrapper 에 `data-col="${colStart}/${colEnd}"`,
`data-row="${rowStart}/${rowEnd}"`, `data-snap="${snapError.toFixed(4)}"`.
- overlay 블록에 `data-overlay-reason="${overlayReason}"`.
- 마운트 시 `console.groupCollapsed('[layout] xLines/yLines/cols/rows/blocks')`
로 구조화된 dump.
CSS:
```css
.itpl-dbg { outline: 1px solid rgba(108,92,231,.25); }
.itpl-dbg .itpl-line-v,
.itpl-dbg .itpl-line-h {
position: absolute; z-index: 9999; pointer-events: none;
background: rgba(0,206,201,.4);
}
.itpl-dbg .itpl-line-v { top: 0; bottom: 0; width: 1px; }
.itpl-dbg .itpl-line-h { left: 0; right: 0; height: 1px; }
.itpl-dbg .itpl-slot { outline: 1px dashed rgba(108,92,231,.35); }
.itpl-dbg .itpl-overlay{ outline: 1px dashed rgba(253,121,168,.55); }
```
---
## 6. TemplateRenderer 교체 패치 초안
핵심 변경:
- `buildBands`, `.itpl-band*` CSS, `BlockSlot/OverlaySlot``leftGapPct`·
`itpl-band-action/main` 구조 제거.
- `computeLayout()` 으로 layout 생성. grid / overlay 분리 렌더.
- feature flag (`useLineEngine`) 로 기존 band 렌더와 병행 운용.
```tsx
// frontend/components/dash/TemplateRenderer.tsx (발췌 — line 엔진 부분)
import { computeLayout, toTrackTemplate } from '@/lib/layout/lineLayout';
function useLineEngineFlag(): boolean {
if (typeof window === 'undefined') {
return process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line';
}
const qs = new URLSearchParams(window.location.search);
const q = qs.get('layout');
if (q === 'line' || q === 'band') return q === 'line';
const ls = window.localStorage?.getItem('INVYONE_LAYOUT');
if (ls === 'line' || ls === 'band') return ls === 'line';
return process.env.NEXT_PUBLIC_LAYOUT_ENGINE === 'line';
}
function useDebugLinesFlag(): boolean {
if (typeof window === 'undefined') return false;
const qs = new URLSearchParams(window.location.search);
if (qs.get('debug') === 'lines' || qs.has('debug')) return true;
return window.localStorage?.getItem('INVYONE_DEBUG_LINES') === '1';
}
function LineGridView({
blocks, context, view, aspectPolicy,
}: {
blocks: BlockV2[];
context: TemplateRenderContext;
view: ViewKey;
aspectPolicy: 'preserve' | 'free';
}) {
const layout = useMemo(() => computeLayout(
blocks.map((b) => ({
id: b.id, xPct: b.xPct, yPct: b.yPct, wPct: b.wPct, hPct: b.hPct,
forceOverlay: b.role === 'overlay',
})),
{ rowSizing: aspectPolicy === 'preserve' ? 'fr' : 'auto' },
), [blocks, aspectPolicy]);
const debug = useDebugLinesFlag();
const gridTemplateColumns = toTrackTemplate(layout.cols, 'fr');
const gridTemplateRows = toTrackTemplate(layout.rows,
aspectPolicy === 'preserve' ? 'fr' : 'auto');
const byId = new Map(blocks.map((b) => [b.id, b] as const));
if (debug && typeof window !== 'undefined') {
// eslint-disable-next-line no-console
console.groupCollapsed(
'%c[layout/line]',
'color:#00cec9;font-weight:bold',
`blocks=${blocks.length} cols=${layout.cols.length} rows=${layout.rows.length}`,
);
console.table(layout.blocks.map((bl) => ({
id: bl.blockId, mode: bl.mode,
col: bl.mode === 'grid' ? `${bl.colStart}/${bl.colEnd}` : '',
row: bl.mode === 'grid' ? `${bl.rowStart}/${bl.rowEnd}` : '',
reason: bl.overlayReason ?? '',
snapErr: bl.snapError?.toFixed(4) ?? '',
})));
console.log('xLines', layout.xLines);
console.log('yLines', layout.yLines);
console.log('tol x/y', layout.xToleranceUsed, layout.yToleranceUsed);
console.groupEnd();
}
return (
<div className={`itpl-wrapper itpl-line ${debug ? 'itpl-dbg' : ''}`}>
<style>{LINE_CSS}</style>
<div className="itpl-grid" style={{ gridTemplateColumns, gridTemplateRows }}>
{layout.blocks.map((bl) => {
const block = byId.get(bl.blockId);
if (!block) return null;
if (bl.mode === 'grid') {
return (
<div
key={block.id}
className={`itpl-slot role-${block.role} policy-${block.responsivePolicy}`}
data-col={`${bl.colStart}/${bl.colEnd}`}
data-row={`${bl.rowStart}/${bl.rowEnd}`}
data-snap={bl.snapError?.toFixed(4)}
style={{
gridColumn: `${bl.colStart} / ${bl.colEnd}`,
gridRow: `${bl.rowStart} / ${bl.rowEnd}`,
}}
>
<BlockRenderer block={block} context={context} view={view} />
</div>
);
}
return (
<div
key={block.id}
className={`itpl-overlay role-${block.role} policy-${block.responsivePolicy}`}
data-overlay-reason={bl.overlayReason}
style={{
left: `${bl.leftPct! * 100}%`,
top: `${bl.topPct! * 100}%`,
width: `${bl.wPct! * 100}%`,
height: `${bl.hPct! * 100}%`,
}}
>
<BlockRenderer block={block} context={context} view={view} />
</div>
);
})}
{debug && layout.xLines.map((x, i) => (
<div key={`vx-${i}`} className="itpl-line-v" style={{ left: `${x * 100}%` }} />
))}
{debug && layout.yLines.map((y, i) => (
<div key={`hy-${i}`} className="itpl-line-h" style={{ top: `${y * 100}%` }} />
))}
</div>
</div>
);
}
const LINE_CSS = `
.itpl-wrapper.itpl-line {
position: relative; width: 100%; height: 100%;
container-type: inline-size; container-name: tpl;
box-sizing: border-box; overflow: hidden;
}
.itpl-line .itpl-grid {
position: relative; display: grid; width: 100%; height: 100%;
box-sizing: border-box;
}
.itpl-line .itpl-slot { box-sizing: border-box; min-width: 0; min-height: 0; overflow: hidden; }
.itpl-line .itpl-overlay{ position: absolute; z-index: 2; box-sizing: border-box; }
.itpl-line .role-action { display: flex; align-items: center; }
.itpl-dbg .itpl-line-v,
.itpl-dbg .itpl-line-h { position: absolute; z-index: 9999; pointer-events: none; background: rgba(0,206,201,.45); }
.itpl-dbg .itpl-line-v { top: 0; bottom: 0; width: 1px; }
.itpl-dbg .itpl-line-h { left: 0; right: 0; height: 1px; }
.itpl-dbg .itpl-slot { outline: 1px dashed rgba(108,92,231,.35); }
.itpl-dbg .itpl-overlay { outline: 1px dashed rgba(253,121,168,.55); }
`;
```
기존 TemplateRenderer 의 최상위 return 부를 다음처럼 분기:
```tsx
const useLine = useLineEngineFlag();
// ...
if (useLine) {
return <LineGridView
blocks={blocks}
context={context}
view={view}
aspectPolicy={(v2Views.canvas?.aspectPolicy ?? 'preserve')}
/>;
}
// 기존 band 렌더 (1차 이행 중 동일 출력 보장용)
return (<div className="itpl-wrapper">...</div>);
```
2~3주 안정화 후 else 블록과 `buildBands` 관련 코드 삭제.
---
## 7. templateMigrate.ts — normalizeRoles Stage 2 제거 패치
Stage 2 (coord 기반 button/input role 재분류) 완전 삭제. Stage 1 (legacy
componentId 통합 + policy 보정) 만 유지. `bandId` 는 읽을 때 건드리지 않고,
새로 쓸 때는 기록하지 않는 방향이지만, 1차 패치에선 제거하지 않고 그대로 둠
(구버전 데이터 호환 보전).
```ts
// frontend/lib/utils/templateMigrate.ts (normalizeRoles 교체)
// (COORD_DEPENDENT_ROLES 상수는 삭제)
function normalizeRoles(v: TemplateViewsV2): TemplateViewsV2 {
const patchView = (view: ViewV2 | undefined): ViewV2 | undefined => {
if (!view) return view;
// componentId / responsivePolicy 보정만 — role 은 디자이너 의도 그대로 보존.
const next: BlockV2[] = view.blocks.map((b) => {
const unified = LEGACY_TO_UNIFIED[b.componentId] ?? b.componentId;
const nextPolicy = inferPolicy(unified);
const idChanged = unified !== b.componentId;
const policyChanged = b.responsivePolicy !== nextPolicy;
if (!idChanged && !policyChanged) return b;
return { ...b, componentId: unified, responsivePolicy: nextPolicy };
});
return { ...view, blocks: next };
};
return {
...v,
list: patchView(v.list)!,
...(v.create ? { create: patchView(v.create)! } : {}),
...(v.edit ? { edit: patchView(v.edit)! } : {}),
};
}
```
`bandId` 는 이 함수에서 건드리지 않고 그대로 통과. computeLayout 은 BlockV2
`bandId` 필드를 **읽지도 않음** → 레이아웃 계산과 완전 무관.
Optional: 저장(save) 경로에서 새 포맷을 쓸 때는 `bandId` 를 기록하지 않도록
정리할 수 있으나 이번 패치에서는 호환만 유지.
---
## 8. Feature flag 기반 점진 전환안
우선순위:
1. `?layout=line` URL 쿼리 — 개발/디자인 QA 용.
2. `localStorage.INVYONE_LAYOUT === 'line'` — 고정 전환.
3. `NEXT_PUBLIC_LAYOUT_ENGINE === 'line'` — 배포 기본값.
전환 일정 (제안):
| Phase | 기간 | 기본값 | 설명 |
|---|---|---|---|
| P1 | Day 0~3 | `band` | line 은 URL 쿼리/localStorage 로만 활성. QA/디자인팀 확인 |
| P2 | Day 4~10 | `line` (env) | 배포 기본을 line 으로. `?layout=band` 로 롤백 가능 |
| P3 | Day 11~ | `line` 고정 | band 코드/CSS 삭제. `normalizeRoles` Stage 2 제거 확정. bandId 저장 중단 |
롤백:
- 문제 발생 시 `?layout=band` 또는 env 변경으로 즉시 복귀.
- line 엔진은 순수 함수라 roll-forward 패치(컴포넌트 격리 버그 등)가 쉬움.
---
## 9. 구현 순서
1. **Step 1 — lineLayout.ts + 테스트** (독립 모듈)
- `frontend/lib/layout/lineLayout.ts` 신규.
- `frontend/lib/layout/lineLayout.test.ts` jest 테스트 (T1~T15).
- 런타임 렌더와 완전 분리되어 안전하게 병합 가능.
2. **Step 2 — TemplateRenderer 에 feature flag 로 연결**
- `LineGridView` 추가 + `useLineEngineFlag()`.
- 기본값 `band`, QA 수동 전환.
3. **Step 3 — debug=lines 시각화**
- 위 §5 CSS + line div 렌더. console dump.
4. **Step 4 — preview / runtime parity 확인**
- `TemplateResponsivePreview``DashboardCard` 모두 `TemplateRenderer`
경유이므로 자동 parity. 추가로 ScreenDesigner 미리보기에 LineGridView
를 read-only 로 심어 편집–렌더 일치 확인.
5. **Step 5 — normalizeRoles Stage 2 제거**
- §7 패치 적용. 사용자 role 보존 테스트.
6. **Step 6 — band 제거 준비**
- 기본 flag 를 line 으로 전환 (P2).
- 1~2주 모니터링 후 `buildBands`, `.itpl-band*`, BlockSlot (기존),
OverlaySlot (기존), band-line CSS 전부 삭제.
- bandId 저장 중단 (templateAdapter 등 저장 경로 확인 필요).
---
## 10. 결정 포인트
- **aspectPolicy='free'** (세로 auto) 일 때 rowSizing='auto'. grid cell 내부
`overflow: hidden` 이 content 세로 확장과 충돌 가능 → `.policy-scroll`
내부 overflow 허용, 나머지는 유지.
- **responsivePolicy 해석** (line 엔진 하의 CSS):
- `fixed` : cell 내부 `min-width:0; min-height:0; width:100%; height:100%;`
- `scroll`: 동일 + 내부 컴포넌트가 `overflow: auto;` 처리
- `reflow`: cell 내부 `display:grid; grid-template-columns: repeat(auto-fit, minmax(...));`
- `wrap` : cell 내부 `display:flex; flex-wrap:wrap;`
(cell 자체의 위치/크기는 좌표 그대로. 내부 레이아웃만 policy 별로 달라짐)
- **action 의 우측 정렬 기본 없음**: 좌표 유지가 원칙. 필요 시
`.role-action``justify-content``flex-end` 로 명시하는 옵트인 클
래스를 컴포넌트 쪽에서 추가.
---
## 11. 완료 기준
- [ ] `lineLayout.ts` + T1~T15 테스트 모두 통과.
- [ ] feature flag ON 상태에서 기존 템플릿 시각 동일성(또는 개선) 확인.
- [ ] `?debug=lines` 로 선/셀/overlay 사유 확인 가능.
- [ ] `TemplateResponsivePreview` 크기 전환 시 열 폭이 디자이너 배치 비율을
유지한 채 자연 축소.
- [ ] `normalizeRoles` 가 사용자 role 을 덮어쓰지 않음 (수동 확인).
- [ ] band 엔진 비활성 + 제거까지 완료.