/* ============================================================================= * ui_template_sheet_labels.ts * 사람이 읽는 글 — 식의 열 id 를 열 이름(머리 글)으로 · 머리 글이 안 잘리는 최소 열 폭. * * 열 이름 = 맨 위 글(종류) + 맨 아래 글 · 다른 열과 겹치면 가운데 층을 더 붙임(자리 글 「(…마다)」 는 뺌). * 보이기만 바꿈 — 문서의 식은 그대로 id 식(고칠 때도 id 식). * ========================================================================== */ import { layoutHead } from "./ui_template_sheet_header"; import type { SheetColumn } from "./ui_template_sheet_types"; const PLACEHOLDER = /[((][^))]*마다[))]/; const NAME_PAD = 16; const MAX_MIN_WIDTH = 220; export interface ColumnName { /** 맨 위 글(종류) + 아래 글 — 표 전체에서 안 겹침. */ full: string; /** 같은 종류 안에서 부를 이름 — 겹치면 `full`. */ short: string; top: string; } /** 열 id → 열 이름. */ export function columnNames(cols: SheetColumn[]): Map { const paths = cols.map((c) => { const path = c.머리.filter((label): label is string => !!label && !PLACEHOLDER.test(label)); return path.length ? path : [c.id]; }); /** 맨 위 + 아래서 `take` 층 — 모자라면 통째. */ const nameOf = (path: string[], take: number): string => (take + 1 >= path.length ? path : [path[0], ...path.slice(-take)]).join(" "); const names = new Map(); cols.forEach((col, j) => { const path = paths[j]; let full = path.join(" "); for (let take = 1; take + 1 < path.length; take += 1) { const name = nameOf(path, take); if (!paths.some((other, k) => k !== j && nameOf(other, take) === name)) { full = name; break; } } const leaf = path.length > 1 ? path.slice(1).join(" ") : path[0]; const clash = paths.some( (other, k) => k !== j && other[0] === path[0] && (other.length > 1 ? other.slice(1).join(" ") : other[0]) === leaf, ); names.set(col.id, { full, short: clash ? full : leaf, top: path[0] }); }); return names; } /** 식 → 읽는 글 — `[열id]` → 열 이름 · `[열id@줄]` → 이름@줄 · `[$변수]` → 변수 이름 · `*` `/` → × ÷. * `host` 를 주면 같은 종류 열은 짧은 이름. */ export function formulaLabel( formula: string, names: Map, host?: string, ): string { const top = host ? names.get(host)?.top : undefined; return formula .replace(/\[([^\]]+)\]/g, (_, ref: string) => { const text = ref.trim(); if (text.startsWith("$")) return text.slice(1).replace(/_/g, " "); const [col, row] = text.split("@"); const hit = names.get(col.trim()); const name = !hit ? col.trim() : top !== undefined && hit.top === top ? hit.short : hit.full; return row ? `${name}@${row.trim()}` : name; }) .replace(/\s*\*\s*/g, " × ") .replace(/\s*\/\s*/g, " ÷ "); } let ruler: CanvasRenderingContext2D | null = null; export function textWidth(text: string, font: string): number { ruler ??= document.createElement("canvas").getContext("2d"); if (!ruler) return text.length * 13; ruler.font = font; return ruler.measureText(text).width; } /** 열마다 머리 글 · 단위가 안 잘리는 최소 폭(px) — 여러 열을 덮는 칸은 모자란 만큼 나눠 얹음. */ export function headMinWidths( cols: SheetColumn[], depth: number, font: string, ): Map { const mins = cols.map((c) => textWidth(c.단위 ?? "", font) + NAME_PAD); for (const level of layoutHead(cols, depth).reverse()) { for (const cell of level) { const need = textWidth(cell.label, font) + NAME_PAD; const have = mins.slice(cell.col, cell.col + cell.colspan).reduce((a, b) => a + b, 0); if (need <= have) continue; const extra = (need - have) / cell.colspan; for (let k = cell.col; k < cell.col + cell.colspan; k += 1) mins[k] += extra; } } return new Map(cols.map((c, j) => [c.id, Math.min(MAX_MIN_WIDTH, Math.ceil(mins[j]))])); }