Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
"""표 문서(마스터 템플릿 표 양식) 서버 재계산 — 화면과 같은 TS 를 Node 로 돌리는 껍데기.
|
||||
|
||||
CLAUDE.md 5장 ② — 계산은 `ui_template/sheet/ui_template_sheet_recalc.ts` 한 벌.
|
||||
여기는 번들을 부르기만 함(`npm run build:formula` → `config/formula_node/`).
|
||||
[저장] 때 브라우저 값을 받아 적지 않고 이 결과를 정본으로 씀.
|
||||
|
||||
결과 = `{계산: {줄: {열: 값}}, 합계: {합계줄: {열: 값}}, 오류: [{줄, 열, 까닭}]}` · 값은 십진 글.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from common_util.common_util_node_bundle import ROOT, build_bundle, run_bundle_json
|
||||
|
||||
BUNDLE = ROOT / "config" / "formula_node" / "common_util_sheet_recalc_node.js"
|
||||
NPM_SCRIPT = "build:formula"
|
||||
SOURCE_DIR = ROOT / "ui_template" / "sheet"
|
||||
ENTRY = ROOT / "common_util" / "common_util_sheet_recalc_node.ts"
|
||||
|
||||
|
||||
def _stale() -> bool:
|
||||
"""번들이 없거나 표 TS 원본보다 오래됐으면 참 — 낡으면 화면과 서버가 다른 값을 냄."""
|
||||
if not BUNDLE.is_file():
|
||||
return True
|
||||
built_at = BUNDLE.stat().st_mtime
|
||||
return any(p.stat().st_mtime > built_at for p in [ENTRY, *SOURCE_DIR.glob("*.ts")])
|
||||
|
||||
|
||||
def recalc_sheets(docs: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||
"""표 문서 여럿을 한 번에 — 입력 차례 그대로 결과. 번들 빌드·실행 실패는 None."""
|
||||
if _stale() and not build_bundle(NPM_SCRIPT):
|
||||
return None
|
||||
out = run_bundle_json(BUNDLE, NPM_SCRIPT, {"docs": docs})
|
||||
return None if out is None else out["results"]
|
||||
|
||||
|
||||
def recalc_sheet(doc: dict[str, Any]) -> dict[str, Any] | None:
|
||||
results = recalc_sheets([doc])
|
||||
return None if results is None else results[0]
|
||||
@@ -0,0 +1,27 @@
|
||||
/* =============================================================================
|
||||
* common_util_sheet_recalc_node.ts
|
||||
* 표 문서 풀이를 **서버가** 돌리는 진입점 — [저장] 때 정본으로 다시 풂.
|
||||
*
|
||||
* 풀이는 화면이 쓰는 `ui_template_sheet_recalc.ts` 그대로 — 여기에는 계산이 없음.
|
||||
* 파이썬 껍데기 = `common_util_sheet_recalc.py` · 번들 = `npm run build:formula`.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { docs: SheetDoc[] }
|
||||
* 출력 { results: SheetResult[] } — 입력 차례 그대로
|
||||
* 끝 코드: 0 성공 / 2 인자 오류
|
||||
* ⚠ `ui_template/sheet/` 밖에 둠 — M02 화면이 그 폴더를 통째로 훑어 불러옴(node:fs 가 브라우저로 새지 않게).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { recalcSheet } from "@ui/sheet/ui_template_sheet_recalc";
|
||||
import type { SheetDoc } from "@ui/sheet/ui_template_sheet_types";
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as { docs?: SheetDoc[] };
|
||||
const results = (input.docs ?? []).map((doc) => recalcSheet(doc));
|
||||
writeFileSync(outputPath, JSON.stringify({ results }));
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
"build": "node ./config/node_modules/typescript/bin/tsc --noEmit && node ./config/node_modules/vite/bin/vite.js build --configLoader runner && npm run build:corridor && npm run build:server-calc && npm run build:formula && npm run build:b07-cad",
|
||||
"build:corridor": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B05_Profile/B05_Profile_Corridor_Node.ts --outDir ../config/corridor_node",
|
||||
"build:server-calc": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B06_Section/B06_Section_Server_Calc_Node.ts --outDir ../config/server_calc_node",
|
||||
"build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../B08_Quantity/B08_Quantity_Formula_Node.ts --outDir ../config/formula_node",
|
||||
"build:formula": "node ./config/node_modules/vite/bin/vite.js build --configLoader runner --ssr ../common_util/common_util_sheet_recalc_node.ts --outDir ../config/formula_node",
|
||||
"install:b07-cad": "npm --prefix B07_DesignDetail/openwebcad install",
|
||||
"build:b07-cad": "npm run install:b07-cad && npm --prefix B07_DesignDetail/openwebcad run build",
|
||||
"preview": "node ./config/node_modules/vite/bin/vite.js preview --configLoader runner",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""표 양식 식 풀이 — 서버 Node 길(`common_util_sheet_recalc`) 그대로 시험.
|
||||
|
||||
풀이는 `ui_template/sheet/ui_template_sheet_recalc.ts` 한 벌 · 화면과 서버가 같은 TS 를 씀.
|
||||
거울 — 파이썬 Decimal 로 따로 푼 답과 Node 답을 무작위 문서 여럿에서 견줌.
|
||||
⭐ 기준값 하나는 실무 엑셀 캐시값 — 울진 2공구 `4.2 수량산출(2공구).xlsx` 「구조물집계표」
|
||||
5~14줄 · 16줄 `=SUM(…)` 합계(openpyxl data_only 로 읽음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import shutil
|
||||
import sys
|
||||
from decimal import ROUND_DOWN, ROUND_FLOOR, ROUND_HALF_UP, ROUND_UP, Decimal, getcontext
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from common_util.common_util_sheet_recalc import recalc_sheet, recalc_sheets # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
|
||||
def _doc(cols: list[dict], rows: list[dict], **head) -> dict:
|
||||
for col in cols:
|
||||
col.setdefault("머리", [col["id"]])
|
||||
return {"양식": "시험", "종류": "표", "판": 1, "열": cols, "줄": rows, **head}
|
||||
|
||||
|
||||
def _run(doc: dict) -> dict:
|
||||
out = recalc_sheet(doc)
|
||||
assert out is not None, "Node 번들 실행 실패"
|
||||
return out
|
||||
|
||||
|
||||
# ── 실무 캐시값 ──────────────────────────────────────────────────────────
|
||||
PRACTICE = { # 측점: {엑셀 열: 값} — 5~14줄
|
||||
"전구간": {"AC": 10},
|
||||
"71+13": {"D": 10, "F": 20, "G": 10, "H": 10, "AQ": 22, "AR": 3},
|
||||
"75+0": {"AF": 1},
|
||||
"78+7": {"F": 10, "H": 20, "AK": 18, "AL": 2, "AM": 1},
|
||||
"84+0": {"F": 10, "H": 20, "AK": 16, "AL": 2, "AN": 1},
|
||||
"89+10": {"E": 10, "H": 10, "AK": 18, "AL": 2, "AN": 1},
|
||||
"100+0": {"AF": 1},
|
||||
"102+0": {"F": 10, "H": 10, "AK": 16, "AL": 1, "AM": 1},
|
||||
"105+12": {"D": 10, "F": 10, "G": 10, "H": 10, "AO": 16, "AP": 2},
|
||||
"112+0": {"E": 10, "H": 10, "AK": 16, "AL": 2, "AN": 1},
|
||||
}
|
||||
PRACTICE_SUM = {"C": "0", "D": "20", "E": "20", "F": "60", "G": "20", "H": "90", "AC": "10"}
|
||||
PRACTICE_SUM |= {"AF": "2", "AK": "84", "AL": "9", "AM": "2", "AN": "3", "AO": "16"}
|
||||
PRACTICE_SUM |= {"AP": "2", "AQ": "22", "AR": "3", "S": "0", "U": "0"}
|
||||
|
||||
|
||||
def test_practice_totals_match_excel():
|
||||
names = sorted(PRACTICE_SUM)
|
||||
cols = [{"id": "B", "꼴": "글"}] + [{"id": n} for n in names if n not in ("S", "U")]
|
||||
cols += [{"id": "Q"}, {"id": "R"}]
|
||||
cols += [{"id": "S", "식": "[Q]*[R]"}, {"id": "U", "식": "INT([R]/6.00000001)*[Q]"}]
|
||||
rows = [
|
||||
{"id": f"r{i}", "값": {"B": sta, **vals}} for i, (sta, vals) in enumerate(PRACTICE.items())
|
||||
]
|
||||
doc = _doc(cols, rows, 합계줄=[{"id": "sum", "이름": "계", "식": "SUM"}])
|
||||
out = _run(doc)
|
||||
assert out["오류"] == []
|
||||
assert {k: out["합계"]["sum"][k] for k in PRACTICE_SUM} == PRACTICE_SUM # 엑셀에 있는 합 칸만
|
||||
assert out["계산"]["r0"] == {"S": "0", "U": "0"} # 엑셀 S5 · U5 = 0
|
||||
|
||||
|
||||
def test_pavement_joint_coupling_and_rounding():
|
||||
cols = [
|
||||
{"id": "b"},
|
||||
{"id": "l"},
|
||||
{"id": "a", "식": "[b]*[l]"},
|
||||
{"id": "jt", "식": "INT([l]/6.00000001)*[b]"},
|
||||
{"id": "cp", "식": "ROUNDUP([l]/[$본],0)-1"},
|
||||
{"id": "r", "식": "[l]/3", "끝수": {"자리": 2, "방법": "반올림"}},
|
||||
{"id": "up", "식": "[l]/3", "끝수": {"자리": 1, "방법": "올림"}},
|
||||
{"id": "dn", "식": "[b]*1.15*100", "끝수": {"자리": 0, "방법": "버림"}},
|
||||
]
|
||||
rows = [{"id": "r1", "값": {"b": 3, "l": 120}}, {"id": "r2", "값": {"b": "0.29", "l": 16}}]
|
||||
out = _run(_doc(cols, rows, 변수={"본": 8}))
|
||||
assert out["오류"] == []
|
||||
assert out["계산"]["r1"] == {
|
||||
"a": "360",
|
||||
"jt": "57", # 120/6.00000001 = 19.99… → 19 × 3
|
||||
"cp": "14", # 올림(120/8)−1
|
||||
"r": "40",
|
||||
"up": "40",
|
||||
"dn": "345",
|
||||
}
|
||||
# 부동소수면 틀어지는 자리 — 0.29×1.15×100 = 33.35 → 버림 33 · 16/3 = 5.333… → 올림 5.4
|
||||
assert out["계산"]["r2"] == {
|
||||
"a": "4.64",
|
||||
"jt": "0.58",
|
||||
"cp": "1",
|
||||
"r": "5.33",
|
||||
"up": "5.4",
|
||||
"dn": "33",
|
||||
}
|
||||
|
||||
|
||||
def test_row_override_total_map_and_cell_ref():
|
||||
cols = [{"id": "sta", "꼴": "글"}, {"id": "x"}, {"id": "y", "식": "[x]*2"}]
|
||||
rows = [
|
||||
{"id": "r1", "값": {"sta": "전구간", "x": 5}, "고정": "전구간"},
|
||||
{"id": "r2", "값": {"x": 7}, "식": {"y": "[x]+[x@r1]"}},
|
||||
]
|
||||
totals = [
|
||||
{"id": "sum", "이름": "계", "식": {"x": "SUM", "*": "MAX([y@r1],[y@r2])"}},
|
||||
{"id": "avg", "이름": "평균", "식": {"x": "ROUND([x@sum]/2,1)"}},
|
||||
]
|
||||
out = _run(_doc(cols, rows, 합계줄=totals))
|
||||
assert out["오류"] == []
|
||||
assert out["계산"] == {"r1": {"y": "10"}, "r2": {"y": "12"}}
|
||||
assert out["합계"] == {"sum": {"x": "12", "y": "12"}, "avg": {"x": "6"}}
|
||||
|
||||
|
||||
def test_errors_stay_in_their_cells():
|
||||
cols = [
|
||||
{"id": "x"},
|
||||
{"id": "p", "식": "[q]+1"},
|
||||
{"id": "q", "식": "[p]+1"},
|
||||
{"id": "z", "식": "[x]/0"},
|
||||
{"id": "n", "식": "[없음]+1"},
|
||||
{"id": "t", "식": "[x]*2"},
|
||||
{"id": "bad", "식": "[x]*("},
|
||||
]
|
||||
rows = [{"id": "r1", "값": {"x": "글자"}}, {"id": "r2", "값": {"x": 4}}]
|
||||
out = _run(_doc(cols, rows))
|
||||
why = {(e["줄"], e["열"]): e["까닭"] for e in out["오류"]}
|
||||
assert "돌고 도는 참조" in why.values()
|
||||
assert {("r2", "p"), ("r2", "q")} <= set(why)
|
||||
assert "0 으로 나눔" in why[("r2", "z")]
|
||||
assert "없는 열" in why[("r2", "n")]
|
||||
assert "글" in why[("r1", "t")]
|
||||
assert ("r2", "bad") in why
|
||||
assert out["계산"]["r2"] == {"t": "8"} # 나머지 칸은 계속 풂
|
||||
|
||||
|
||||
# ── 거울 — 파이썬 Decimal 로 따로 푼 답 ─────────────────────────────────
|
||||
getcontext().prec = 60
|
||||
TEMPLATES = {
|
||||
"mul": ("[a]*[b]", lambda a, b, k: a * b),
|
||||
"joint": (
|
||||
"INT([b]/6.00000001)*[a]",
|
||||
lambda a, b, k: (b / Decimal("6.00000001")).to_integral_value(ROUND_FLOOR) * a,
|
||||
),
|
||||
"ratio": (
|
||||
"ROUND([a]/[b]*100,2)",
|
||||
lambda a, b, k: (a / b * 100).quantize(Decimal("0.01"), ROUND_HALF_UP),
|
||||
),
|
||||
"band": (
|
||||
"ROUNDUP([a]/8,0)-1",
|
||||
lambda a, b, k: (a / 8).to_integral_value(ROUND_UP) - 1,
|
||||
),
|
||||
"down": (
|
||||
"ROUNDDOWN([a]*1.15,1)",
|
||||
lambda a, b, k: (a * Decimal("1.15")).quantize(Decimal("0.1"), ROUND_DOWN),
|
||||
),
|
||||
"mix": ("[a]-[b]*2+[$k]", lambda a, b, k: a - b * 2 + k),
|
||||
}
|
||||
|
||||
|
||||
def _plain(x: Decimal) -> str:
|
||||
text = format(x.normalize(), "f")
|
||||
return "0" if text in ("-0", "") else text
|
||||
|
||||
|
||||
def test_mirror_random_docs_match_decimal():
|
||||
rnd = random.Random(20260925)
|
||||
docs, expected = [], []
|
||||
for _ in range(40):
|
||||
k = Decimal(rnd.randint(-500, 500)) / 10
|
||||
rows, want = [], {}
|
||||
for i in range(rnd.randint(1, 12)):
|
||||
a = Decimal(rnd.randint(0, 99999)) / 100
|
||||
b = Decimal(rnd.randint(1, 99999)) / 100
|
||||
rows.append({"id": f"r{i}", "값": {"a": str(a), "b": str(b)}})
|
||||
want[f"r{i}"] = {name: fn(a, b, k) for name, (_, fn) in TEMPLATES.items()}
|
||||
cols = [{"id": "a"}, {"id": "b"}] + [{"id": n, "식": f} for n, (f, _) in TEMPLATES.items()]
|
||||
docs.append(
|
||||
_doc(cols, rows, 변수={"k": str(k)}, 합계줄=[{"id": "s", "이름": "계", "식": "SUM"}])
|
||||
)
|
||||
sums = {n: sum((w[n] for w in want.values()), Decimal(0)) for n in TEMPLATES}
|
||||
expected.append((want, sums))
|
||||
results = recalc_sheets(docs)
|
||||
assert results is not None
|
||||
for out, (want, sums) in zip(results, expected, strict=True):
|
||||
assert out["오류"] == []
|
||||
assert out["계산"] == {r: {n: _plain(v) for n, v in w.items()} for r, w in want.items()}
|
||||
for name, total in sums.items():
|
||||
assert out["합계"]["s"][name] == _plain(total)
|
||||
@@ -0,0 +1,189 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet.css — 엑셀처럼 도는 표 부품(`ui_template_sheet.ts`)
|
||||
* 색은 테마 토큰만 — 설계값 = 초록 · 계산 = 노랑 · 손 입력 = 보라 기운
|
||||
* ========================================================================== */
|
||||
|
||||
.ui-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ui-sheet__toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-sheet__toolbar .ui-btn.is-on {
|
||||
background: var(--color-mist-violet);
|
||||
}
|
||||
|
||||
.ui-sheet__hint {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ui-sheet__scroll {
|
||||
overflow: auto;
|
||||
max-height: calc(100vh - 260px);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.ui-sheet__pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.ui-sheet__table {
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ui-sheet__page {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.ui-sheet__table th,
|
||||
.ui-sheet__table td {
|
||||
position: relative;
|
||||
height: 26px;
|
||||
padding: 2px 6px;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-sheet__table thead th,
|
||||
.ui-sheet__table thead td {
|
||||
background: var(--color-surface);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-sheet__gutter {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
background: var(--color-surface) !important;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-sheet__table .is-frozen {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
box-shadow: 1px 0 0 var(--color-border);
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-bound {
|
||||
background: color-mix(in srgb, var(--color-success) 7%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-calc {
|
||||
background: color-mix(in srgb, var(--color-warning) 10%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-hand {
|
||||
background: color-mix(in srgb, var(--color-lavender-wash) 14%, var(--color-canvas));
|
||||
}
|
||||
|
||||
.ui-sheet--project .ui-sheet__table tbody td.is-locked {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-override {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-error {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ui-sheet__table .is-editable {
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.ui-sheet__table td.is-selected {
|
||||
outline: 2px solid var(--color-focus-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.ui-sheet__table tr.is-fixed td {
|
||||
border-bottom: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ui-sheet__meta td {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.76rem;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.ui-sheet__total td,
|
||||
.ui-sheet__total th {
|
||||
border-top: 2px solid var(--color-border);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-sheet__chip {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ui-sheet__chip.is-bound {
|
||||
background: color-mix(in srgb, var(--color-success) 18%, var(--color-canvas));
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.ui-sheet__chip.is-calc {
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.ui-sheet__chip.is-hand {
|
||||
background: var(--color-mist-violet);
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ui-sheet__resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.ui-sheet__table td input,
|
||||
.ui-sheet__table th input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 2px;
|
||||
border: 0;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet.ts
|
||||
* 엑셀처럼 도는 표 부품 — `createSheet(칸, 문서, {mode, onChange})` → `{getDoc, setDoc, recalc, destroy}`.
|
||||
*
|
||||
* master = 시스템 관리자 양식 고치기 — 머리 · 단위 · 식 · 들어갈 것 · 일위대가 · 줄 · 열 더하기·지우기 · 손 열.
|
||||
* project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽.
|
||||
* 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음.
|
||||
* 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
|
||||
* 칸 고치기는 M01 `startEdit`(Enter · 칸 밖 = 확정 · Esc = 취소) 를 그대로 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { startEdit } from "../../M01_MasterData/M01_MasterData_UI_Cells";
|
||||
import {
|
||||
addColumn,
|
||||
addRow,
|
||||
canDeleteRow,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
type SheetMode,
|
||||
setCell,
|
||||
setColumnWidth,
|
||||
} from "./ui_template_sheet_ops";
|
||||
import { recalcSheet } from "./ui_template_sheet_recalc";
|
||||
import {
|
||||
columnWidth,
|
||||
KEY_UNIT,
|
||||
keyEditable,
|
||||
navRows,
|
||||
type RenderState,
|
||||
renderSheet,
|
||||
} from "./ui_template_sheet_render";
|
||||
import { st } from "./ui_template_sheet_text";
|
||||
import type { SheetColumn, SheetDoc, SheetResult } from "./ui_template_sheet_types";
|
||||
import "./ui_template_sheet.css";
|
||||
|
||||
export type { SheetMode } from "./ui_template_sheet_ops";
|
||||
export type { SheetDoc, SheetResult } from "./ui_template_sheet_types";
|
||||
|
||||
export interface SheetOptions {
|
||||
mode: SheetMode;
|
||||
onChange?: (doc: SheetDoc) => void;
|
||||
}
|
||||
|
||||
export interface SheetHandle {
|
||||
getDoc(): SheetDoc;
|
||||
setDoc(doc: SheetDoc): void;
|
||||
recalc(): SheetResult;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
const MIN_WIDTH = 32;
|
||||
|
||||
export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptions): SheetHandle {
|
||||
const state: RenderState = {
|
||||
doc: structuredClone(input),
|
||||
mode: opts.mode,
|
||||
result: { 계산: {}, 합계: {}, 오류: [] },
|
||||
sel: null,
|
||||
};
|
||||
const scroll = el("div", { className: "ui-sheet__scroll" });
|
||||
const toolbar = el("div", { className: "ui-sheet__toolbar" });
|
||||
const root = el("div", {
|
||||
className: `ui-sheet ui-sheet--${opts.mode}`,
|
||||
attrs: { tabindex: "0" },
|
||||
children: [toolbar, el("p", { className: "ui-sheet__hint", text: st("Hint") }), scroll],
|
||||
});
|
||||
host.replaceChildren(root);
|
||||
|
||||
const colOf = (id: string): SheetColumn | undefined => state.doc.열.find((c) => c.id === id);
|
||||
const rowIdOf = (key: string | undefined): string | null =>
|
||||
key?.startsWith("d:") ? key.slice(2) : null;
|
||||
|
||||
// ── 단추 ──────────────────────────────────────────────────────────
|
||||
const button = (label: string, run: () => void): HTMLButtonElement =>
|
||||
createButton({ label, variant: "ghost", onClick: run });
|
||||
const rowAdd = button(st("Row_Add"), () => {
|
||||
const id = addRow(state.doc, state.mode, rowIdOf(state.sel?.r));
|
||||
state.sel = { r: `d:${id}`, c: state.sel?.c ?? state.doc.열[0]?.id ?? "" };
|
||||
changed();
|
||||
});
|
||||
const rowDelete = button(st("Row_Delete"), () => {
|
||||
const id = rowIdOf(state.sel?.r);
|
||||
if (!id) return;
|
||||
const keys = navRows(state.doc, state.mode);
|
||||
deleteRow(state.doc, id);
|
||||
const next = keys[keys.indexOf(`d:${id}`) + 1] ?? keys[keys.indexOf(`d:${id}`) - 1];
|
||||
state.sel = next && state.sel ? { r: next, c: state.sel.c } : null;
|
||||
changed();
|
||||
});
|
||||
const colAdd = button(st("Col_Add"), () => {
|
||||
const id = addColumn(state.doc, state.sel?.c ?? null, 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;
|
||||
const at = state.doc.열.findIndex((c) => c.id === id);
|
||||
deleteColumn(state.doc, id);
|
||||
const next = state.doc.열[Math.min(at, state.doc.열.length - 1)];
|
||||
state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null;
|
||||
changed();
|
||||
});
|
||||
const colHand = button(st("Col_Hand"), () => {
|
||||
const col = state.sel && colOf(state.sel.c);
|
||||
if (!col) return;
|
||||
if (col.손) delete col.손;
|
||||
else col.손 = true;
|
||||
changed();
|
||||
});
|
||||
toolbar.append(rowAdd, rowDelete);
|
||||
if (opts.mode === "master") toolbar.append(colAdd, 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;
|
||||
colDelete.disabled = !col || state.doc.열.length <= 1;
|
||||
colHand.disabled = !col;
|
||||
colHand.classList.toggle("is-on", !!col?.손);
|
||||
};
|
||||
|
||||
// ── 그리기 · 고름 ─────────────────────────────────────────────────
|
||||
const render = (): void => {
|
||||
state.result = recalcSheet(state.doc);
|
||||
const { scrollLeft, scrollTop } = scroll;
|
||||
scroll.replaceChildren(renderSheet(state));
|
||||
scroll.scrollLeft = scrollLeft;
|
||||
scroll.scrollTop = scrollTop;
|
||||
syncToolbar();
|
||||
};
|
||||
const changed = (): void => {
|
||||
render();
|
||||
opts.onChange?.(structuredClone(state.doc));
|
||||
};
|
||||
|
||||
const cellsAt = (r: string, c: string): HTMLElement[] => [
|
||||
...scroll.querySelectorAll<HTMLElement>(
|
||||
`td[data-r="${CSS.escape(r)}"][data-c="${CSS.escape(c)}"]`,
|
||||
),
|
||||
];
|
||||
const select = (r: string, c: string, reveal = false): void => {
|
||||
for (const cell of scroll.querySelectorAll(".is-selected"))
|
||||
cell.classList.remove("is-selected");
|
||||
state.sel = { r, c };
|
||||
const cells = cellsAt(r, c);
|
||||
for (const cell of cells) cell.classList.add("is-selected");
|
||||
if (reveal) cells[0]?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
||||
syncToolbar();
|
||||
};
|
||||
const move = (dr: number, dc: number): void => {
|
||||
const keys = navRows(state.doc, state.mode);
|
||||
const cols = state.doc.열;
|
||||
if (!state.sel) {
|
||||
if (keys.length && cols.length) select(keys[0], cols[0].id, true);
|
||||
return;
|
||||
}
|
||||
const r = Math.max(0, Math.min(keys.length - 1, keys.indexOf(state.sel.r) + dr));
|
||||
const at = cols.findIndex((c) => c.id === state.sel!.c);
|
||||
const c = Math.max(0, Math.min(cols.length - 1, at + dc));
|
||||
select(keys[r], cols[c].id, true);
|
||||
};
|
||||
|
||||
// ── 칸 고치기 ─────────────────────────────────────────────────────
|
||||
const rawText = (key: string, col: SheetColumn): string => {
|
||||
if (key === KEY_UNIT) return col.단위 ?? "";
|
||||
if (key === "s:formula") return col.식 ?? "";
|
||||
if (key === "s:desc") return col.설명 ?? "";
|
||||
if (key === "s:price") return col.일위대가 ?? "";
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
const formula = row?.식?.[col.id] ?? col.식;
|
||||
if (formula) return `=${formula}`;
|
||||
const raw = row?.값[col.id];
|
||||
return raw === null || raw === undefined ? "" : String(raw);
|
||||
};
|
||||
const write = (key: string, col: SheetColumn, text: string): void => {
|
||||
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 setCell(state.doc, key.slice(2), col, text);
|
||||
changed();
|
||||
};
|
||||
const edit = (initial?: string): void => {
|
||||
const sel = state.sel;
|
||||
const col = sel && colOf(sel.c);
|
||||
if (!sel || !col || !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;
|
||||
};
|
||||
const editHead = (th: HTMLElement): void => {
|
||||
const start = state.doc.열.findIndex((c) => c.id === th.dataset.col);
|
||||
const level = Number(th.dataset.level);
|
||||
if (start < 0 || Number.isNaN(level)) return;
|
||||
const span = state.doc.열.slice(start, start + (th as HTMLTableCellElement).colSpan);
|
||||
startEdit(th, th.textContent ?? "", (value) => {
|
||||
for (const col of span) {
|
||||
while (col.머리.length <= level) col.머리.push(null);
|
||||
col.머리[level] = value.trim();
|
||||
}
|
||||
changed();
|
||||
});
|
||||
};
|
||||
|
||||
// ── 사건 ──────────────────────────────────────────────────────────
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("input")) return;
|
||||
const td = target.closest<HTMLElement>("td[data-r]");
|
||||
if (td) select(td.dataset.r!, td.dataset.c!);
|
||||
const th = target.closest<HTMLElement>("th[data-col]");
|
||||
if (th) select(state.sel?.r ?? KEY_UNIT, th.dataset.col!);
|
||||
root.focus({ preventScroll: true });
|
||||
};
|
||||
const onDblClick = (event: MouseEvent): void => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest("input")) return;
|
||||
const th = target.closest<HTMLElement>("th[data-col]");
|
||||
if (th && state.mode === "master") editHead(th);
|
||||
else if (target.closest("td[data-r]")) edit();
|
||||
};
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
const typing = (event.target as HTMLElement).tagName === "INPUT";
|
||||
if (typing) {
|
||||
if (event.key === "Enter") {
|
||||
move(1, 0);
|
||||
root.focus({ preventScroll: true });
|
||||
} else if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
(event.target as HTMLInputElement).blur();
|
||||
move(0, event.shiftKey ? -1 : 1);
|
||||
root.focus({ preventScroll: true });
|
||||
} else if (event.key === "Escape") {
|
||||
render();
|
||||
root.focus({ preventScroll: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const arrows: Record<string, [number, number]> = {
|
||||
ArrowUp: [-1, 0],
|
||||
ArrowDown: [1, 0],
|
||||
ArrowLeft: [0, -1],
|
||||
ArrowRight: [0, 1],
|
||||
};
|
||||
if (event.key in arrows) {
|
||||
event.preventDefault();
|
||||
move(...arrows[event.key]);
|
||||
} else if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
move(0, event.shiftKey ? -1 : 1);
|
||||
} else if (event.key === "Enter" || event.key === "F2") {
|
||||
event.preventDefault();
|
||||
edit();
|
||||
} else if (event.key === "Delete" || event.key === "Backspace") {
|
||||
const col = state.sel && colOf(state.sel.c);
|
||||
if (state.sel && col && keyEditable(state, state.sel.r, col)) {
|
||||
event.preventDefault();
|
||||
write(state.sel.r, col, "");
|
||||
}
|
||||
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
edit(event.key);
|
||||
}
|
||||
};
|
||||
|
||||
// 열 너비 끌기 — 단위 줄 칸 오른쪽 손잡이
|
||||
let drag: { id: string; x: number; width: number; total: number } | null = null;
|
||||
const onPointerDown = (event: PointerEvent): void => {
|
||||
const handle = (event.target as HTMLElement).closest<HTMLElement>("[data-resize]");
|
||||
const col = handle && colOf(handle.dataset.resize!);
|
||||
if (!handle || !col) return;
|
||||
event.preventDefault();
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
const total = parseFloat(scroll.querySelector<HTMLElement>("table")?.style.width ?? "0");
|
||||
drag = { id: col.id, x: event.clientX, width: columnWidth(state.doc, col), total };
|
||||
};
|
||||
const onPointerMove = (event: PointerEvent): void => {
|
||||
if (!drag) return;
|
||||
const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x);
|
||||
for (const c of scroll.querySelectorAll<HTMLElement>(`col[data-col="${CSS.escape(drag.id)}"]`))
|
||||
c.style.width = `${width}px`;
|
||||
for (const table of scroll.querySelectorAll<HTMLElement>("table"))
|
||||
table.style.width = `${drag.total - drag.width + width}px`;
|
||||
};
|
||||
const onPointerUp = (event: PointerEvent): void => {
|
||||
if (!drag) return;
|
||||
const width = Math.max(MIN_WIDTH, drag.width + event.clientX - drag.x);
|
||||
const id = drag.id;
|
||||
drag = null;
|
||||
setColumnWidth(state.doc, id, width);
|
||||
changed();
|
||||
};
|
||||
|
||||
root.addEventListener("click", onClick);
|
||||
root.addEventListener("dblclick", onDblClick);
|
||||
root.addEventListener("keydown", onKey);
|
||||
root.addEventListener("pointerdown", onPointerDown);
|
||||
root.addEventListener("pointermove", onPointerMove);
|
||||
root.addEventListener("pointerup", onPointerUp);
|
||||
render();
|
||||
|
||||
return {
|
||||
getDoc: () => structuredClone(state.doc),
|
||||
setDoc: (doc) => {
|
||||
state.doc = structuredClone(doc);
|
||||
state.sel = null;
|
||||
render();
|
||||
},
|
||||
recalc: () => {
|
||||
render();
|
||||
return structuredClone(state.result);
|
||||
},
|
||||
destroy: () => {
|
||||
root.remove();
|
||||
drag = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_formula.ts
|
||||
* 표 식 읽기 · 풀기 — 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 파서를 되살려 표 참조를 더함.
|
||||
*
|
||||
* 식 말: 사칙 · 괄호 · 비교(= <> < <= > >=) · 함수 SUM · INT · ROUND · ROUNDUP · ROUNDDOWN ·
|
||||
* MIN · MAX · IF · 참조 `[열id]`(같은 줄) · `[열id@줄id]`(한 칸) · `[$이름]`(문서 변수).
|
||||
* `SUM([열id])` = 그 열의 본문 줄 전부. 빈 칸은 0(엑셀과 같음).
|
||||
* ⚠ `eval` 을 쓰지 않음 — 식은 사용자 글이라 직접 짠 파서로만 읽음.
|
||||
* 우선순위는 엑셀과 같음 — 부호 > `* /` > `+ -` > 비교. 같은 단은 왼쪽부터.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
add,
|
||||
cmp,
|
||||
div,
|
||||
type Frac,
|
||||
frac,
|
||||
FormulaError,
|
||||
type IntMode,
|
||||
mul,
|
||||
parseDecimal,
|
||||
roundAt,
|
||||
sub,
|
||||
toInteger,
|
||||
ZERO,
|
||||
} from "./ui_template_sheet_frac";
|
||||
|
||||
type Token =
|
||||
| { kind: "num"; text: string }
|
||||
| { kind: "str"; text: string }
|
||||
| { kind: "id"; text: string }
|
||||
| { kind: "ref"; text: string }
|
||||
| { kind: "op"; text: string };
|
||||
|
||||
export type Node =
|
||||
| { type: "num"; value: Frac }
|
||||
| { type: "str"; value: string }
|
||||
| { type: "ref"; col: string; row: string | null }
|
||||
| { type: "var"; name: string }
|
||||
| { type: "unary"; op: string; arg: Node }
|
||||
| { type: "binary"; op: string; left: Node; right: Node }
|
||||
| { type: "call"; name: string; args: Node[] };
|
||||
|
||||
export type Value = Frac | string | boolean;
|
||||
|
||||
/** 풀 때 참조를 대 주는 쪽 — 표 풀이(`_recalc`)가 채움. */
|
||||
export interface Scope {
|
||||
cell(col: string, row: string | null): Value;
|
||||
column(col: string): Frac[];
|
||||
variable(name: string): Value;
|
||||
}
|
||||
|
||||
const OPERATORS = ["<=", ">=", "<>", "+", "-", "*", "/", "(", ")", ",", "=", "<", ">"];
|
||||
const COMPARISONS = new Set(["=", "<>", "<", "<=", ">", ">="]);
|
||||
const ROUNDERS: Record<string, IntMode> = { ROUND: "round", ROUNDUP: "away", ROUNDDOWN: "trunc" };
|
||||
|
||||
function tokenize(source: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let i = 0;
|
||||
while (i < source.length) {
|
||||
const ch = source[i];
|
||||
if (/\s/.test(ch)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const number = /^(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/.exec(source.slice(i));
|
||||
if (number) {
|
||||
tokens.push({ kind: "num", text: number[0] });
|
||||
i += number[0].length;
|
||||
continue;
|
||||
}
|
||||
if (ch === "[") {
|
||||
const end = source.indexOf("]", i + 1);
|
||||
if (end < 0) throw new FormulaError("「[」가 닫히지 않음");
|
||||
tokens.push({ kind: "ref", text: source.slice(i + 1, end).trim() });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" || ch === '"') {
|
||||
const end = source.indexOf(ch, i + 1);
|
||||
if (end < 0) throw new FormulaError("따옴표가 닫히지 않음");
|
||||
tokens.push({ kind: "str", text: source.slice(i + 1, end) });
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
const identifier = /^[\p{L}_][\p{L}\p{N}_]*/u.exec(source.slice(i));
|
||||
if (identifier) {
|
||||
tokens.push({ kind: "id", text: identifier[0].toUpperCase() });
|
||||
i += identifier[0].length;
|
||||
continue;
|
||||
}
|
||||
const op = OPERATORS.find((candidate) => source.startsWith(candidate, i));
|
||||
if (!op) throw new FormulaError(`읽을 수 없는 글자: ${ch}`);
|
||||
tokens.push({ kind: "op", text: op });
|
||||
i += op.length;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function refNode(text: string): Node {
|
||||
if (!text) throw new FormulaError("빈 참조 []");
|
||||
if (text.startsWith("$")) return { type: "var", name: text.slice(1).trim() };
|
||||
const at = text.indexOf("@");
|
||||
if (at < 0) return { type: "ref", col: text, row: null };
|
||||
return { type: "ref", col: text.slice(0, at).trim(), row: text.slice(at + 1).trim() };
|
||||
}
|
||||
|
||||
/** 앞에 `=` 가 붙어 있어도 됨(엑셀 버릇). */
|
||||
export function parseFormula(source: string): Node {
|
||||
const tokens = tokenize(source.trim().replace(/^=/, ""));
|
||||
let at = 0;
|
||||
const peek = (): Token | undefined => tokens[at];
|
||||
const isOp = (text: string): boolean => peek()?.kind === "op" && peek()?.text === text;
|
||||
const expect = (text: string): void => {
|
||||
if (!isOp(text)) throw new FormulaError(`「${text}」가 있어야 함`);
|
||||
at += 1;
|
||||
};
|
||||
|
||||
const comparison = (): Node => {
|
||||
let left = additive();
|
||||
while (peek()?.kind === "op" && COMPARISONS.has(peek()!.text)) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: additive() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const additive = (): Node => {
|
||||
let left = term();
|
||||
while (isOp("+") || isOp("-")) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: term() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const term = (): Node => {
|
||||
let left = unary();
|
||||
while (isOp("*") || isOp("/")) {
|
||||
const op = tokens[at++].text;
|
||||
left = { type: "binary", op, left, right: unary() };
|
||||
}
|
||||
return left;
|
||||
};
|
||||
const unary = (): Node => {
|
||||
if (isOp("-") || isOp("+")) {
|
||||
const op = tokens[at++].text;
|
||||
return { type: "unary", op, arg: unary() };
|
||||
}
|
||||
return primary();
|
||||
};
|
||||
const primary = (): Node => {
|
||||
const token = peek();
|
||||
if (!token) throw new FormulaError("식이 중간에 끝남");
|
||||
at += 1;
|
||||
if (token.kind === "num") return { type: "num", value: parseDecimal(token.text) };
|
||||
if (token.kind === "str") return { type: "str", value: token.text };
|
||||
if (token.kind === "ref") return refNode(token.text);
|
||||
if (token.kind === "id") {
|
||||
if (!isOp("(")) throw new FormulaError(`모르는 이름: ${token.text} — 칸은 [열id] 로`);
|
||||
at += 1;
|
||||
const args: Node[] = [];
|
||||
if (!isOp(")")) {
|
||||
args.push(comparison());
|
||||
while (isOp(",")) {
|
||||
at += 1;
|
||||
args.push(comparison());
|
||||
}
|
||||
}
|
||||
expect(")");
|
||||
return { type: "call", name: token.text, args };
|
||||
}
|
||||
if (token.text === "(") {
|
||||
const inner = comparison();
|
||||
expect(")");
|
||||
return inner;
|
||||
}
|
||||
throw new FormulaError(`여기에 올 수 없음: ${token.text}`);
|
||||
};
|
||||
|
||||
if (!tokens.length) throw new FormulaError("빈 식");
|
||||
const tree = comparison();
|
||||
if (at < tokens.length) throw new FormulaError(`식 끝에 남은 글: ${tokens[at].text}`);
|
||||
return tree;
|
||||
}
|
||||
|
||||
/** 식이 가리키는 참조 — 순환 · 없는 열 검사용. */
|
||||
export function* refsOf(node: Node): Generator<Node & { type: "ref" | "var" }> {
|
||||
if (node.type === "ref" || node.type === "var") yield node;
|
||||
else if (node.type === "unary") yield* refsOf(node.arg);
|
||||
else if (node.type === "binary") {
|
||||
yield* refsOf(node.left);
|
||||
yield* refsOf(node.right);
|
||||
} else if (node.type === "call") for (const arg of node.args) yield* refsOf(arg);
|
||||
}
|
||||
|
||||
const isFrac = (v: Value): v is Frac => typeof v === "object";
|
||||
|
||||
export function asFrac(value: Value): Frac {
|
||||
if (isFrac(value)) return value;
|
||||
if (typeof value === "boolean") return frac(value ? 1n : 0n);
|
||||
throw new FormulaError(`수 자리에 글이 옴: ${value}`);
|
||||
}
|
||||
|
||||
function truthy(value: Value): boolean {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (isFrac(value)) return value.n !== 0n;
|
||||
throw new FormulaError(`조건 자리에 글이 옴: ${value}`);
|
||||
}
|
||||
|
||||
function compareValues(a: Value, b: Value): number {
|
||||
if (typeof a === "string" || typeof b === "string") {
|
||||
if (typeof a !== "string" || typeof b !== "string") {
|
||||
throw new FormulaError("글과 수를 견줄 수 없음");
|
||||
}
|
||||
return a === b ? 0 : a < b ? -1 : 1;
|
||||
}
|
||||
return cmp(asFrac(a), asFrac(b));
|
||||
}
|
||||
|
||||
function digitsOf(node: Node | undefined, scope: Scope): number {
|
||||
if (!node) return 0;
|
||||
const value = asFrac(evaluate(node, scope));
|
||||
if (value.d !== 1n) throw new FormulaError("자리 수는 정수");
|
||||
return Number(value.n);
|
||||
}
|
||||
|
||||
export function evaluate(node: Node, scope: Scope): Value {
|
||||
switch (node.type) {
|
||||
case "num":
|
||||
case "str":
|
||||
return node.value;
|
||||
case "ref":
|
||||
return scope.cell(node.col, node.row);
|
||||
case "var":
|
||||
return scope.variable(node.name);
|
||||
case "unary": {
|
||||
const value = asFrac(evaluate(node.arg, scope));
|
||||
return node.op === "-" ? sub(ZERO, value) : value;
|
||||
}
|
||||
case "binary": {
|
||||
const left = evaluate(node.left, scope);
|
||||
const right = evaluate(node.right, scope);
|
||||
if (COMPARISONS.has(node.op)) {
|
||||
const order = compareValues(left, right);
|
||||
return {
|
||||
"=": order === 0,
|
||||
"<>": order !== 0,
|
||||
"<": order < 0,
|
||||
"<=": order <= 0,
|
||||
">": order > 0,
|
||||
">=": order >= 0,
|
||||
}[node.op]!;
|
||||
}
|
||||
const a = asFrac(left);
|
||||
const b = asFrac(right);
|
||||
if (node.op === "+") return add(a, b);
|
||||
if (node.op === "-") return sub(a, b);
|
||||
if (node.op === "*") return mul(a, b);
|
||||
return div(a, b);
|
||||
}
|
||||
case "call": {
|
||||
const { name, args } = node;
|
||||
if (name === "IF") {
|
||||
if (args.length !== 3) throw new FormulaError("IF 는 인자가 셋(조건, 참, 거짓)");
|
||||
// 고른 갈래만 풂 — 안 고른 갈래의 오류가 칸을 막지 않게
|
||||
return evaluate(truthy(evaluate(args[0], scope)) ? args[1] : args[2], scope);
|
||||
}
|
||||
if (name === "SUM") {
|
||||
let total = ZERO;
|
||||
for (const arg of args) {
|
||||
const values =
|
||||
arg.type === "ref" && arg.row === null
|
||||
? scope.column(arg.col)
|
||||
: [asFrac(evaluate(arg, scope))];
|
||||
for (const value of values) total = add(total, value);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
if (name === "INT") {
|
||||
if (args.length !== 1) throw new FormulaError("INT 는 인자가 하나");
|
||||
return frac(toInteger(asFrac(evaluate(args[0], scope)), "floor"));
|
||||
}
|
||||
if (name in ROUNDERS) {
|
||||
if (args.length < 1 || args.length > 2) throw new FormulaError(`${name} 는 (값, 자리)`);
|
||||
return roundAt(asFrac(evaluate(args[0], scope)), digitsOf(args[1], scope), ROUNDERS[name]);
|
||||
}
|
||||
if (name === "MIN" || name === "MAX") {
|
||||
const values = args.map((arg) => asFrac(evaluate(arg, scope)));
|
||||
if (!values.length) throw new FormulaError(`${name} 에 인자가 없음`);
|
||||
return values.reduce((best, v) => (cmp(v, best) < 0 === (name === "MIN") ? v : best));
|
||||
}
|
||||
throw new FormulaError(`모르는 함수: ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_frac.ts
|
||||
* 표 식의 수 — BigInt 분수. 옛 `B08_Quantity_Formula.ts`(git 8472fc9f) 의 분수 몫을 되살림.
|
||||
*
|
||||
* ⚠ 부동소수는 1.15×100 = 114.999… 라 버림이 114 로 틀어짐 — 실무 엑셀 `INT(x*100)/100` 이
|
||||
* 뜻한 값은 십진 값의 버림이라 분수로 풀어야 맞음. 서버(파이썬 Decimal)와 1원도 안 갈림.
|
||||
* ========================================================================== */
|
||||
|
||||
export class FormulaError extends Error {}
|
||||
|
||||
export interface Frac {
|
||||
n: bigint;
|
||||
d: bigint;
|
||||
}
|
||||
|
||||
/** 정수로 떨구는 법 — floor = 엑셀 INT · trunc = ROUNDDOWN · away = ROUNDUP · round = ROUND. */
|
||||
export type IntMode = "floor" | "trunc" | "away" | "round";
|
||||
|
||||
const DIGITS = 30;
|
||||
const TEN = 10n;
|
||||
|
||||
const abs = (x: bigint): bigint => (x < 0n ? -x : x);
|
||||
|
||||
function gcd(a: bigint, b: bigint): bigint {
|
||||
a = abs(a);
|
||||
b = abs(b);
|
||||
while (b) [a, b] = [b, a % b];
|
||||
return a || 1n;
|
||||
}
|
||||
|
||||
export function frac(n: bigint, d = 1n): Frac {
|
||||
if (d === 0n) throw new FormulaError("0 으로 나눔");
|
||||
if (d < 0n) [n, d] = [-n, -d];
|
||||
const g = gcd(n, d);
|
||||
return { n: n / g, d: d / g };
|
||||
}
|
||||
|
||||
export const ZERO = frac(0n);
|
||||
export const add = (a: Frac, b: Frac): Frac => frac(a.n * b.d + b.n * a.d, a.d * b.d);
|
||||
export const sub = (a: Frac, b: Frac): Frac => frac(a.n * b.d - b.n * a.d, a.d * b.d);
|
||||
export const mul = (a: Frac, b: Frac): Frac => frac(a.n * b.n, a.d * b.d);
|
||||
export function div(a: Frac, b: Frac): Frac {
|
||||
if (b.n === 0n) throw new FormulaError("0 으로 나눔");
|
||||
return frac(a.n * b.d, a.d * b.n);
|
||||
}
|
||||
export function cmp(a: Frac, b: Frac): number {
|
||||
const left = a.n * b.d;
|
||||
const right = b.n * a.d;
|
||||
return left === right ? 0 : left < right ? -1 : 1;
|
||||
}
|
||||
|
||||
/** 십진 글(`-12.5` · `3` · `1e-3` · `1,234`)을 분수로. */
|
||||
export function parseDecimal(text: string): Frac {
|
||||
const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text.replace(/,/g, "").trim());
|
||||
if (!match || (match[2] === "" && (match[3] ?? "") === "")) {
|
||||
throw new FormulaError(`수로 읽지 못함: ${text}`);
|
||||
}
|
||||
const [, sign, whole, fraction = "", exponent = "0"] = match;
|
||||
let n = BigInt((whole || "0") + fraction);
|
||||
let d = TEN ** BigInt(fraction.length);
|
||||
const e = Number(exponent);
|
||||
if (e > 0) n *= TEN ** BigInt(e);
|
||||
if (e < 0) d *= TEN ** BigInt(-e);
|
||||
return frac(sign === "-" ? -n : n, d);
|
||||
}
|
||||
|
||||
/** 수로 온 값은 **보이는 십진 표기**로 읽음 — 0.15 를 이진 근사값으로 받지 않음. */
|
||||
export function toFrac(value: number | string): Frac {
|
||||
if (typeof value === "number") {
|
||||
if (!Number.isFinite(value)) throw new FormulaError(`수가 아님: ${value}`);
|
||||
return parseDecimal(String(value));
|
||||
}
|
||||
return parseDecimal(value);
|
||||
}
|
||||
|
||||
export function toInteger(x: Frac, mode: IntMode): bigint {
|
||||
const q = x.n / x.d; // 0 쪽으로 자름
|
||||
const r = x.n % x.d;
|
||||
if (r === 0n) return q;
|
||||
const negative = x.n < 0n;
|
||||
if (mode === "floor") return negative ? q - 1n : q;
|
||||
if (mode === "trunc") return q;
|
||||
if (mode === "away") return negative ? q - 1n : q + 1n;
|
||||
// 사사오입 — 엑셀 ROUND 와 같이 0 에서 먼 쪽
|
||||
return abs(r) * 2n >= x.d ? (negative ? q - 1n : q + 1n) : q;
|
||||
}
|
||||
|
||||
/** 소수 `digits` 자리에서 떨굼(음수 자리 = 십 · 백 자리). */
|
||||
export function roundAt(x: Frac, digits: number, mode: IntMode): Frac {
|
||||
const places = Math.trunc(digits);
|
||||
const scale = places >= 0 ? frac(TEN ** BigInt(places)) : frac(1n, TEN ** BigInt(-places));
|
||||
return div(frac(toInteger(mul(x, scale), mode)), scale);
|
||||
}
|
||||
|
||||
/** 분수를 십진 글로 — 끝나는 소수는 그대로 · 안 끝나면 30자리에서 사사오입. */
|
||||
export function fracToString(x: Frac): string {
|
||||
const scaled = toInteger(mul(x, frac(TEN ** BigInt(DIGITS))), "round");
|
||||
const negative = scaled < 0n;
|
||||
const digits = abs(scaled)
|
||||
.toString()
|
||||
.padStart(DIGITS + 1, "0");
|
||||
const whole = digits.slice(0, -DIGITS);
|
||||
const fraction = digits.slice(-DIGITS).replace(/0+$/, "");
|
||||
const body = fraction ? `${whole}.${fraction}` : whole;
|
||||
return negative && body !== "0" ? `-${body}` : body;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_header.ts
|
||||
* 여러 층 머리 짜기 — 열마다 `머리`(층 수만큼 글) → 층마다 칸(colspan · rowspan).
|
||||
*
|
||||
* `null` = 위 칸과 합침(세로) · 모자란 층도 `null` 로 채움(위 칸이 늘어남).
|
||||
* 옆 열과 그 층까지 앞머리가 모두 같고 아래 합침 모양도 같으면 가로로 합침.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetColumn } from "./ui_template_sheet_types";
|
||||
|
||||
export interface HeadCell {
|
||||
level: number;
|
||||
col: number;
|
||||
colspan: number;
|
||||
rowspan: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function headDepth(cols: SheetColumn[], layers?: string[]): number {
|
||||
return Math.max(1, layers?.length ?? 0, ...cols.map((c) => c.머리.length));
|
||||
}
|
||||
|
||||
/** 층마다 칸 목록 — 앞 층부터. */
|
||||
export function layoutHead(cols: SheetColumn[], depth: number): HeadCell[][] {
|
||||
const paths = cols.map((c) =>
|
||||
Array.from({ length: depth }, (_, i) => (i === 0 ? (c.머리[0] ?? "") : (c.머리[i] ?? null))),
|
||||
);
|
||||
const span = (j: number, i: number): number => {
|
||||
let k = i + 1;
|
||||
while (k < depth && paths[j][k] === null) k += 1;
|
||||
return k - i;
|
||||
};
|
||||
const samePrefix = (a: number, b: number, i: number): boolean =>
|
||||
paths[a].slice(0, i + 1).every((label, lv) => label === paths[b][lv]);
|
||||
|
||||
const levels: HeadCell[][] = [];
|
||||
for (let i = 0; i < depth; i += 1) {
|
||||
const cells: HeadCell[] = [];
|
||||
let j = 0;
|
||||
while (j < cols.length) {
|
||||
if (paths[j][i] === null) {
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
const rowspan = span(j, i);
|
||||
let colspan = 1;
|
||||
while (
|
||||
j + colspan < cols.length &&
|
||||
paths[j + colspan][i] !== null &&
|
||||
samePrefix(j, j + colspan, i) &&
|
||||
span(j + colspan, i) === rowspan
|
||||
) {
|
||||
colspan += 1;
|
||||
}
|
||||
cells.push({ level: i, col: j, colspan, rowspan, label: paths[j][i] as string });
|
||||
j += colspan;
|
||||
}
|
||||
levels.push(cells);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_ops.ts
|
||||
* 표 문서 고치기 — 줄 · 열 더하기 · 지우기 · 칸 값. 문서를 그 자리에서 바꿈(부른 쪽이 다시 그림).
|
||||
* 화면(DOM)을 모름 — 격자(`ui_template_sheet.ts`)가 부름.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetCell, SheetColumn, SheetDoc, SheetRow } from "./ui_template_sheet_types";
|
||||
|
||||
export type SheetMode = "master" | "project";
|
||||
|
||||
export const ROW_FIXED_ALL = "전구간";
|
||||
|
||||
/** 겹치지 않는 새 id — `c1` · `c2` … */
|
||||
function freshId(prefix: string, used: Iterable<string>): string {
|
||||
const taken = new Set(used);
|
||||
let n = 1;
|
||||
while (taken.has(`${prefix}${n}`)) n += 1;
|
||||
return `${prefix}${n}`;
|
||||
}
|
||||
|
||||
/** 보이는 줄 차례 — 「전구간」 줄이 맨 위 · 나머지는 문서 차례. */
|
||||
export function orderedRows(doc: SheetDoc): SheetRow[] {
|
||||
const fixed = doc.줄.filter((r) => r.고정 === ROW_FIXED_ALL);
|
||||
return [...fixed, ...doc.줄.filter((r) => r.고정 !== ROW_FIXED_ALL)];
|
||||
}
|
||||
|
||||
export const isCalcColumn = (col: SheetColumn): boolean => !!col.식;
|
||||
export const isBoundColumn = (col: SheetColumn): boolean => !!col.바인딩;
|
||||
|
||||
/** 본문 칸을 고칠 수 있나 — master 는 전부(계산 칸은 `=` 식으로) · project 는 손 열만(+ 사용자가 더한 줄의 글 열). */
|
||||
export function cellEditable(mode: SheetMode, col: SheetColumn, row: SheetRow): boolean {
|
||||
if (mode === "master") return true;
|
||||
if (isBoundColumn(col) || isCalcColumn(col)) return false;
|
||||
return !!col.손 || (!!row.손 && col.꼴 === "글");
|
||||
}
|
||||
|
||||
export function canDeleteRow(mode: SheetMode, row: SheetRow): boolean {
|
||||
if (mode === "master") return true;
|
||||
return !!row.손 && row.고정 !== ROW_FIXED_ALL;
|
||||
}
|
||||
|
||||
/** `after` 줄 뒤에 빈 줄(없으면 맨 끝) — 새 줄 id. project 에서 더한 줄은 `손`. */
|
||||
export function addRow(doc: SheetDoc, mode: SheetMode, after: string | null): string {
|
||||
const id = freshId(
|
||||
"r",
|
||||
doc.줄.map((r) => r.id),
|
||||
);
|
||||
const row: SheetRow = { id, 값: {}, ...(mode === "project" ? { 손: true } : {}) };
|
||||
const at = doc.줄.findIndex((r) => r.id === after);
|
||||
doc.줄.splice(at < 0 ? doc.줄.length : at + 1, 0, row);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function deleteRow(doc: SheetDoc, id: string): void {
|
||||
doc.줄 = doc.줄.filter((r) => r.id !== id);
|
||||
}
|
||||
|
||||
/** `after` 열 오른쪽에 같은 묶음의 새 열 — 윗머리는 그대로 · 맨 아래 글만 「새 열」. */
|
||||
export function addColumn(doc: SheetDoc, after: string | null, label: string): string {
|
||||
const id = freshId(
|
||||
"c",
|
||||
doc.열.map((c) => c.id),
|
||||
);
|
||||
const at = doc.열.findIndex((c) => c.id === after);
|
||||
const base = at < 0 ? null : doc.열[at];
|
||||
const head = base ? [...base.머리] : [label];
|
||||
if (base) {
|
||||
let last = head.length - 1;
|
||||
while (last > 0 && head[last] === null) last -= 1;
|
||||
head[last] = label;
|
||||
}
|
||||
const col: SheetColumn = { id, 머리: head, 단위: base?.단위 ?? null, 꼴: "수" };
|
||||
doc.열.splice(at < 0 ? doc.열.length : at + 1, 0, col);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** 열을 지움 — 그 열을 쓰던 식은 남아 오류 칸으로 보임(조용히 고치지 않음). */
|
||||
export function deleteColumn(doc: SheetDoc, id: string): void {
|
||||
doc.열 = doc.열.filter((c) => c.id !== id);
|
||||
for (const row of doc.줄) {
|
||||
delete row.값[id];
|
||||
if (row.식) delete row.식[id];
|
||||
}
|
||||
if (doc.보기?.열너비) delete doc.보기.열너비[id];
|
||||
}
|
||||
|
||||
/** 칸에 친 글 → 저장 값. `=` 로 시작하면 그 칸만의 식(`줄.식`) · 수 열은 수 꼴이면 수 · 빈 글은 칸을 비움. */
|
||||
export function setCell(doc: SheetDoc, rowId: string, col: SheetColumn, text: string): void {
|
||||
const row = doc.줄.find((r) => r.id === rowId);
|
||||
if (!row) return;
|
||||
const trimmed = text.trim();
|
||||
if (row.식) {
|
||||
delete row.식[col.id];
|
||||
if (!Object.keys(row.식).length) delete row.식;
|
||||
}
|
||||
if (trimmed.startsWith("=") && trimmed.slice(1).trim() !== (col.식 ?? "")) {
|
||||
delete row.값[col.id];
|
||||
(row.식 ??= {})[col.id] = trimmed.slice(1).trim();
|
||||
return;
|
||||
}
|
||||
if (trimmed === "" || trimmed.startsWith("=")) {
|
||||
delete row.값[col.id];
|
||||
return;
|
||||
}
|
||||
const plain = trimmed.replace(/,/g, "");
|
||||
const value: SheetCell =
|
||||
col.꼴 !== "글" && /^-?(\d+(\.\d*)?|\.\d+)$/.test(plain) ? Number(plain) : trimmed;
|
||||
// ⚠ 긴 소수는 Number 로 바꾸면 끝자리가 틀어짐 — 보이는 글 그대로 둠(풀이가 십진으로 읽음)
|
||||
row.값[col.id] = typeof value === "number" && String(value) !== plain ? plain : value;
|
||||
}
|
||||
|
||||
export function setColumnWidth(doc: SheetDoc, id: string, width: number): void {
|
||||
doc.보기 ??= {};
|
||||
(doc.보기.열너비 ??= {})[id] = Math.round(width);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_recalc.ts
|
||||
* 표 문서 한 벌 풀이 — 계산 열 · 한 칸만 다른 식(`줄.식`) · 합계 줄(열마다 다른 식) · 변수.
|
||||
*
|
||||
* 화면(조작 중 즉시)과 서버(`common_util_sheet_recalc_node.ts` — [저장] 때 정본 재계산)가
|
||||
* **이 파일 하나**를 같이 씀(CLAUDE.md 5장 ②). 두 벌로 짜면 끝수가 조용히 갈림.
|
||||
* 순환은 풀면서 잡음 — 풀고 있는 칸을 다시 부르면 그 고리의 칸마다 오류.
|
||||
* 오류 칸은 값 없이 `오류` 에 까닭과 함께 — 나머지 칸은 계속 풂.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
type Frac,
|
||||
FormulaError,
|
||||
fracToString,
|
||||
roundAt,
|
||||
toFrac,
|
||||
ZERO,
|
||||
} from "./ui_template_sheet_frac";
|
||||
import {
|
||||
evaluate,
|
||||
parseFormula,
|
||||
type Node,
|
||||
type Scope,
|
||||
type Value,
|
||||
} from "./ui_template_sheet_formula";
|
||||
import type {
|
||||
SheetColumn,
|
||||
SheetDoc,
|
||||
SheetError,
|
||||
SheetResult,
|
||||
SheetRounding,
|
||||
SheetTotal,
|
||||
} from "./ui_template_sheet_types";
|
||||
|
||||
const ROUND_MODE = { 반올림: "round", 올림: "away", 버림: "trunc" } as const;
|
||||
|
||||
export const isNumberColumn = (col: SheetColumn): boolean => col.꼴 !== "글";
|
||||
|
||||
/** 합계 줄 한 칸의 식 — 없으면 null(빈 칸). `"SUM"` 은 그 열의 열 합. */
|
||||
export function totalFormula(total: SheetTotal, col: SheetColumn): string | null {
|
||||
if (!isNumberColumn(col)) return null;
|
||||
const text = typeof total.식 === "string" ? total.식 : (total.식[col.id] ?? total.식["*"]);
|
||||
if (!text || !text.trim()) return null;
|
||||
return text.trim().toUpperCase() === "SUM" ? `SUM([${col.id}])` : text;
|
||||
}
|
||||
|
||||
/** 칸 값 → 수 · 글. 빈 칸은 null. */
|
||||
function inputValue(raw: unknown): Value | null {
|
||||
if (raw === null || raw === undefined || raw === "") return null;
|
||||
if (typeof raw === "number") return toFrac(raw);
|
||||
const text = String(raw).trim();
|
||||
if (text === "") return null;
|
||||
try {
|
||||
return toFrac(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRound(value: Value, rounding: SheetRounding | null | undefined): Value {
|
||||
if (!rounding || typeof value !== "object") return value;
|
||||
return roundAt(value, rounding.자리 ?? 0, ROUND_MODE[rounding.방법] ?? "round");
|
||||
}
|
||||
|
||||
const show = (value: Value): string =>
|
||||
typeof value === "object"
|
||||
? fracToString(value)
|
||||
: typeof value === "boolean"
|
||||
? value
|
||||
? "1"
|
||||
: "0"
|
||||
: value;
|
||||
|
||||
class Failed {
|
||||
constructor(readonly message: string) {}
|
||||
}
|
||||
|
||||
export function recalcSheet(doc: SheetDoc): SheetResult {
|
||||
const cols = new Map(doc.열.map((c) => [c.id, c]));
|
||||
const rows = new Map(doc.줄.map((r) => [r.id, r]));
|
||||
const totals = new Map((doc.합계줄 ?? []).map((t) => [t.id, t]));
|
||||
const parsed = new Map<string, Node | Failed>();
|
||||
const memo = new Map<string, Value | null | Failed>();
|
||||
const visiting = new Set<string>();
|
||||
const result: SheetResult = { 계산: {}, 합계: {}, 오류: [] };
|
||||
|
||||
const parse = (text: string): Node => {
|
||||
let node = parsed.get(text);
|
||||
if (!node) {
|
||||
try {
|
||||
node = parseFormula(text);
|
||||
} catch (error) {
|
||||
node = new Failed(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
parsed.set(text, node);
|
||||
}
|
||||
if (node instanceof Failed) throw new FormulaError(node.message);
|
||||
return node;
|
||||
};
|
||||
|
||||
const formulaOf = (rowId: string, col: SheetColumn): string | null => {
|
||||
const row = rows.get(rowId);
|
||||
if (row) return row.식?.[col.id] ?? col.식 ?? null;
|
||||
return totalFormula(totals.get(rowId)!, col);
|
||||
};
|
||||
|
||||
/** 한 칸 — 식 칸이면 풀어 끝수까지 · 입력 칸이면 값. 빈 칸 null. */
|
||||
const cell = (rowId: string, colId: string): Value | null => {
|
||||
const col = cols.get(colId);
|
||||
if (!col) throw new FormulaError(`없는 열: ${colId}`);
|
||||
if (!rows.has(rowId) && !totals.has(rowId)) throw new FormulaError(`없는 줄: ${rowId}`);
|
||||
const key = `${rowId}\u0000${colId}`;
|
||||
if (memo.has(key)) {
|
||||
const hit = memo.get(key)!;
|
||||
if (hit instanceof Failed) throw new FormulaError(`오류 칸을 씀 [${colId}@${rowId}]`);
|
||||
return hit;
|
||||
}
|
||||
const formula = formulaOf(rowId, col);
|
||||
if (!formula) {
|
||||
const value = rows.has(rowId) ? inputValue(rows.get(rowId)!.값[colId]) : null;
|
||||
memo.set(key, value);
|
||||
return value;
|
||||
}
|
||||
if (visiting.has(key)) throw new FormulaError("돌고 도는 참조");
|
||||
visiting.add(key);
|
||||
try {
|
||||
const value = applyRound(evaluate(parse(formula), scopeFor(rowId)), col.끝수);
|
||||
memo.set(key, value);
|
||||
return value;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
memo.set(key, new Failed(message));
|
||||
result.오류.push({ 줄: rowId, 열: colId, 까닭: message } satisfies SheetError);
|
||||
throw new FormulaError(`오류 칸을 씀 [${colId}@${rowId}]`);
|
||||
} finally {
|
||||
visiting.delete(key);
|
||||
}
|
||||
};
|
||||
|
||||
const scopeFor = (rowId: string): Scope => ({
|
||||
cell: (col, row) => cell(row ?? rowId, col) ?? ZERO,
|
||||
column: (col) => {
|
||||
const out: Frac[] = [];
|
||||
for (const id of rows.keys()) {
|
||||
const value = cell(id, col);
|
||||
if (typeof value === "object" && value !== null) out.push(value);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
variable: (name) => {
|
||||
const raw = doc.변수?.[name];
|
||||
if (raw === undefined) throw new FormulaError(`없는 변수: $${name}`);
|
||||
return inputValue(raw) ?? ZERO;
|
||||
},
|
||||
});
|
||||
|
||||
const solve = (rowId: string, into: Record<string, Record<string, string>>): void => {
|
||||
for (const col of doc.열) {
|
||||
if (!formulaOf(rowId, col)) continue;
|
||||
try {
|
||||
const value = cell(rowId, col.id);
|
||||
(into[rowId] ??= {})[col.id] = value === null ? "" : show(value);
|
||||
} catch {
|
||||
// 까닭은 `오류` 에 이미 적힘
|
||||
}
|
||||
}
|
||||
};
|
||||
for (const id of rows.keys()) solve(id, result.계산);
|
||||
for (const id of totals.keys()) solve(id, result.합계);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 화면 표시용 — 십진 글에 천 단위 쉼표. */
|
||||
export function groupDigits(text: string): string {
|
||||
const match = /^(-?)(\d+)(\.\d+)?$/.exec(text);
|
||||
if (!match) return text;
|
||||
return `${match[1]}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}${match[3] ?? ""}`;
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_render.ts
|
||||
* 표 그리기 — 문서 + 풀이 결과 → 쪽마다 `<table>`(머리 층 · 단위 줄 · 본문 · 합계 줄).
|
||||
*
|
||||
* 칸마다 `data-r`(줄 열쇠) · `data-c`(열 id) — 고치기 · 키보드는 격자(`ui_template_sheet.ts`)가 위임으로 받음.
|
||||
* 줄 열쇠 — 본문 `d:<줄id>` · 합계 `t:<합계id>` · 마스터 줄 `s:unit|formula|desc|price`.
|
||||
* master = 값 대신 열마다 식 · 들어갈 것 · 일위대가 줄 · project = 쪽줄마다 쪽을 나눔(쪽마다 머리 · 합계는 마지막 쪽).
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { headDepth, layoutHead } from "./ui_template_sheet_header";
|
||||
import {
|
||||
cellEditable,
|
||||
isBoundColumn,
|
||||
isCalcColumn,
|
||||
orderedRows,
|
||||
type SheetMode,
|
||||
} from "./ui_template_sheet_ops";
|
||||
import { groupDigits, totalFormula } from "./ui_template_sheet_recalc";
|
||||
import { st } from "./ui_template_sheet_text";
|
||||
import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template_sheet_types";
|
||||
|
||||
export const KEY_UNIT = "s:unit";
|
||||
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
|
||||
const GUTTER = 64;
|
||||
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
|
||||
const PAGE_ROWS = 50;
|
||||
|
||||
export interface RenderState {
|
||||
doc: SheetDoc;
|
||||
mode: SheetMode;
|
||||
result: SheetResult;
|
||||
sel: { r: string; c: string } | null;
|
||||
}
|
||||
|
||||
export const columnWidth = (doc: SheetDoc, col: SheetColumn): number =>
|
||||
doc.보기?.열너비?.[col.id] ?? DEFAULT_WIDTH[col.꼴 === "글" ? "글" : "수"];
|
||||
|
||||
/** 키보드로 옮겨 다니는 줄 차례(쪽을 가로지름). */
|
||||
export function navRows(doc: SheetDoc, mode: SheetMode): string[] {
|
||||
const special = mode === "master" ? [KEY_UNIT, ...MASTER_ROWS] : [KEY_UNIT];
|
||||
return [
|
||||
...special,
|
||||
...orderedRows(doc).map((r) => `d:${r.id}`),
|
||||
...(doc.합계줄 ?? []).map((t) => `t:${t.id}`),
|
||||
];
|
||||
}
|
||||
|
||||
/** 칸을 고칠 수 있나 — 줄 열쇠 기준(머리 칸은 따로). */
|
||||
export function keyEditable(state: RenderState, key: string, col: SheetColumn): boolean {
|
||||
if (key.startsWith("s:")) return state.mode === "master";
|
||||
if (key.startsWith("t:")) return false;
|
||||
const row = state.doc.줄.find((r) => `d:${r.id}` === key);
|
||||
return !!row && cellEditable(state.mode, col, row);
|
||||
}
|
||||
|
||||
const shown = (text: string | undefined): string =>
|
||||
!text || text === "0" ? "" : groupDigits(text);
|
||||
|
||||
function kindOf(col: SheetColumn): { cls: string; label: string } | null {
|
||||
if (isBoundColumn(col)) return { cls: "is-bound", label: st("Kind_Bound") };
|
||||
if (isCalcColumn(col)) return { cls: "is-calc", label: st("Kind_Calc") };
|
||||
if (col.손) return { cls: "is-hand", label: st("Kind_Hand") };
|
||||
return null;
|
||||
}
|
||||
|
||||
export function renderSheet(state: RenderState): HTMLElement {
|
||||
const { doc, mode } = state;
|
||||
const cols = doc.열;
|
||||
const depth = headDepth(cols, doc.층);
|
||||
const head = layoutHead(cols, depth);
|
||||
const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭]));
|
||||
for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭);
|
||||
const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length);
|
||||
const lefts: number[] = [];
|
||||
let left = GUTTER;
|
||||
for (const col of cols) {
|
||||
lefts.push(left);
|
||||
left += columnWidth(doc, col);
|
||||
}
|
||||
|
||||
const freeze = (cell: HTMLElement, from: number, span = 1): void => {
|
||||
if (from + span > frozen) return;
|
||||
cell.classList.add("is-frozen");
|
||||
cell.style.left = `${lefts[from]}px`;
|
||||
};
|
||||
const gutter = (tag: "th" | "td", text: string, cls = ""): HTMLElement =>
|
||||
el(tag, { className: `ui-sheet__gutter ${cls}`.trim(), text });
|
||||
|
||||
const bodyCell = (key: string, col: SheetColumn, j: number, text: string): HTMLElement => {
|
||||
const td = el("td", { text, attrs: { "data-r": key, "data-c": col.id } });
|
||||
const kind = kindOf(col);
|
||||
if (kind) td.classList.add(kind.cls);
|
||||
if (col.꼴 !== "글") td.classList.add("is-num");
|
||||
td.classList.add(keyEditable(state, key, col) ? "is-editable" : "is-locked");
|
||||
const why = errors.get(`${key}|${col.id}`);
|
||||
if (why) {
|
||||
td.classList.add("is-error");
|
||||
td.textContent = st("Error");
|
||||
td.title = why;
|
||||
} else if (text) td.title = text;
|
||||
if (state.sel?.r === key && state.sel.c === col.id) td.classList.add("is-selected");
|
||||
freeze(td, j);
|
||||
return td;
|
||||
};
|
||||
|
||||
const dataRow = (row: SheetRow, n: number): HTMLElement => {
|
||||
const key = `d:${row.id}`;
|
||||
const tr = el("tr", { children: [gutter("th", String(n))] });
|
||||
if (row.고정) tr.classList.add("is-fixed");
|
||||
cols.forEach((col, j) => {
|
||||
const formula = row.식?.[col.id] ?? col.식;
|
||||
const raw = row.값[col.id];
|
||||
const text = formula
|
||||
? shown(state.result.계산[row.id]?.[col.id])
|
||||
: raw === null || raw === undefined
|
||||
? ""
|
||||
: col.꼴 !== "글" && typeof raw === "number"
|
||||
? groupDigits(String(raw))
|
||||
: String(raw);
|
||||
const td = bodyCell(key, col, j, text);
|
||||
if (row.식?.[col.id]) td.classList.add("is-override");
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
};
|
||||
|
||||
const masterRow = (key: (typeof MASTER_ROWS)[number]): HTMLElement => {
|
||||
const label = { "s:formula": "Row_Formula", "s:desc": "Row_Desc", "s:price": "Row_Unit_Price" }[
|
||||
key
|
||||
] as "Row_Formula" | "Row_Desc" | "Row_Unit_Price";
|
||||
const tr = el("tr", { className: "ui-sheet__meta", children: [gutter("th", st(label))] });
|
||||
cols.forEach((col, j) => {
|
||||
const text =
|
||||
key === "s:formula"
|
||||
? (col.식 ?? "")
|
||||
: key === "s:desc"
|
||||
? (col.설명 ?? "")
|
||||
: (col.일위대가 ?? "");
|
||||
const td = bodyCell(key, col, j, text);
|
||||
td.classList.remove("is-num");
|
||||
const kind = kindOf(col);
|
||||
if (key === "s:desc" && kind) {
|
||||
const chip = el("span", {
|
||||
className: `ui-sheet__chip ${kind.cls}`,
|
||||
text: col.펼침 ? `${kind.label} · ${st("Kind_Spread")}` : kind.label,
|
||||
});
|
||||
td.prepend(chip);
|
||||
}
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
};
|
||||
|
||||
const thead = (): HTMLElement => {
|
||||
const rows = head.map((cells, level) => {
|
||||
const tr = el("tr", { children: [gutter("th", doc.층?.[level] ?? "")] });
|
||||
for (const cell of cells) {
|
||||
const th = el("th", {
|
||||
text: cell.label,
|
||||
attrs: {
|
||||
colspan: String(cell.colspan),
|
||||
rowspan: String(cell.rowspan),
|
||||
"data-level": String(cell.level),
|
||||
"data-col": cols[cell.col].id,
|
||||
},
|
||||
});
|
||||
if (mode === "master") th.classList.add("is-editable");
|
||||
freeze(th, cell.col, cell.colspan);
|
||||
tr.append(th);
|
||||
}
|
||||
return tr;
|
||||
});
|
||||
const unit = el("tr", {
|
||||
className: "ui-sheet__unit",
|
||||
children: [gutter("th", st("Row_Unit"))],
|
||||
});
|
||||
cols.forEach((col, j) => {
|
||||
const th = bodyCell(KEY_UNIT, col, j, col.단위 ?? "");
|
||||
th.classList.remove("is-num");
|
||||
th.append(el("span", { className: "ui-sheet__resize", attrs: { "data-resize": col.id } }));
|
||||
unit.append(th);
|
||||
});
|
||||
return el("thead", { children: [...rows, unit] });
|
||||
};
|
||||
|
||||
const totalRows = (): HTMLElement[] =>
|
||||
(doc.합계줄 ?? []).map((total) => {
|
||||
const tr = el("tr", { className: "ui-sheet__total", children: [gutter("th", total.이름)] });
|
||||
cols.forEach((col, j) => {
|
||||
const td = bodyCell(`t:${total.id}`, col, j, shown(state.result.합계[total.id]?.[col.id]));
|
||||
if (totalFormula(total, col)) td.title = totalFormula(total, col)!;
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
});
|
||||
|
||||
const colgroup = el("colgroup", {
|
||||
children: [
|
||||
el("col", { attrs: { style: `width:${GUTTER}px` } }),
|
||||
...cols.map((col) =>
|
||||
el("col", { attrs: { style: `width:${columnWidth(doc, col)}px`, "data-col": col.id } }),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const data = orderedRows(doc);
|
||||
const size = mode === "project" ? Math.max(1, doc.쪽줄 ?? PAGE_ROWS) : Math.max(1, data.length);
|
||||
const pageCount = Math.max(1, Math.ceil(data.length / size));
|
||||
const pages: HTMLElement[] = [];
|
||||
for (let p = 0; p < pageCount; p += 1) {
|
||||
const slice = data.slice(p * size, (p + 1) * size);
|
||||
const body = el("tbody", {
|
||||
children: [
|
||||
...(mode === "master" && p === 0 ? MASTER_ROWS.map(masterRow) : []),
|
||||
...slice.map((row, i) => dataRow(row, p * size + i + 1)),
|
||||
...(p === pageCount - 1 ? totalRows() : []),
|
||||
],
|
||||
});
|
||||
const table = el("table", {
|
||||
className: "ui-sheet__table",
|
||||
attrs: { style: `width:${left}px` },
|
||||
children: [colgroup.cloneNode(true) as HTMLElement, thead(), body],
|
||||
});
|
||||
if (pageCount > 1) {
|
||||
table.prepend(
|
||||
el("caption", {
|
||||
text: `${p + 1} / ${pageCount} ${st("Page")}`,
|
||||
className: "ui-sheet__page",
|
||||
}),
|
||||
);
|
||||
}
|
||||
pages.push(table);
|
||||
}
|
||||
return el("div", { className: "ui-sheet__pages", children: pages });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_text.ts
|
||||
* 표 부품 글자 — [한국어, 영어]
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Row_Add: ["줄 더하기", "Add row"],
|
||||
Row_Delete: ["줄 지우기", "Delete row"],
|
||||
Col_Add: ["열 더하기", "Add column"],
|
||||
Col_Delete: ["열 지우기", "Delete column"],
|
||||
Col_Hand: ["손 입력 열", "Manual column"],
|
||||
Col_New: ["새 열", "New column"],
|
||||
Row_Unit: ["단위", "Unit"],
|
||||
Row_Formula: ["식", "Formula"],
|
||||
Row_Desc: ["들어갈 것", "Source"],
|
||||
Row_Unit_Price: ["일위대가", "Unit price"],
|
||||
Kind_Bound: ["설계값", "Design"],
|
||||
Kind_Calc: ["계산", "Calc"],
|
||||
Kind_Hand: ["손 입력", "Manual"],
|
||||
Kind_Spread: ["값마다 열", "Per value"],
|
||||
Page: ["쪽", "page"],
|
||||
Error: ["#오류", "#ERR"],
|
||||
Hint: [
|
||||
"칸을 누르고 바로 치거나 Enter · F2 로 고침 · = 로 시작하면 그 칸만 식 · 화살표 · Tab 으로 옮김 · Delete 로 비움",
|
||||
"Type or press Enter / F2 to edit · start with = for a cell formula · arrows / Tab to move · Delete to clear",
|
||||
],
|
||||
} as const;
|
||||
|
||||
export function st(key: keyof typeof TEXT): string {
|
||||
return TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* =============================================================================
|
||||
* ui_template_sheet_types.ts
|
||||
* 표 문서(마스터 템플릿 표 양식) 모양 — `tmp/M02_분석/3_표양식.md` 4장 + 창끼리 계약 「표 문서」.
|
||||
*
|
||||
* 줄 `값` 에는 입력만 둠 — 계산 열 · 합계 줄 값은 저장하지 않고 열 때마다 다시 풂.
|
||||
* 식은 열 id · 줄 id 로 참조 — 줄 · 열을 끼우거나 지워도 식이 안 깨짐.
|
||||
* ========================================================================== */
|
||||
|
||||
export type SheetCell = string | number | null;
|
||||
|
||||
/** 끝수 — 방법 이름은 M01 `master_formula` 와 같음(반올림 · 올림 · 버림). */
|
||||
export interface SheetRounding {
|
||||
자리: number;
|
||||
방법: "반올림" | "올림" | "버림";
|
||||
}
|
||||
|
||||
/** 설계 출처 — sub4 가 채움. 표 부품은 있으면 project 에서 그 열을 잠금. */
|
||||
export interface SheetBinding {
|
||||
종류: string;
|
||||
펼침?: string[];
|
||||
값?: string;
|
||||
}
|
||||
|
||||
export interface SheetColumn {
|
||||
id: string;
|
||||
/** 층 수만큼 · `null` = 위 칸과 합침(세로) · 옆 열과 앞머리가 같으면 가로로 합침. */
|
||||
머리: (string | null)[];
|
||||
단위?: string | null;
|
||||
꼴?: "수" | "글";
|
||||
/** 줄마다 같은 식 — 있으면 계산 칸(잠김). */
|
||||
식?: string;
|
||||
끝수?: SheetRounding | null;
|
||||
/** 마스터에서 보일 「들어갈 것」 글. */
|
||||
설명?: string;
|
||||
바인딩?: SheetBinding | null;
|
||||
/** 일위대가 조합 키. */
|
||||
일위대가?: string | null;
|
||||
/** 마스터 한 칸 → 프로젝트에서 설계 값마다 열. */
|
||||
펼침?: boolean;
|
||||
/** 사용자 입력 열. */
|
||||
손?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetRow {
|
||||
id: string;
|
||||
값: Record<string, SheetCell>;
|
||||
/** 이 줄에서만 다른 식 — 열 식을 이김. */
|
||||
식?: Record<string, string>;
|
||||
/** `전구간` = 맨 위 손 입력 줄. */
|
||||
고정?: string;
|
||||
/** 사용자가 더한 줄 — project 에서 측점을 고치고 지울 수 있음. */
|
||||
손?: boolean;
|
||||
}
|
||||
|
||||
export interface SheetTotal {
|
||||
id: string;
|
||||
이름: string;
|
||||
/** `"SUM"` = 수 열마다 열 합 · 묶음 = 열마다 다른 식(`"*"` = 나머지 열). */
|
||||
식: string | Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SheetView {
|
||||
열너비?: Record<string, number>;
|
||||
틀고정?: { 열?: number };
|
||||
}
|
||||
|
||||
export interface SheetDoc {
|
||||
양식: string;
|
||||
종류: "표";
|
||||
판: number;
|
||||
층?: string[];
|
||||
변수?: Record<string, string | number>;
|
||||
/** 한 쪽 줄 수 — 쪽마다 머리 반복 · 합계는 마지막 쪽. */
|
||||
쪽줄?: number;
|
||||
열: SheetColumn[];
|
||||
줄: SheetRow[];
|
||||
합계줄?: SheetTotal[];
|
||||
보기?: SheetView;
|
||||
}
|
||||
|
||||
export interface SheetError {
|
||||
줄: string;
|
||||
열: string;
|
||||
까닭: string;
|
||||
}
|
||||
|
||||
/** 재계산 결과 — 값은 십진 문자열(끝수 뒤). 화면과 서버 Node 가 같은 모양을 냄. */
|
||||
export interface SheetResult {
|
||||
계산: Record<string, Record<string, string>>;
|
||||
합계: Record<string, Record<string, string>>;
|
||||
오류: SheetError[];
|
||||
}
|
||||
Reference in New Issue
Block a user