- 식 있는 열 = 계산 · 바인딩만 = 설계값(펼침이면 값마다 열) · 손 = 손 입력 - 식 줄은 [열id] 대신 머리 글 이름 · 같은 종류는 짧게 · 풍선은 긴 이름 + id 식 · 변수도 이름 - 열 폭 최소 = 머리 글 · 단위 폭(캔버스로 잼) · 들어갈 것 · 식 줄은 세 줄로 자르고 풍선에 전문 · 머리 칸도 풍선 - 쪽 나눔을 pagePlan 으로 떼어 시험 test_sheet_pages.py(줄 120 → 쪽 3 · 합계 마지막 쪽만) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
104 lines
4.1 KiB
TypeScript
104 lines
4.1 KiB
TypeScript
/* =============================================================================
|
||
* 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<string, ColumnName> {
|
||
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<string, ColumnName>();
|
||
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<string, ColumnName>,
|
||
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;
|
||
|
||
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<string, number> {
|
||
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]))]));
|
||
}
|