Files
invyone/frontend/lib/layout/lineLayout.ts
T
DDD1542 3eda684787
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m22s
사용자 대시보드 기능강화 및 인비온 스튜디오 메뉴관리 자잘한수정
2026-04-22 18:27:06 +09:00

814 lines
30 KiB
TypeScript

/**
* 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,
/** 같은 윗선(row band)으로 볼 bottom 차이 허용치 */
ROW_BAND_TOLERANCE: 0.012,
/** row band 자동 정렬 대상으로 보는 최대 높이 비율 */
ROW_LIKE_MAX_HEIGHT: 0.12,
} as const;
// ─────────────────────────────────────────────────────────────────────────────
// 입출력 타입
// ─────────────────────────────────────────────────────────────────────────────
export interface LayoutBlockInput {
id: string;
componentId?: 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';
/** 한 줄 control 은 세로 span 을 1행으로 축소할 componentId 목록 */
intrinsicRowSpanIds?: string[];
}
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;
const intrinsicRowSpanIds = new Set(
opts.intrinsicRowSpanIds ?? ['search', 'button', 'button-bar', 'pagination'],
);
const normalizedBlocks = normalizeRowBands(blocks);
// 0. 빈 입력 처리
if (!Array.isArray(normalizedBlocks) || normalizedBlocks.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 normalizedBlocks) {
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(normalizedBlocks.length);
const gridOk: { input: LayoutBlockInput; layout: BlockLayout; inputIdx: number }[] = [];
for (let i = 0; i < normalizedBlocks.length; i++) {
const b = normalizedBlocks[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;
let rowStart = t.idx + 1;
let rowEnd = bt.idx + 1;
// 한 줄 control 은 저장된 큰 bounding box 때문에 여러 row 를 점유해도,
// 실제 렌더는 한 줄 높이로 보이는 경우가 많다. 이런 컴포넌트는 centerY
// 기준 단일 row span 으로 축소해 중간 공백 row 가 커지는 문제를 막는다.
const componentId = b.componentId;
if (componentId && intrinsicRowSpanIds.has(componentId) && rowEnd > rowStart + 1) {
const centerY = b.yPct + b.hPct / 2;
let centerIdx = nearestLine(centerY, yLines).idx;
centerIdx = Math.max(0, Math.min(centerIdx, yLines.length - 2));
rowStart = centerIdx + 1;
rowEnd = rowStart + 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 normalizeRowBands(blocks: LayoutBlockInput[]): LayoutBlockInput[] {
const tol = LAYOUT_CONSTANTS.ROW_BAND_TOLERANCE;
const maxHeight = LAYOUT_CONSTANTS.ROW_LIKE_MAX_HEIGHT;
const cloned = blocks.map((b) => ({ ...b }));
const rowLikes = cloned
.filter((b) => !b.forceOverlay && b.hPct <= maxHeight)
.sort((a, b) => a.yPct - b.yPct || a.xPct - b.xPct);
let i = 0;
while (i < rowLikes.length) {
const seedTop = rowLikes[i].yPct;
const band: LayoutBlockInput[] = [rowLikes[i]];
let j = i + 1;
while (j < rowLikes.length && Math.abs(rowLikes[j].yPct - seedTop) <= tol) {
band.push(rowLikes[j]);
j++;
}
if (band.length >= 2) {
const bottoms = band.map((b) => b.yPct + b.hPct);
const minBottom = Math.min(...bottoms);
const maxBottom = Math.max(...bottoms);
// 같은 윗선에서 시작한 작은 높이 블록은 bottom 차이가 조금 나도
// 하나의 row band 로 본다. 더 긴 블록 쪽으로 맞춰 새 가로선 분리를 막는다.
if (maxBottom - minBottom <= tol) {
for (const block of band) {
block.hPct = Math.max(0, maxBottom - block.yPct);
}
}
}
i = j;
}
return cloned;
}
// ─────────────────────────────────────────────────────────────────────────────
// 내부 유틸
// ─────────────────────────────────────────────────────────────────────────────
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/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;
/** gap row 의 preferred 상한 (px). 큰 공백 행이 카드 내부를 과도하게 먹지 않게 한다. */
gapPreferredCap?: number;
/** 단일 제어 컴포넌트의 preferred share 상한 (px) */
intrinsicPreferredCaps?: Partial<Record<string, 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',
'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 gapPreferredCap = opts.gapPreferredCap ?? 24;
const intrinsicPreferredCaps = {
search: 44,
button: 40,
'button-bar': 40,
pagination: 40,
...(opts.intrinsicPreferredCaps ?? {}),
};
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 === 'overlay') 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 intrinsicCap =
b.componentId != null ? intrinsicPreferredCaps[b.componentId] : undefined;
const share =
intrinsicCap != null
? Math.min(designH / span, intrinsicCap)
: 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.min(Math.round(designH), gapPreferredCap),
);
} 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);
}