/* ============================================================================= * spreadsheet_headers.ts (주인 C) * 열 글자 · 행 번호 머리 · 폭 · 높이 재기(숨김 = 0) · 끌기 · 숨김 표시. * `LineGeometry` = 자리 하나 재기 — 자리 대부분 기본값이라(성긴 `열`·`행` 표) 겹친 만큼만 * 어긋난 표로 쥐고 이분 탐색으로 자리 ↔ 픽셀을 오간다(1만 행이어도 가벼움). * 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`. * ========================================================================== */ import { colIndex, colName } from "./spreadsheet_address"; import type { RowInfo, Sheet, Workbook } from "./spreadsheet_types"; import type { SpreadsheetContext } from "./spreadsheet_view_types"; export const DEFAULT_COL_CHARS = 8.43; export const DEFAULT_ROW_PT = 15; /** 엑셀 글자 폭 → px 근사(굴림 10pt 기준 · Calibri 공식과 같은 꼴). */ const CHAR_PX = 7; const CHAR_PAD_PX = 5; const PT_PX = 4 / 3; /** 엑셀 열 폭 상한(글자 단위) — 이 위로 재는 값이 들어와도 격자는 여기서 막음(자동 맞춤 폭주 방어). */ export const MAX_COL_CHARS = 255; export const colCharsToPx = (chars: number): number => Math.round(Math.min(chars, MAX_COL_CHARS) * CHAR_PX + CHAR_PAD_PX); export const pxToColChars = (px: number): number => Math.max(0, (px - CHAR_PAD_PX) / CHAR_PX); export const ptToPx = (pt: number): number => Math.round(pt * PT_PX); export const pxToPt = (px: number): number => px / PT_PX; export const colLabel = (c: number): string => colName(c); export const rowLabel = (r: number): string => String(r + 1); /** 자리 하나 재기 — 성긴 기본값 어긋남 표(정렬된 색인)로 자리 ↔ 픽셀 변환. */ export interface LineGeometry { /** 자리 `i` 폭(px) — 숨겼으면 0. */ size(i: number): number; /** 자리 `i` 가 0 부터 시작하는 픽셀 자리(내용 좌표). */ offset(i: number): number; /** 자리 `count` 개(0..count-1)를 합친 픽셀 길이. */ total(count: number): number; /** 픽셀 자리 `px` 가 든 자리 번호(0 부터). */ indexAt(px: number): number; } class SparseGeometry implements LineGeometry { private readonly idx: number[] = []; private readonly prefix: number[] = []; constructor( private readonly base: number, overrides: Map, ) { const keys = [...overrides.keys()].sort((a, b) => a - b); let acc = 0; for (const k of keys) { acc += overrides.get(k)! - base; this.idx.push(k); this.prefix.push(acc); } } /** 어긋난 표에서 `i` 가 들어갈 자리(첫 idx[k] >= i). */ private lowerBound(i: number): number { let lo = 0; let hi = this.idx.length; while (lo < hi) { const mid = (lo + hi) >> 1; if (this.idx[mid] < i) lo = mid + 1; else hi = mid; } return lo; } size(i: number): number { const at = this.lowerBound(i); if (this.idx[at] === i) { const before = at > 0 ? this.prefix[at - 1] : 0; return this.base + (this.prefix[at] - before); } return this.base; } offset(i: number): number { const at = this.lowerBound(i); const acc = at > 0 ? this.prefix[at - 1] : 0; return i * this.base + acc; } total(count: number): number { return this.offset(count); } indexAt(px: number): number { if (px <= 0) return 0; let hi = Math.max(1, Math.ceil(px / Math.max(this.base, 1)) + this.idx.length + 2); while (this.offset(hi) <= px) hi *= 2; let lo = 0; while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (this.offset(mid) <= px) lo = mid; else hi = mid - 1; } return lo; } } export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry { const base = colCharsToPx(sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS); const overrides = new Map(); for (const [key, info] of Object.entries(sheet.열 ?? {})) { const c = colIndex(key); if (c < 0) continue; if (info.숨김 || info.묶음?.접힘) overrides.set(c, 0); else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭)); } return new SparseGeometry(base, overrides); } export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry { const base = ptToPx(sheet.기본?.행높이 ?? book.기본?.행높이 ?? DEFAULT_ROW_PT); const overrides = new Map(); for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) { const r = Number(key) - 1; if (!Number.isInteger(r) || r < 0) continue; if (info.숨김 || info.걸러짐 || info.묶음?.접힘) overrides.set(r, 0); else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이)); } return new SparseGeometry(base, overrides); } /** 접힌 윤곽 묶음(연속 자리) — 묶음 있는 자리를 모아 붙은 것끼리 한 덩이로(같은 접힘 값끼리만). */ export interface OutlineRun { from: number; to: number; collapsed: boolean; } export function buildOutlineRuns( records: Record | undefined, indexOf: (key: string) => number, ): OutlineRun[] { const items = Object.entries(records ?? {}) .map(([key, info]) => ({ i: indexOf(key), collapsed: !!info.묶음?.접힘, has: !!info.묶음 })) .filter((x) => x.has && x.i >= 0) .sort((a, b) => a.i - b.i); const runs: OutlineRun[] = []; for (const it of items) { const last = runs[runs.length - 1]; if (last && last.to === it.i - 1 && last.collapsed === it.collapsed) last.to = it.i; else runs.push({ from: it.i, to: it.i, collapsed: it.collapsed }); } return runs; } /** 그 자리가 숨김 · 걸러짐(사용자 · 필터) 인가 — 묶음 접힘은 빼고(화면 표시가 다름). */ export function isExplicitHidden(info: { 숨김?: boolean; 걸러짐?: boolean } | undefined): boolean { return !!info?.숨김 || !!info?.걸러짐; } /** `from..to` 안에서 숨기지 않은 자리만. */ export function visibleIndices(geo: LineGeometry, from: number, to: number): number[] { const list: number[] = []; for (let i = from; i <= to; i += 1) if (geo.size(i) > 0) list.push(i); return list; } export interface HeaderCell { el: HTMLElement; index: number; } /** 열 머리 칸 — 글자 · 폭 끌기 손잡이 · 숨김 표시(왼쪽에 건너뛴 열이 있으면). */ export function makeColHeaderCell( c: number, x: number, w: number, hiddenBefore: boolean, ): HeaderCell { const cellEl = document.createElement("div"); cellEl.className = "aislo-grid__colhead"; if (hiddenBefore) cellEl.classList.add("aislo-grid__colhead--hidden-before"); cellEl.style.left = `${x}px`; cellEl.style.width = `${w}px`; cellEl.dataset.col = String(c); const label = document.createElement("span"); label.className = "aislo-grid__head-label"; label.textContent = colLabel(c); cellEl.append(label); const handle = document.createElement("div"); handle.className = "aislo-grid__resize aislo-grid__resize--col"; cellEl.append(handle); return { el: cellEl, index: c }; } /** 행 머리 칸 — 번호 · 높이 끌기 손잡이 · 숨김 표시(위에 건너뛴 행이 있으면). */ export function makeRowHeaderCell( r: number, y: number, h: number, hiddenBefore: boolean, ): HeaderCell { const cellEl = document.createElement("div"); cellEl.className = "aislo-grid__rowhead"; if (hiddenBefore) cellEl.classList.add("aislo-grid__rowhead--hidden-before"); cellEl.style.top = `${y}px`; cellEl.style.height = `${h}px`; cellEl.dataset.row = String(r); const label = document.createElement("span"); label.className = "aislo-grid__head-label"; label.textContent = rowLabel(r); cellEl.append(label); const handle = document.createElement("div"); handle.className = "aislo-grid__resize aislo-grid__resize--row"; cellEl.append(handle); return { el: cellEl, index: r }; } export interface ResizeDragOptions { handle: HTMLElement; axis: "col" | "row"; currentPx: () => number; onPreview: (px: number) => void; onCommit: (px: number) => void; } /** 폭 · 높이 끌기 — 누르는 순간 시작 픽셀을 쥐고 포인터가 옮긴 만큼 더함. */ export function startDragResize(opts: ResizeDragOptions): void { opts.handle.addEventListener("pointerdown", (ev) => { ev.preventDefault(); ev.stopPropagation(); const startClient = opts.axis === "col" ? ev.clientX : ev.clientY; const startPx = opts.currentPx(); opts.handle.setPointerCapture(ev.pointerId); const next = (e: PointerEvent): number => { const cur = opts.axis === "col" ? e.clientX : e.clientY; return Math.max(4, startPx + (cur - startClient)); }; const move = (e: PointerEvent): void => opts.onPreview(next(e)); const up = (e: PointerEvent): void => { opts.handle.removeEventListener("pointermove", move); opts.handle.removeEventListener("pointerup", up); opts.onCommit(next(e)); }; opts.handle.addEventListener("pointermove", move); opts.handle.addEventListener("pointerup", up); }); } /** 폭 끌기 다 됨 → `열폭` 명령(글자 단위로 되돌려 보냄). */ export function commitColWidth(ctx: SpreadsheetContext, c: number, px: number): void { ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: [c], 폭: pxToColChars(px) }); } /** 높이 끌기 다 됨 → `행높이` 명령(pt 단위로 되돌려 보냄). */ export function commitRowHeight(ctx: SpreadsheetContext, r: number, px: number): void { ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: [r], 높이: pxToPt(px) }); }