This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 엔진 비활성 + 제거까지 완료.
|
||||
Reference in New Issue
Block a user