Files
Aislo/A00_Common/spreadsheet/spreadsheet_headers.ts
T

224 lines
8.1 KiB
TypeScript

/* =============================================================================
* 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 colCharsToPx = (chars: number): number => Math.round(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<number, number>,
) {
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(book.기본?.열폭 ?? DEFAULT_COL_CHARS);
const overrides = new Map<number, number>();
for (const [key, info] of Object.entries(sheet.열 ?? {})) {
const c = colIndex(key);
if (c < 0) continue;
if (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(book.기본?.행높이 ?? DEFAULT_ROW_PT);
const overrides = new Map<number, number>();
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.숨김) overrides.set(r, 0);
else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이));
}
return new SparseGeometry(base, overrides);
}
/** `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) });
}