From 5fc1e7ede2e9d477c8cab8699b73ffe972c5ec59 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 20 Apr 2026 16:51:33 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=9C=A0=EB=B0=B0=EC=B9=98=20->=20?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=EC=84=A0=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=9E=90=EC=9C=A0=ED=98=95=20=EB=B0=98=EC=9D=91?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/dash/TemplateRenderer.tsx | 491 ++++++++++- .../lib/layout/__tests__/lineLayout.test.ts | 313 +++++++ frontend/lib/layout/lineLayout.ts | 733 +++++++++++++++++ .../components/button/ButtonComponent.tsx | 27 +- notes/gbpark/2026-04-20-line-based-layout.md | 773 ++++++++++++++++++ 5 files changed, 2330 insertions(+), 7 deletions(-) create mode 100644 frontend/lib/layout/__tests__/lineLayout.test.ts create mode 100644 frontend/lib/layout/lineLayout.ts create mode 100644 notes/gbpark/2026-04-20-line-based-layout.md diff --git a/frontend/components/dash/TemplateRenderer.tsx b/frontend/components/dash/TemplateRenderer.tsx index ecb3874f..736e9c45 100644 --- a/frontend/components/dash/TemplateRenderer.tsx +++ b/frontend/components/dash/TemplateRenderer.tsx @@ -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( + () => 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 ( + + ); + } + return (
+
+ {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 ( +
+ +
+ ); + } + return ( +
+ +
+ ); + })} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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; + + 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 ( = {}, +): 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); + }); +}); diff --git a/frontend/lib/layout/lineLayout.ts b/frontend/lib/layout/lineLayout.ts new file mode 100644 index 00000000..3b30c81e --- /dev/null +++ b/frontend/lib/layout/lineLayout.ts @@ -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, 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(); + 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, + 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(); + 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; + const rowInfo: { + maxShare: number; + kinds: Set; + occupiedCount: number; + }[] = []; + for (let i = 0; i < rowCount; i++) { + rowInfo.push({ maxShare: 0, kinds: new Set(), 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); +} diff --git a/frontend/lib/registry/components/button/ButtonComponent.tsx b/frontend/lib/registry/components/button/ButtonComponent.tsx index 4a022ed9..112efe7b 100644 --- a/frontend/lib/registry/components/button/ButtonComponent.tsx +++ b/frontend/lib/registry/components/button/ButtonComponent.tsx @@ -129,10 +129,22 @@ export const ButtonComponent: React.FC = ({ 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 = ({ ...(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) { diff --git a/notes/gbpark/2026-04-20-line-based-layout.md b/notes/gbpark/2026-04-20-line-based-layout.md new file mode 100644 index 00000000..5c5553ee --- /dev/null +++ b/notes/gbpark/2026-04-20-line-based-layout.md @@ -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, 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, 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` 개의 `
` 를 wrapper 에 absolute + 렌더, `left: ${x*100}%`. +- `yLines.length` 개의 `
` 를 `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 ( +
+ +
+ {layout.blocks.map((bl) => { + const block = byId.get(bl.blockId); + if (!block) return null; + if (bl.mode === 'grid') { + return ( +
+ +
+ ); + } + return ( +
+ +
+ ); + })} + {debug && layout.xLines.map((x, i) => ( +
+ ))} + {debug && layout.yLines.map((y, i) => ( +
+ ))} +
+
+ ); +} + +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 ; +} +// 기존 band 렌더 (1차 이행 중 동일 출력 보장용) +return (
...
); +``` + +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 엔진 비활성 + 제거까지 완료.