814 lines
30 KiB
TypeScript
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);
|
|
}
|