Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into sub_laptop_6

This commit is contained in:
2026-09-28 09:49:23 +09:00
10 changed files with 325 additions and 42 deletions
+20 -10
View File
@@ -252,29 +252,39 @@ function unescapeMsoNumFmt(v: string): string {
return v.replace(/^"|"$/g, "").replace(/\\(.)/g, "$1");
}
const BORDER_STYLE_MAP: Record<string, BorderLine> = {
solid: "thin",
dashed: "dashed",
dotted: "dotted",
double: "double",
};
/** pt 굵기 → 우리 선 종류(엑셀 내보내기 흔한 값 기준 — hairline ≈ .5pt 이하 · thin ≈ 1pt · medium ≈
* 1.5~2pt · thick ≈ 2pt 넘음). `solid` 아닌 것은 굵기와 상관없이 그 모양. */
function ptToBorderLine(pt: number, cssStyle: string): BorderLine {
if (cssStyle === "dashed") return "dashed";
if (cssStyle === "dotted") return "dotted";
if (cssStyle === "double") return "double";
if (pt <= 0.5) return "hair";
if (pt <= 1.25) return "thin";
if (pt <= 2) return "medium";
return "thick";
}
function parseBorderShorthand(v: string): BorderSide | undefined {
const m = /([\d.]+)\s*(?:pt|px)\s+(solid|dashed|dotted|double)\s*([^;]*)/i.exec(v);
if (!m) return undefined;
const 선 = BORDER_STYLE_MAP[m[2].toLowerCase()] ?? "thin";
const 선 = ptToBorderLine(Number(m[1]), m[2].toLowerCase());
const 색 = cssColorToHex(m[3]);
return 색 ? { 선, 색 } : { 선 };
}
/** `<style>...</style>` 안 `.xl65 {...}` 짜임(엑셀이 인라인 대신 클래스로 서식을 실을 때) → 클래스별 선언. */
/** `<style>...</style>` 안 `.xl65 {...}` · `.xl65, .xl70 {...}`(묶음 선택자) 짜임(엑셀이 인라인 대신
* 클래스로 서식을 실을 때) → 클래스별 선언(같은 클래스가 여러 규칙에 나오면 뒤엣것이 이김). */
function parseClassRules(doc: Document): Record<string, Record<string, string>> {
const css = Array.from(doc.querySelectorAll("style"))
.map((s) => s.textContent ?? "")
.join("\n");
const rules: Record<string, Record<string, string>> = {};
for (const m of css.matchAll(/\.([a-zA-Z0-9_-]+)\s*\{([^}]*)\}/g)) {
rules[m[1]] = parseStyleAttr(m[2]);
for (const m of css.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
const decls = parseStyleAttr(m[2]);
for (const selectorPart of m[1].split(",")) {
const cls = /\.([a-zA-Z0-9_-]+)\s*$/.exec(selectorPart.trim());
if (cls) Object.assign((rules[cls[1]] ??= {}), decls);
}
}
return rules;
}
@@ -4,7 +4,7 @@
"판": 1,
"설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.",
"바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값) · 값꼴 = 자리마다 머리 글 꼴({} = 값) · 빈값 = 설계값이 빈 자리 머리 글 · 펼침틀 = 계산 열이 같은 묶음으로 따라 나뉨(묶음 · 머리틀 · 값꼴 · 빈값 · 식틀 — 설계값을 받지 않아 바인딩 없음)",
"층": ["종류", "공법", "규격"],
"층": ["종류", "분류1", "분류2"],
"변수": {
"파형강관_1본_m": 8,
"흄관_1본_m": "",
@@ -0,0 +1,77 @@
<!-- fixture_excel_style_paste.html — 엑셀이 표 서식(머리 채움 · 가는 선 · 합계 줄 위 굵은 선 · 숫자
쉼표)을 실제로 어떻게 싣는지 재현(브레인 9-28 사용자 지시 근거 = PLAN.md 12장). 서식 대부분이
인라인 style 이 아니라 <style> 안 클래스(.xl65 같은 이름 · 묶음 선택자 포함)에 있고 칸에는
class 만 두는 실제 엑셀 내보내기 짜임을 그대로 씀 — `parseHtmlTable`/`styleFromTd` 가 클래스
규칙을 인라인과 합쳐 읽는지 이 견본으로 시험. -->
<html
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns="http://www.w3.org/TR/REC-html40"
>
<head>
<meta charset="utf-8" />
<style>
<!--table
{mso-displayed-decimal-separator:"\.";
mso-displayed-thousand-separator:"\,";}
.xl_head, .xl_head2
{background:#D9D9D9;
font-weight:700;
text-align:center;
border:.5pt solid windowtext;}
.xl_num
{mso-number-format:"\#\,\#\#0";
text-align:right;
border:.5pt solid windowtext;}
.xl_label
{border:.5pt solid windowtext;}
.xl_total_label
{font-weight:700;
border-left:.5pt solid windowtext;
border-right:.5pt solid windowtext;
border-bottom:.5pt solid windowtext;
border-top:1.5pt solid windowtext;}
.xl_total_num
{mso-number-format:"\#\,\#\#0";
font-weight:700;
text-align:right;
border-left:.5pt solid windowtext;
border-right:.5pt solid windowtext;
border-bottom:.5pt solid windowtext;
border-top:1.5pt solid windowtext;}
-->
</style>
</head>
<body link="#0563C1" vlink="#954F72">
<!--StartFragment-->
<table
border="0"
cellpadding="0"
cellspacing="0"
width="288"
style="border-collapse: collapse; width: 216pt"
>
<tr height="20" style="height: 15pt">
<td class="xl_head" width="96" style="width: 72pt" x:str>품목</td>
<td class="xl_head2" width="96" style="width: 72pt" x:str>수량</td>
<td class="xl_head2" width="96" style="width: 72pt" x:str>금액</td>
</tr>
<tr height="20" style="height: 15pt">
<td class="xl_label" x:str>터파기</td>
<td class="xl_num" x:num>120</td>
<td class="xl_num" x:num>1200000</td>
</tr>
<tr height="20" style="height: 15pt">
<td class="xl_label" x:str>메우기</td>
<td class="xl_num" x:num>80</td>
<td class="xl_num" x:num>800000</td>
</tr>
<tr height="20" style="height: 15pt">
<td class="xl_total_label" x:str>합계</td>
<td class="xl_total_num" x:num>200</td>
<td class="xl_total_num" x:num>2000000</td>
</tr>
</table>
<!--EndFragment-->
</body>
</html>
@@ -1,6 +1,6 @@
"""표 도면 줄 · 칸 고르기 신호 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄.
도면 줄 `s:drawing` = master 만 · 일위대가 줄 바로 아래(키보드 차례) · project 에는 없음.
도면 줄 `s:drawing` · 산출근거 줄 `s:basis` = master 만 · 일위대가 줄 바로 아래 차례(키보드) · project 에는 없음.
`selectionOf` = 격자가 `onSelect` 로 넘기는 모양({row, col, colId} · 모르는 열은 col -1 · colId null).
"""
@@ -51,7 +51,7 @@ def _run() -> dict:
def test_drawing_row_under_price_master_only():
got = _run()
assert got["master"] == [
"s:unit", "s:formula", "s:desc", "s:price", "s:drawing", "d:r1", "t:t1",
"s:unit", "s:formula", "s:desc", "s:price", "s:drawing", "s:basis", "d:r1", "t:t1",
] # fmt: skip
assert got["project"] == ["s:unit", "d:r1", "t:t1"]
@@ -0,0 +1,90 @@
"""표 둘째 층 묶음 더하기 · 층 이름 고치기 — `ui_template_sheet_ops.ts` 를 Node 로 바로 돌려 봄.
`addGroup` = 고른 열과 같은 맨 위 묶음(종류) 끝에 `[종류, 새 분류, …, 새 열]` · 다른 종류 열은 안 건드림.
`setLayerName` = 층 이름(줄 머리 글) · 모자란 층은 빈 글로 채움.
"""
from __future__ import annotations
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
OPS = ROOT / "ui_template" / "sheet" / "ui_template_sheet_ops.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
SCRIPT = """
const { addGroup, setLayerName } = await import(process.argv[1]);
const base = JSON.parse(process.argv[2]);
const out = {};
let doc = structuredClone(base);
out.id = addGroup(doc, "a1", 3, "새 분류", "새 열");
out.after_a1 = doc.열.map((c) => [c.id, c.머리]);
doc = structuredClone(base);
addGroup(doc, "b1", 4, "G", "L");
out.deep = doc.열.at(-1).머리;
doc = structuredClone(base);
addGroup(doc, null, 3, "G", "L");
out.none = doc.열.at(-1).머리;
doc = structuredClone(base);
setLayerName(doc, 1, " 분류1 ");
out.layers = doc.층;
doc = structuredClone(base);
delete doc.층;
setLayerName(doc, 2, "분류2");
out.padded = doc.층;
console.log(JSON.stringify(out));
"""
DOC = {
"양식": "시험",
"종류": "표",
"판": 1,
"층": ["종류", "공법", "규격"],
"열": [
{"id": "a1", "머리": ["가", "x", "1"], "단위": "m"},
{"id": "a2", "머리": ["가", "x", "2"]},
{"id": "a3", "머리": ["가", "y", None]},
{"id": "b1", "머리": ["나", None, None]},
],
"줄": [],
}
def _run() -> dict:
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e",
SCRIPT, OPS.as_uri(), json.dumps(DOC, ensure_ascii=False)],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_group_goes_to_end_of_same_top():
got = _run()
assert got["id"] == "c1"
assert got["after_a1"] == [
["a1", ["가", "x", "1"]],
["a2", ["가", "x", "2"]],
["a3", ["가", "y", None]],
["c1", ["가", "새 분류", "새 열"]],
["b1", ["나", None, None]],
]
def test_group_depth_and_no_selection():
got = _run()
assert got["deep"] == ["나", "G", None, "L"]
assert got["none"] == ["G", "G", "L"]
def test_layer_name():
got = _run()
assert got["layers"] == ["종류", "분류1", "규격"]
assert got["padded"] == ["", "", "분류2"]
+7
View File
@@ -123,6 +123,13 @@
cursor: cell;
}
/* 조회 후 선택 칸 — 두 번 누르거나 Enter 로 고름 */
.ui-sheet__table td.is-pick {
cursor: pointer;
text-decoration: underline dotted;
text-underline-offset: 3px;
}
.ui-sheet__table td.is-selected {
outline: 2px solid var(--color-focus-ring);
outline-offset: -2px;
+62 -15
View File
@@ -1,9 +1,12 @@
/* =============================================================================
* ui_template_sheet.ts
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, onSelect})` → `{getDoc, setDoc, recalc, destroy}`.
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange, drawingLabel, basisLabel, onSelect, onPick})`
* → `{getDoc, setDoc, recalc, destroy}`.
*
* master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열.
* 도면 줄(읽기만) = `drawingLabel` 글 · 칸을 고르면 `onSelect`.
* master = 시스템 관리자 양식 고치기 — 머리 · 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 ·
* 둘째 층 묶음 더하기 · 손 열.
* 도면 · 산출근거 줄 = `drawingLabel` · `basisLabel` 글 · 칸을 고르면 `onSelect` ·
* `onPick` 을 주면 일위대가 · 도면 · 산출근거 칸은 치지 않고 조회 후 선택.
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
@@ -14,27 +17,27 @@ import { createButton, el } from "@ui/ui_template_elements";
import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells";
import {
addColumn,
addGroup,
addRow,
canDeleteRow,
deleteColumn,
deleteRow,
KEY_BASIS,
KEY_DRAWING,
KEY_PRICE,
KEY_UNIT,
navRows,
PICK_ROWS,
selectionOf,
type SheetMode,
setCell,
setColumnWidth,
setHeadLabel,
setLayerName,
} from "./ui_template_sheet_ops";
import { headDepth } from "./ui_template_sheet_header";
import { recalcSheet } from "./ui_template_sheet_recalc";
import {
drawingText,
keyEditable,
type RenderState,
renderSheet,
} from "./ui_template_sheet_render";
import { keyEditable, refText, type RenderState, renderSheet } from "./ui_template_sheet_render";
import { st } from "./ui_template_sheet_text";
import type { SheetColumn, SheetDoc, SheetResult, SheetSelection } from "./ui_template_sheet_types";
import "./ui_template_sheet.css";
@@ -47,8 +50,14 @@ export interface SheetOptions {
onChange?: (doc: SheetDoc) => void;
/** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */
drawingLabel?: (colId: string) => string;
/** 산출근거 줄 칸 글(표번) — `drawingLabel` 과 같음. */
basisLabel?: (colId: string) => string;
/** 칸을 고름 — 누르기 · 방향키 · Tab. */
onSelect?: (sel: SheetSelection) => void;
/** 조회 후 선택 — master 일위대가 · 도면 · 산출근거 칸을 두 번 누르기 · Enter · F2 로 부름.
* 글을 돌려주면 일위대가 칸에 넣음 · 도면 · 산출근거는 부른 쪽이 가져온 뒤 `recalc()`.
* 없으면 일위대가는 글로 고침 · 도면 · 산출근거는 읽기만. */
onPick?: (sel: SheetSelection) => Promise<string | null | undefined> | void;
}
export interface SheetHandle {
@@ -67,6 +76,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
result: { 계산: {}, 합계: {}, 오류: [] },
sel: null,
drawingLabel: opts.drawingLabel,
basisLabel: opts.basisLabel,
pick: !!opts.onPick,
};
const scroll = el("div", { className: "ui-sheet__scroll" });
const toolbar = el("div", { className: "ui-sheet__toolbar" });
@@ -103,6 +114,12 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id };
changed();
});
const groupAdd = button(st("Group_Add"), () => {
const depth = headDepth(state.doc.열, state.doc.층);
const id = addGroup(state.doc, state.sel?.c ?? null, depth, st("Group_New"), st("Col_New"));
state.sel = { r: state.sel?.r ?? KEY_UNIT, c: id };
changed();
});
const colDelete = button(st("Col_Delete"), () => {
const id = state.sel?.c;
if (!id || state.doc.열.length <= 1) return;
@@ -120,13 +137,20 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
changed();
});
toolbar.append(rowAdd, rowDelete);
if (opts.mode === "master") toolbar.append(colAdd, colDelete, colHand);
if (opts.mode === "master") toolbar.append(colAdd, groupAdd, colDelete, colHand);
const syncToolbar = (): void => {
const rowId = rowIdOf(state.sel?.r);
const row = state.doc.줄.find((r) => r.id === rowId);
rowDelete.disabled = !row || !canDeleteRow(state.mode, row);
const col = state.sel ? colOf(state.sel.c) : undefined;
// 둘째 층 묶음 — 층이 셋 이상일 때만 · 이름은 둘째 층 이름(「분류1 더하기」)
const group = state.doc.층?.[1]?.trim();
groupAdd.hidden = headDepth(state.doc.열, state.doc.층) < 3;
groupAdd.disabled = !col;
groupAdd.querySelector(".ui-btn__label")!.textContent = group
? `${group}${st("Add_Suffix")}`
: st("Group_Add");
colDelete.disabled = !col || state.doc.열.length <= 1;
colHand.disabled = !col;
colHand.classList.toggle("is-on", !!col?.손);
@@ -180,8 +204,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
if (key === KEY_UNIT) return col.단위 ?? "";
if (key === "s:formula") return col.식 ?? "";
if (key === "s:desc") return col.설명 ?? "";
if (key === "s:price") return col.일위대가 ?? "";
if (key === KEY_DRAWING) return drawingText(state, col.id);
if (key === KEY_PRICE) return col.일위대가 ?? "";
if (key === KEY_DRAWING || key === KEY_BASIS) return refText(state, key, col.id);
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
const formula = row?.식?.[col.id] ?? col.식;
if (formula) return `=${formula}`;
@@ -189,27 +213,48 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
return raw === null || raw === undefined ? "" : String(raw);
};
const write = (key: string, col: SheetColumn, text: string): void => {
if (key === KEY_DRAWING) return; // 읽기만
if (key === KEY_DRAWING || key === KEY_BASIS) return; // 읽기만
const value = text.trim();
if (key === KEY_UNIT) col.단위 = value || null;
else if (key === "s:formula") {
if (value) col.식 = value;
else delete col.식;
} else if (key === "s:desc") col.설명 = value;
else if (key === "s:price") col.일위대가 = value || null;
else if (key === KEY_PRICE) col.일위대가 = value || null;
else setCell(state.doc, key.slice(2), col, text);
changed();
};
/** 조회 후 선택 — 부른 쪽이 고른 글을 돌려주면 일위대가 칸에 넣음(그 사이 문서가 바뀌었으면 새 열에) */
const pick = async (key: string, colId: string): Promise<void> => {
const got = await opts.onPick?.(selectionOf(state.doc, key, colId));
const col = colOf(colId);
if (key === KEY_PRICE && typeof got === "string" && col) write(key, col, got);
root.focus({ preventScroll: true });
};
const edit = (initial?: string): void => {
const sel = state.sel;
const col = sel && colOf(sel.c);
if (!sel || !col || !keyEditable(state, sel.r, col)) return;
if (!sel || !col) return;
if (opts.onPick && state.mode === "master" && PICK_ROWS.includes(sel.r)) {
if (initial === undefined) void pick(sel.r, col.id); // 글자를 쳐서는 안 엶
return;
}
if (!keyEditable(state, sel.r, col)) return;
const td = cellsAt(sel.r, sel.c)[0];
if (!td) return;
startEdit(td, rawText(sel.r, col), (value) => write(sel.r, col, value));
const box = td.querySelector("input");
if (box && initial !== undefined) box.value = initial;
};
/** 층 이름(머리 층 줄 머리 칸) 고치기 — master */
const editLayer = (th: HTMLElement): void => {
const level = Number(th.dataset.layer);
if (Number.isNaN(level)) return;
startEdit(th, th.textContent ?? "", (value) => {
setLayerName(state.doc, level, value);
changed();
});
};
const editHead = (th: HTMLElement): void => {
const start = state.doc.열.findIndex((c) => c.id === th.dataset.col);
const level = Number(th.dataset.level);
@@ -242,7 +287,9 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
const target = event.target as HTMLElement;
if (target.closest("input")) return;
const th = target.closest<HTMLElement>("th[data-col]");
const layer = target.closest<HTMLElement>("th[data-layer]");
if (th && state.mode === "master") editHead(th);
else if (layer && state.mode === "master") editLayer(layer);
else if (target.closest("td[data-r]")) edit();
};
const onKey = (event: KeyboardEvent): void => {
+38 -2
View File
@@ -19,10 +19,14 @@ export const ROW_FIXED_ALL = "전구간";
const PAGE_ROWS = 50;
export const KEY_UNIT = "s:unit";
export const KEY_PRICE = "s:price";
export const KEY_DRAWING = "s:drawing";
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 줄은 읽기만. */
export const MASTER_ROWS = ["s:formula", "s:desc", "s:price", KEY_DRAWING] as const;
export const KEY_BASIS = "s:basis";
/** 마스터 전용 줄(첫 쪽 단위 줄 아래 차례) — 도면 · 산출근거 줄은 손으로 못 침. */
export const MASTER_ROWS = ["s:formula", "s:desc", KEY_PRICE, KEY_DRAWING, KEY_BASIS] as const;
export type MasterRowKey = (typeof MASTER_ROWS)[number];
/** 조회 후 선택 줄 — `onPick` 을 주면 이 줄 칸은 치지 않고 고름. */
export const PICK_ROWS: readonly string[] = [KEY_PRICE, KEY_DRAWING, KEY_BASIS];
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
@@ -124,6 +128,38 @@ export function addColumn(doc: SheetDoc, after: string | null, label: string): s
return id;
}
/** `after` 열과 같은 맨 위 묶음(종류) 끝에 둘째 층 새 묶음 열 — `[위, group, …null, leaf]`(층 `depth` 가
* 3 이상일 때 · 가운데 층은 위 칸과 합침) · 새 열 id. */
export function addGroup(
doc: SheetDoc,
after: string | null,
depth: number,
group: string,
leaf: string,
): string {
const id = freshId(
"c",
doc.열.map((c) => c.id),
);
let at = doc.열.findIndex((c) => c.id === after);
const base = at < 0 ? null : doc.열[at];
const top = base?.머리[0] ?? group;
if (base) while (at + 1 < doc.열.length && doc.열[at + 1].머리[0] === top) at += 1;
const head: (string | null)[] = [top, group];
while (head.length < depth - 1) head.push(null);
head.push(leaf);
const col: SheetColumn = { id, 머리: head, 단위: base?.단위 ?? null, 꼴: "수" };
doc.열.splice(at < 0 ? doc.열.length : at + 1, 0, col);
return id;
}
/** 머리 층 이름(줄 머리 글) 고치기 — 모자란 층은 빈 글로 채움. */
export function setLayerName(doc: SheetDoc, level: number, text: string): void {
const layers = (doc.층 ??= []);
while (layers.length <= level) layers.push("");
layers[level] = text.trim();
}
/** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */
export function deleteColumn(doc: SheetDoc, id: string): void {
doc.열 = doc.열.filter((c) => c.id !== id);
+24 -12
View File
@@ -3,8 +3,9 @@
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
*
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing`.
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price|drawing|basis`.
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 · 도면 · 산출근거 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
* 머리 층 줄 머리 칸은 `data-layer`(층 차례) — master 에서 층 이름을 고침.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
@@ -14,10 +15,12 @@ import {
cellEditable,
isBoundColumn,
isCalcColumn,
KEY_BASIS,
KEY_DRAWING,
KEY_UNIT,
MASTER_ROWS,
type MasterRowKey,
PICK_ROWS,
pagePlan,
type SheetMode,
} from "./ui_template_sheet_ops";
@@ -34,13 +37,17 @@ export interface RenderState {
mode: SheetMode;
result: SheetResult;
sel: { r: string; c: string } | null;
/** 도면 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
/** 도면 · 산출근거 줄 칸 글(열 id) — 없거나 빈 글이면 「없음」. */
drawingLabel?: (colId: string) => string;
basisLabel?: (colId: string) => string;
/** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */
pick?: boolean;
}
/** 도면 줄 칸 글. */
export const drawingText = (state: RenderState, colId: string): string =>
state.drawingLabel?.(colId)?.trim() || st("Drawing_None");
/** 도면 · 산출근거 줄 칸 글. */
export const refText = (state: RenderState, key: string, colId: string): string =>
(key === KEY_BASIS ? state.basisLabel : state.drawingLabel)?.(colId)?.trim() ||
st("Drawing_None");
/** 열 폭 — 저장 폭(없으면 기본) · 머리 글이 안 잘리는 최소 폭보다 좁지 않게. */
const columnWidth = (doc: SheetDoc, col: SheetColumn, mins: Map<string, number>): number =>
@@ -55,9 +62,9 @@ function headFont(): string {
return `600 ${rem * 0.82}px ${getComputedStyle(document.body).fontFamily}`;
}
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 도면 줄은 읽기만. */
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로) · 도면 · 산출근거 줄은 손으로 못 침. */
export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean {
if (key === KEY_DRAWING) return false;
if (key === KEY_DRAWING || key === KEY_BASIS) return false;
if (key.startsWith("s:")) return state.mode === "master";
if (key.startsWith("t:")) return false;
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
@@ -86,7 +93,7 @@ export function renderSheet(state: RenderState): HTMLElement {
const names = columnNames(cols);
const font = headFont();
const mins = headMinWidths(cols, depth, font);
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 도면 · 합계 이름이 안 잘리게
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 도면 · 산출근거 · 합계 이름이 안 잘리게
const GUTTER = Math.min(
GUTTER_MAX,
Math.max(
@@ -98,6 +105,7 @@ export function renderSheet(state: RenderState): HTMLElement {
st("Row_Desc"),
st("Row_Unit_Price"),
st("Row_Drawing"),
st("Row_Basis"),
...(doc.합계줄 ?? []).map((t) => t.이름),
].map((label) => Math.ceil(textWidth(label, font)) + 16),
),
@@ -165,6 +173,7 @@ export function renderSheet(state: RenderState): HTMLElement {
"s:desc": "Row_Desc",
"s:price": "Row_Unit_Price",
"s:drawing": "Row_Drawing",
"s:basis": "Row_Basis",
} as const
)[key];
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
@@ -174,11 +183,12 @@ export function renderSheet(state: RenderState): HTMLElement {
? formulaLabel(col.식 ?? "", names, col.id)
: key === "s:desc"
? ""
: key === KEY_DRAWING
? drawingText(state, col.id)
: key === KEY_DRAWING || key === KEY_BASIS
? refText(state, key, col.id)
: (col.일위대가 ?? "");
const td = bodyCell(key, col, j, text);
td.classList.remove("is-num");
if (state.pick && PICK_ROWS.includes(key)) td.classList.add("is-pick");
if (key === "s:formula" && col.식) {
// 같은 종류 열은 짧은 이름 · 풍선은 긴 이름 + 고칠 때 쓰는 id 식
td.textContent = "";
@@ -209,7 +219,9 @@ export function renderSheet(state: RenderState): HTMLElement {
const thead = (): HTMLElement => {
const rows = head.map((cells, level) => {
const tr = el("tr", { children: [gutter("th", doc.층?.[level] ?? "")] });
const layer = gutter("th", doc.층?.[level] ?? "", mode === "master" ? "is-editable" : "");
layer.dataset.layer = String(level);
const tr = el("tr", { children: [layer] });
for (const cell of cells) {
const th = el("th", {
text: cell.label,
@@ -17,6 +17,10 @@ const TEXT = {
Row_Desc: ["들어갈 것", "Source"],
Row_Unit_Price: ["일위대가", "Unit price"],
Row_Drawing: ["도면", "Drawing"],
Row_Basis: ["산출근거", "Basis"],
Group_Add: ["묶음 더하기", "Add group"],
Group_New: ["새 분류", "New group"],
Add_Suffix: [" 더하기", " +"],
Drawing_None: ["없음", "None"],
Kind_Bound: ["설계값", "Design"],
Kind_Calc: ["계산", "Calc"],