Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1

This commit is contained in:
2026-11-07 17:10:32 +09:00
20 changed files with 1423 additions and 331 deletions
@@ -16,6 +16,7 @@ from M01_MasterData import M01_MasterData_Store_Make as make
_ERR = (store.cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError)
import master_equation as meq # noqa: E402 — `store` 가 scripts 를 길에 둔 뒤
import master_equation_link as link # noqa: E402
import master_equation_edit as me # noqa: E402
@@ -81,6 +82,11 @@ def preview(key: str, edits: list, inputs: dict | None = None) -> dict:
return {"되나": True, "까닭": [], "줄": new, "수식": eq, "전": before, "후": eq["합계"]}
def links(ask: dict) -> dict:
"""새 기호 연결 후보(800-3) — {갈래: 표 | 요소 | 로직, 검색, 분류?} → {후보, 더있음} · 읽기만."""
return link.candidates(store.loaded()[1], ask)
def save(
key: str, edits: list, inputs: dict | None = None, version: str = "", confirmed: bool = False
) -> dict:
+12
View File
@@ -372,6 +372,18 @@ def post_logic_equation_preview(body: EquationBody) -> dict:
return _call(equation.preview, body.key, body.고침, body.inputs)
class LinksBody(BaseModel):
갈래: str
검색: str = ""
분류: str = "인력"
@router.post("/logic/equation/links")
def post_logic_equation_links(body: LinksBody) -> dict:
"""새 기호 연결 후보(800-3) — 표 · 요소 · 로직 이름 찾기(읽기만)."""
return _call(equation.links, body.model_dump())
@router.post("/logic/equation/save")
def post_logic_equation_save(body: EquationBody) -> dict:
"""수식 고침 저장 — 검사 통과 뒤 `/save` 길(정본 = 개정확인 · 판 다르면 409)."""
@@ -67,3 +67,47 @@
.m01-eq__why {
margin-top: var(--spacing-8);
}
/* 800-2 — 왼쪽 수식 영역 · 오른쪽 변수 정의 영역 */
.m01-eq__split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(14rem, 20rem);
gap: var(--spacing-12);
align-items: start;
}
.m01-eq__left {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
@container (max-width: 700px) {
.m01-eq__split {
grid-template-columns: minmax(0, 1fr);
}
.m01-eq__split > .ui-eq__defs {
border-left: 0;
border-top: 1px solid var(--color-border);
}
}
.m01-eq__def-edit,
.m01-eq__draft {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid var(--color-border-strong);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.m01-eq__def-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-8);
}
@@ -3,8 +3,10 @@
* M01 단가산출 로직 상세 「수식」 탭(PLAN 796-2 · 요구 655 · 656 · 657) — PPT 수식 꼴(결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이).
* 그림 = 공통 수식 부품(`ui_template_equation*.ts`) · 서버 나무 = `POST /api/m01/logic/equation`(796-1)
* 위 = 옵션 가로(도구줄 + 값 칸 · 바꾸면 다시 셈) · 합계 · 고침 도구(시스템 관리자만)
* 고침 = 줄을 고르면 그 줄만 식 칸(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사) + 숨은 아이콘 줄(분수 · 괄호 · 첨자 · %)
* · 변수를 누르면 변수 창(`_EquationVar`) · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
* 배치(800-2) = 왼쪽 수식 영역(줄 구분 없이 식이 이어짐 + 소계 표) · 오른쪽 변수 정의 영역(고른 옵션 갈래의 변수만 · 값 · 단위 · DB 열쇠 없음)
* 고침(800-3) = 수식 영역의 식 칸을 눌러 그 자리에서(공용 `sheet_formula_cell` · 칠 때마다 `/equation/preview` 검사 · 틀리면 그 자리 빨강 + 까닭)
* · 새 기호를 쓰면 정의 영역에 그 기호 줄이 생겨 연결 지정(`_EquationDefs` · 고침 `연결`) · 이름 · 기호 · 첨자 고침 · 지움도 정의 줄에서
* · 고친 목록(`edits`)은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `/equation/save`(개정 확인)
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
* 페이지 갈래(`page` · 796-2b M02 단가산출 사본) = 같은 부품 · 길 · 고침 목록(문서 `수식고침`) · 옵션 값이 페이지 것 · 저장 = 페이지 [저장]
@@ -13,16 +15,18 @@
import { API_BASE_URL } from "@config/config_frontend";
import { createBanner } from "@ui/ui_template_banner";
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createButton } from "@ui/ui_template_button";
import { createEqChain } from "@ui/ui_template_equation_line";
import { createEqDefs, defSign, flashEqDef } from "@ui/ui_template_equation_defs";
import { createEqSum } from "@ui/ui_template_equation_sum";
import {
eqText,
type EqLine,
type EqSumHooks,
type EqNode,
type EqTree,
type EqVarRow,
} from "@ui/ui_template_equation";
import { lineId } from "@ui/ui_template_equation_line";
import {
el,
hideLoadingOverlay,
@@ -38,15 +42,15 @@ import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_form
import { createValueControl } from "../M02_MasterTemplete/M02_Labor_Values";
import type { ItemInput } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { ApiError, type LogicRow } from "./M01_MasterData_UI_Logic_Api";
import { openAddDialog, openVarDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import {
formulaEdit,
inputsOf,
insertInto,
type EqEdit,
type InsertKind,
} from "./logic/M01_Logic_Equation_Model";
draftRow,
renameEditor,
type Draft,
type DefsHost,
} from "./M01_MasterData_UI_LogicLab_EquationDefs";
import { openAddDialog } from "./M01_MasterData_UI_LogicLab_EquationVar";
import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
import { formulaEdit, inputsOf, unknownOf, type EqEdit } from "./logic/M01_Logic_Equation_Model";
import "./M01_MasterData_UI_LogicLab_Equation.css";
const TEXT = {
@@ -65,13 +69,11 @@ const TEXT = {
],
Options: ["옵션", "Options"],
Wait: ["수식을 불러오는 중", "Loading equation"],
Close: ["식 닫기", "Close formula"],
Hint: ["줄을 눌러 고침 · 키보드 + − × ÷ / ( )", "Click a line to edit · keyboard + − × ÷ / ( )"],
Frac: ["분수 (위) / (아래)", "Fraction"],
Paren: ["괄호", "Parentheses"],
Sub: ["아래첨자 _", "Subscript"],
Sup: ["위첨자 ^", "Superscript"],
Percent: ["% (/ 100)", "Percent"],
Hint: [
"식을 눌러 바로 고침 · 키보드 + − × ÷ / ( )",
"Click a formula to edit · keyboard + − × ÷ / ( )",
],
Apply: ["연결 적용 · 식 넣기", "Apply links and formula"],
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
Leave_Ok: ["버림", "Discard"],
Saved: ["수식을 저장함", "Equation saved"],
@@ -117,7 +119,7 @@ const loadCache = (): Record<string, EqEdit[]> => {
/** 페이지 캐시 갈래(796-2b M02 단가산출 사본) — 길 · 고침 목록 · 옵션 값이 페이지 것 · 저장 = 페이지 [저장] */
export interface EqPage {
ask: <T>(
suffix: "" | "/preview",
suffix: "" | "/preview" | "/links",
body: Record<string, unknown>,
signal?: AbortSignal,
) => Promise<T>;
@@ -140,6 +142,15 @@ export interface LabEquationOptions {
page?: EqPage;
}
/** 식에 처음 쓴 새 기호 — 연결을 고르면 고침 `연결` + 그 식 고침을 한 벌로 보냄 */
interface PendingLink {
line: EqLine;
text: string;
symbols: string[];
rows: Map<string, Draft>;
apply: HTMLElement;
}
export function createLabEquation(opt: LabEquationOptions) {
const bar = el("div", { className: "m01-eq__bar" });
const optionsBar = el("div", { className: "m01-eq__options", attrs: { "data-eq-options": "" } });
@@ -159,7 +170,10 @@ export function createLabEquation(opt: LabEquationOptions) {
let inputs: Record<string, string> = {};
let before: Record<string, unknown> | null = null;
let why = "";
let selected: string | null = null;
let editingLine: string | null = null;
let defEditing: string | null = null;
let pending: PendingLink | null = null;
let defsBox: HTMLElement = el("div");
let ctl: AbortController | null = null;
let timer = 0;
let paintedBlocked = false;
@@ -168,7 +182,11 @@ export function createLabEquation(opt: LabEquationOptions) {
const canEdit = (): boolean => opt.admin && !opt.blocked();
const page = opt.page;
const vals = (): Record<string, string> => page?.values() ?? inputs;
const ask = <T>(suffix: "" | "/preview", body: Record<string, unknown>, signal?: AbortSignal) =>
const ask = <T>(
suffix: "" | "/preview" | "/links",
body: Record<string, unknown>,
signal?: AbortSignal,
) =>
page ? page.ask<T>(suffix, body, signal) : post<T>(`/logic/equation${suffix}`, body, signal);
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(vals()), 고침: edits });
@@ -302,14 +320,97 @@ export function createLabEquation(opt: LabEquationOptions) {
);
}
// ── 고침 도구 ──────────────────────────────────────────────────────
const ICONS: [InsertKind, string, "Frac" | "Paren" | "Sub" | "Sup" | "Percent"][] = [
["frac", "a⁄b", "Frac"],
["paren", "( )", "Paren"],
["sub", "x₂", "Sub"],
["sup", "x²", "Sup"],
["percent", "%", "Percent"],
];
// ── 바로 고침 · 연결 지정 ──────────────────────────────────────────
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
const defsHost: DefsHost = {
commit,
links: (body) => ask("/links", body),
onDropped: (k) => {
if (page) page.setValue(k, null);
else {
delete inputs[k];
drawOptions();
}
},
};
function buildDefs(): HTMLElement {
const edit = canEdit();
const mids = (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠);
return createEqDefs(eq?.정의 ?? [], {
editing: defEditing,
onEdit: edit
? (d) => {
defEditing = defEditing === defSign(d) ? null : defSign(d);
drawDefs();
}
: undefined,
editor: (d) =>
renameEditor(d, defsHost, () => {
defEditing = null;
drawDefs();
}),
drafts: pending ? [...[...pending.rows.values()].map((d) => d.root), pending.apply] : [],
head: edit
? [
createButton({
label: t("Add"),
variant: "ghost",
size: 24,
iconStart: "plus",
onClick: () => openAddDialog({ mids, commit }),
}),
]
: [],
});
}
function drawDefs(): void {
const next = buildDefs();
defsBox.replaceWith(next);
defsBox = next;
}
/** 식 검사가 「모르는 기호」 를 알려 오면 정의 영역에 그 기호 줄을 세움(같은 기호들이면 입력한 것을 지키며 글만 갱신) */
function setPending(line: EqLine, text: string, miss: string[]): void {
if (!miss.length) {
if (!pending) return;
pending = null;
return drawDefs();
}
if (pending && pending.symbols.join("\u0000") === miss.join("\u0000")) {
[pending.line, pending.text] = [line, text];
return;
}
const rows = new Map(miss.map((m) => [m, draftRow(m, defsHost)] as const));
const apply = el("div", {
className: "m01-eq__def-actions",
children: [createButton({ label: t("Apply"), size: 24, onClick: () => void applyPending() })],
});
pending = { line, text, symbols: miss, rows, apply };
drawDefs();
defsBox.scrollTo({ top: 0 });
}
async function applyPending(): Promise<void> {
const p = pending;
if (!p) return;
const list: EqEdit[] = [];
let missing = false;
for (const d of p.rows.values()) {
const made = d.read();
d.say(typeof made === "string" ? made : "");
if (typeof made === "string") missing = true;
else list.push(made);
}
if (missing) return;
const bad = await commit([...list, formulaEdit(p.line, p.text)]);
if (bad.length) return [...p.rows.values()].forEach((d) => d.say(bad.join(" · ")));
drafts.delete(lineId(p.line));
[pending, editingLine] = [null, null];
paint();
}
const check =
(line: EqLine) =>
@@ -319,19 +420,26 @@ export function createLabEquation(opt: LabEquationOptions) {
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
signal,
);
const miss = got.되나 ? [] : unknownOf(got.까닭 ?? []);
setPending(line, text, miss);
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
return {
되나: !!got.되나,
까닭: got.까닭 ?? [],
까닭: miss.length
? [`모르는 기호 ${miss.join(" · ")} — 오른쪽 변수 정의에서 연결을 지정`]
: (got.까닭 ?? []),
알림: got.되나 && now ? [`${t("Total")} ${now}`] : [],
식: got.되나 ? text : null,
};
};
const total = (sums: Record<string, unknown>): string => ("계" in sums ? "계" : "결과");
/** 수식 영역의 식 칸 자리에 들어가는 식 칸 — 바꾸면 미리 셈 · 되면 고침 목록에 굳힘 */
function editor(line: EqLine, id: string): HTMLElement {
const shown = line.입력글 ?? line.식;
const close = (): void => {
[editingLine, pending] = [null, null];
paint();
};
const cell = createFormulaCell({
key: id,
spot: id,
@@ -340,82 +448,28 @@ export function createLabEquation(opt: LabEquationOptions) {
drafts,
check: check(line),
put: (text) => {
if (text === null) return;
if (text === null) return pending ? undefined : close();
void commit([formulaEdit(line, text)]).then((bad) => {
if (bad.length) showToast(bad.join(" · "), "error");
else close();
});
},
className: "m01-eq__fx",
});
const input = (): HTMLInputElement | null => cell.querySelector("input");
const icons = ICONS.map(([kind, glyph, label]) =>
createIconButton({
glyph,
title: t(label),
size: 24,
onClick: () => {
const box = input();
if (!box) return;
const from = box.selectionStart ?? box.value.length;
const got = insertInto(kind, box.value, from, box.selectionEnd ?? from);
box.value = got.text;
box.dispatchEvent(new Event("input", { bubbles: true }));
box.focus();
box.setSelectionRange(got.caret, got.caret);
},
}),
const input = cell.querySelector("input");
// 안 바꾸고 다른 데를 누르면 닫음 · 바꾼 글이 있거나 연결 지정 중이면 그대로
cell.addEventListener("focusout", () =>
window.setTimeout(() => {
if (editingLine === id && !pending && !cell.contains(document.activeElement)) {
if ((input?.value.trim() ?? shown) === shown) close();
}
}, 120),
);
const add = createButton({
label: t("Add"),
variant: "ghost",
size: 24,
iconStart: "plus",
onClick: () =>
openAddDialog({
mids: (eq?.변수표 ?? []).filter((v) => v.갈래 === "중간").map((v) => v.열쇠),
commit,
}),
});
const close = createButton({
label: t("Close"),
variant: "ghost",
size: 24,
onClick: () => ((selected = null), paint()),
});
// 아이콘 줄 — 고른 줄에만(평소 숨김)
const tools = createToolbar({
items: [...icons, "divider", add, "spacer", close],
size: 24,
label: t("Hint"),
});
tools.dataset.eqTools = id;
const wrap = el("div", { className: "m01-eq__edit", children: [cell, tools] });
wrap.dataset.eqEditor = id;
queueMicrotask(() => input()?.focus());
return wrap;
queueMicrotask(() => input?.focus());
return cell;
}
function varDialog(node: { 열쇠?: string }): void {
const v = (eq?.변수표 ?? []).find((x: EqVarRow) => x.열쇠 === node.열쇠);
if (!v || !canEdit()) return;
openVarDialog({
row: v,
value: vals()[v.열쇠] ?? "",
commit,
onValue: (value) => {
if (page) return page.setValue(v.열쇠, value || null);
if (value) inputs[v.열쇠] = value;
else delete inputs[v.열쇠];
drawOptions();
void refresh();
},
onDropped: (k) => {
if (page) return page.setValue(k, null);
delete inputs[k];
drawOptions();
},
});
}
const showDef = (node: EqNode): void => flashEqDef(defsBox, node);
// ── 그림 ──────────────────────────────────────────────────────────
function drawBar(): void {
@@ -466,24 +520,31 @@ export function createLabEquation(opt: LabEquationOptions) {
return;
}
const edit = canEdit();
const sum = createEqSum(eq, open, page?.sum);
body.replaceChildren(
createEqChain(eq.줄, {
path: "",
open,
selected,
resultUnit: eq.결과단위,
onSelect: edit
? (_line, id) => {
selected = selected === id ? null : id;
paint();
}
: undefined,
onVar: edit ? varDialog : undefined,
slot: edit ? editor : undefined,
}),
...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계
);
const keys = eq.줄.filter((l) => l.갈래 === "호표").map((l) => l.열쇠);
const sum = createEqSum(eq, open, page?.sum, keys);
const left = el("div", {
className: "m01-eq__left",
children: [
createEqChain(eq.줄, {
path: "",
open,
resultUnit: eq.결과단위,
onVar: edit ? undefined : showDef, // 고칠 수 있으면 식 어디를 눌러도 그 자리 고침(변수 칩 포함)
editing: editingLine,
onEdit: edit
? (_line, id) => {
editingLine = id;
paint();
}
: undefined,
editor: edit ? editor : undefined,
after: (line) => (line.갈래 === "호표" ? (sum?.inline.get(line.열쇠) ?? null) : null),
}),
...(sum ? [sum.tail] : []), // 799-1 · 800-4 묶음 소계 · 끝수 · 합계
],
});
defsBox = buildDefs();
body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] }));
}
async function save(): Promise<void> {
@@ -494,7 +555,7 @@ export function createLabEquation(opt: LabEquationOptions) {
post("/logic/equation/save", { ...body0(), version, 개정확인: ok }),
);
if (!done) return; // 개정 확인 취소 = 고침 그대로
[edits, selected] = [[], null];
[edits, editingLine] = [[], null];
persist();
showToast(t("Saved"), "success");
await refresh();
@@ -514,7 +575,7 @@ export function createLabEquation(opt: LabEquationOptions) {
async function discard(): Promise<void> {
edits = [];
selected = null;
[editingLine, defEditing, pending] = [null, null, null];
drafts.clear();
persist();
await refresh();
@@ -529,7 +590,8 @@ export function createLabEquation(opt: LabEquationOptions) {
version = nextVersion;
row = nextRow;
if (nextKey !== key) {
[key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null];
[key, eq, rowNow, before, why, editingLine] = [nextKey, null, null, null, "", null];
[defEditing, pending] = [null, null];
inputs = {};
edits = page ? page.edits() : (loadCache()[nextKey] ?? []);
open.clear();
@@ -0,0 +1,323 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_EquationDefs.ts
* 수식 탭 변수 정의 영역 속 고침(PLAN 800-3 · 요구 660-5) — 796-2a 변수 창을 정의 영역 안으로 옮겨 다시 씀.
* ① 이름 · 기호 · 첨자 고침 · 지움 = 정의 줄 아래 칸(`renameEditor`) — 쓰는 변수 지움은 서버가 막고 쓰는 곳을 알림
* ② 새 기호 연결 지정 = 식에 처음 쓴 기호마다 줄(`draftRow`) — 국문 이름 + 연결(입력 · 표 찾기 · 요소 · 로직 · 상수) → 고침 `연결` 한 건
* 표 · 요소 · 로직 후보 = 서버 `…/equation/links`(읽기만) · 연결 글(엔진 식)은 서버가 지음 — 화면은 고른 것만 보냄
* 기호 글 쪼개기(`symbolParts`) = 서버 키보드 식 변수 글(`master_equation_edit._TOKEN` 기호_아래^위)과 같은 꼴 — 시험 `test_800_2_equation_layout`
* 값 · 단위는 고치지 않음(요구 660-6) · 계산 · 검사는 서버 한 벌
* ========================================================================== */
import type { EqDef } from "@ui/ui_template_equation";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements_base";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varEdits, type EqEdit } from "./logic/M01_Logic_Equation_Model";
export interface Candidate {
키: string;
이름: string;
규격?: string;
조건?: string[];
칸?: string[];
입력?: string[];
}
export interface DefsHost {
commit: (edits: EqEdit[]) => Promise<string[]>;
links: (ask: Record<string, unknown>) => Promise<{ 후보: Candidate[]; 더있음: boolean }>;
onDropped: (key: string) => void;
}
/** 새 기호 글 → 열쇠 · 기호 · 아래 · 위(`Q_d^2` → 열쇠 `Q_d2` · 기호 Q · 아래 d · 위 2) */
export function symbolParts(token: string): Pick<EqDef, "열쇠" | "기호" | "아래" | "위"> {
const [main = "", up = ""] = token.split("^");
const [sign = "", ...low] = main.split("_");
return { 열쇠: token.replace(/[^\p{L}\p{N}_]/gu, ""), 기호: sign, 아래: low.join("_"), 위: up };
}
const grid = (...kids: HTMLElement[]): HTMLElement =>
el("div", { className: "m01-eq__form", children: kids });
const field = (label: string, value = ""): ReturnType<typeof createInputField> =>
createInputField({ label, value, size: 24 });
const reasonBox = (): { root: HTMLElement; say: (list: string[]) => void } => {
const root = el("div", { className: "m01-eq__why" });
return {
root,
say: (list) =>
root.replaceChildren(
...(list.length
? [createBanner({ tone: "danger", title: "안 됨", text: list.join(" · ") })]
: []),
),
};
};
/** 정의 줄 고침 칸 — 이름 · 기호 · 위 · 아래 · [적용] · [변수 지움] */
export function renameEditor(d: EqDef, host: DefsHost, close: () => void): HTMLElement {
const name = field("이름", d.이름);
const sign = field("기호", d.기호);
const up = field("위첨자", d.위);
const down = field("아래첨자", d.아래);
const why = reasonBox();
const form = (v: Pick<EqDef, "이름" | "기호" | "위" | "아래">) => ({
열쇠: d.열쇠,
...v,
단위: "",
});
const apply = createButton({
label: "적용",
size: 24,
onClick: async () => {
const edits = varEdits(
form(d),
form({
이름: name.input.value.trim(),
기호: sign.input.value.trim(),
위: up.input.value.trim(),
아래: down.input.value.trim(),
}),
);
const bad = edits.length ? await host.commit(edits) : [];
why.say(bad);
if (!bad.length) close();
},
});
const drop = createButton({
label: "변수 지움",
variant: "danger-ghost",
size: 24,
onClick: async () => {
const bad = await host.commit([{ 할일: "지움", 열쇠: d.열쇠 }]);
why.say(bad);
if (!bad.length) host.onDropped(d.열쇠);
},
});
const shut = createButton({ label: "닫기", variant: "ghost", size: 24, onClick: close });
const box = el("div", {
className: "m01-eq__def-edit",
children: [
grid(name.root, sign.root, up.root, down.root),
el("div", { className: "m01-eq__def-actions", children: [drop, shut, apply] }),
why.root,
],
});
box.dataset.eqDefEditor = d.기호;
return box;
}
export interface Draft {
root: HTMLElement;
/** 연결 지정 고침 — 아직 덜 골랐으면 까닭 글 */
read: () => EqEdit | string;
say: (why: string) => void;
}
const KINDS = [
{ value: "입력", text: "입력 (옵션 칸에 생김)" },
{ value: "표", text: "표 찾기 (표 · 조건 · 칸)" },
{ value: "요소", text: "요소 (인력 · 재료 · 기계)" },
{ value: "로직", text: "로직 부르기" },
{ value: "상수", text: "상수 (고정 수)" },
];
const CATS = [
{ value: "인력", text: "인력" },
{ value: "재료", text: "재료" },
{ value: "기계", text: "기계" },
];
/** 후보 찾기 — 검색 글을 칠수록 서버 후보 · 고르기 */
function pick(
host: DefsHost,
ask: () => Record<string, unknown>,
onPick: (c: Candidate | null) => void,
label: string,
): { root: HTMLElement; get: () => Candidate | null; refresh: () => void } {
const box = field(label);
const list = createSelectField({
options: [{ value: "", text: "— 검색해서 고름 —" }],
size: 24,
ariaLabel: label,
});
let found: Candidate[] = [];
let timer = 0;
const get = (): Candidate | null => found[Number(list.select.value)] ?? null;
const refresh = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const got = await host.links({ ...ask(), 검색: box.input.value.trim() }).catch(() => null);
found = got?.후보 ?? [];
const more = got?.더있음 ? [{ value: "", text: "… 더 있음 — 검색을 더 칠 것" }] : [];
list.setOptions(
[
{ value: "", text: found.length ? "— 고름 —" : "— 없음 —" },
...found.map((c, i) => ({
value: String(i),
text: `${c.이름}${c.규격 ? ` · ${c.규격}` : ""}`,
})),
...more,
],
"",
);
onPick(null);
}, 250);
};
box.input.addEventListener("input", refresh);
list.select.addEventListener("change", () => onPick(list.select.value === "" ? null : get()));
return {
root: grid(box.root, list.root),
get: () => (list.select.value === "" ? null : get()),
refresh,
};
}
type Reader = () => EqEdit | string;
const inputKind = (): { nodes: HTMLElement[]; read: Reader } => {
const base = field("기본값 (빈 칸 = 옵션에서 고름)");
const text = (): string => base.input.value.trim();
return {
nodes: [base.root],
read: () => ({ 할일: "연결", 갈래: "입력", ...(text() ? { 기본값: text() } : {}) }),
};
};
const constKind = (): { nodes: HTMLElement[]; read: Reader } => {
const val = field("값");
return {
nodes: [val.root],
read: () =>
val.input.value.trim()
? { 할일: "연결", 갈래: "상수", 값: val.input.value.trim() }
: "상수 값을 적을 것",
};
};
const elementKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
const cat = createSelectField({ label: "분류", options: CATS, value: "인력", size: 24 });
const find = pick(
host,
() => ({ 갈래: "요소", 분류: cat.select.value }),
() => undefined,
"요소 이름",
);
cat.select.addEventListener("change", find.refresh);
find.refresh();
return {
nodes: [cat.root, find.root],
read: () => {
const one = find.get();
return one ? { 할일: "연결", 갈래: "요소", 요소: one.키 } : "요소를 고를 것";
},
};
};
const logicKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
const sub = grid();
const args = new Map<string, ReturnType<typeof createInputField>>();
const find = pick(
host,
() => ({ 갈래: "로직" }),
(c) => {
args.clear();
sub.replaceChildren(
...(c?.입력 ?? []).map((n) => {
const f = field(`${n} = (식 · 빈 칸 = 기본)`);
args.set(n, f);
return f.root;
}),
);
},
"로직 이름",
);
return {
nodes: [find.root, sub],
read: () => {
const one = find.get();
if (!one) return "부를 로직을 고를 것";
const 인자 = Object.fromEntries([...args].map(([n, f]) => [n, f.input.value.trim()]));
return { 할일: "연결", 갈래: "로직", 로직: one.키, 인자 };
},
};
};
const tableKind = (host: DefsHost): { nodes: HTMLElement[]; read: Reader } => {
const sub = grid();
const conds = new Map<string, ReturnType<typeof createInputField>>();
const cols = createSelectField({ label: "칸", options: [{ value: "", text: "—" }], size: 24 });
const find = pick(
host,
() => ({ 갈래: "표" }),
(c) => {
conds.clear();
cols.setOptions(
[{ value: "", text: "— 칸 —" }, ...(c?.칸 ?? []).map((v) => ({ value: v, text: v }))],
"",
);
sub.replaceChildren(
...(c?.조건 ?? []).map((n) => {
const f = field(`조건 ${n} = (변수 기호 · 식)`);
conds.set(n, f);
return f.root;
}),
);
},
"표 이름",
);
find.refresh();
return {
nodes: [find.root, cols.root, sub],
read: () => {
const one = find.get();
if (!one || !cols.select.value) return "표와 칸을 고를 것";
const 조건 = Object.fromEntries([...conds].map(([n, f]) => [n, f.input.value.trim()]));
return { 할일: "연결", 갈래: "표", 표: one.키, 칸: cols.select.value, 조건 };
},
};
};
/** 새 기호 줄 — 국문 이름 + 연결 고르기 · `read` 가 고침 `연결` 한 건을 줌 */
export function draftRow(token: string, host: DefsHost): Draft {
const parts = symbolParts(token);
const name = field("국문 이름");
const kind = createSelectField({ label: "연결", options: KINDS, value: "입력", size: 24 });
const body = grid();
const why = reasonBox();
let read: Reader = () => "연결을 고를 것";
const mount = (): void => {
const made = { 입력: inputKind, 상수: constKind }[kind.select.value as "입력" | "상수"]?.();
const picked =
made ??
{ 요소: elementKind, 로직: logicKind, 표: tableKind }[
kind.select.value as "요소" | "로직" | "표"
](host);
body.replaceChildren(...picked.nodes);
read = picked.read;
};
kind.select.addEventListener("change", mount);
mount();
const head = el("div", {
className: "m01-eq__def-actions",
children: [
el("span", { className: "ui-text ui-text--muted", text: `새 기호 ${token} — 연결을 지정` }),
],
});
const root = el("div", {
className: "ui-eq__def m01-eq__draft",
children: [head, grid(name.root, kind.root), body, why.root],
});
root.dataset.eqDraft = token;
return {
root,
say: (text) => why.say(text ? [text] : []),
read: () => {
const made = read();
if (typeof made === "string") return made;
const nameText = name.input.value.trim();
return { ...made, ...parts, ...(nameText ? { 이름: nameText } : {}) };
},
};
}
@@ -8,7 +8,7 @@
import type { EqLine } from "@ui/ui_template_equation";
export interface EqEdit {
할일: "식" | "더함" | "지움" | "변수" | "표시";
할일: "식" | "더함" | "지움" | "변수" | "표시" | "연결";
[key: string]: unknown;
}
@@ -112,3 +112,12 @@ export function addEdit(form: VarForm, after?: string): EqEdit {
} else if (form.기본값) out.기본값 = form.기본값;
return out;
}
/** 서버 까닭 「… · 모르는 기호 「A」 「B」 — …」 → 새 기호 글 목록(`master_equation_edit._set_formula` 가 같은 글을 냄) */
export function unknownOf(reasons: string[]): string[] {
for (const why of reasons) {
const hit = /모르는 기호((?:\s*「[^」]+」)+)/.exec(why);
if (hit?.[1]) return [...hit[1].matchAll(/「([^」]+)」/g)].map((m) => m[1] ?? "");
}
return [];
}
@@ -163,6 +163,11 @@ def read(doc: Any, inputs: dict | None = None) -> dict[str, Any]:
return out | ({"reason": lost[0]["까닭"]} if lost else {})
def links(doc: Any, ask: dict) -> dict[str, Any]:
"""새 기호 연결 후보(800-3) — M01 과 같은 후보 길(읽기만)."""
return _eqn().links(ask)
def preview(doc: Any, edits: list, inputs: dict | None = None) -> dict[str, Any]:
"""고침 통째 미리 셈(저장 없음) — {되나, 까닭, 줄, 수식, 전(고침 없는 사본 SK 금액), 후}."""
from M02_MasterTemplete import M02_Sangeun_Equation_Full as full
+20
View File
@@ -251,6 +251,26 @@ def sangeun_equation(
return _call(_root(session, layer, owner), _sd(eqm.read), body.문서, body.inputs)
class LinksBody(BaseModel):
문서: dict[str, Any] | None = None
갈래: str
검색: str = ""
분류: str = "인력"
블록: int | None = None
@router.post("/layers/{layer}/sangeun/equation/links")
def sangeun_equation_links(
layer: Layer,
body: LinksBody,
owner: str | None = None,
session: Session = Depends(verify_session),
) -> dict:
"""새 기호 연결 후보(800-3) — 표 · 요소 · 로직 이름 찾기(읽기만)."""
ask = {k: getattr(body, k) for k in ("갈래", "검색", "분류")}
return _call(_root(session, layer, owner), _sd(eqm.links), body.문서 or {}, ask)
@router.post("/layers/{layer}/sangeun/equation/preview")
def sangeun_equation_preview(
layer: Layer,
@@ -28,7 +28,9 @@ from pathlib import Path
import master_auto as ma
import master_cut as mcut
import master_equation_view as view
import master_formula as mf
import master_material as mm
from common_util.common_util_money import fmt
@@ -195,10 +197,18 @@ def _raw(node) -> str:
return me.raw_text(node)
def me_keyboard(ctx: _Ctx, node) -> str:
"""조건 식 마디 → 키보드 한 줄 글(변수 = 기호) — 정의 영역 「조건 규격 = 폼타이규격」."""
import master_equation_edit as me
return me.keyboard_of(_raw(node), ctx.row)
def _find(ctx: _Ctx, node, column: str) -> dict:
"""찾기(표, 조건).칸 → 찾기 변수 · 표 · 고른 행."""
raw = _raw(("field", node, column))
more = {"식": raw, "칸": column}
more["조건식"] = {k: me_keyboard(ctx, c) for k, c in node[2]}
try:
ref = mf.fill(node[1], ctx.env)
table_row = ctx.master.get(ref)
@@ -451,6 +461,11 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
total = {k: cut.get(k) for k in mf.BUNDLE}
if depth == 0:
sub = {"소계": subtotals(row, cut, total)}
defs = (
{"정의": view.definitions(out_lines, row, table(row, master), _unused(row))}
if depth == 0
else {}
)
return _clean(
{
"열쇠": row.get("키"),
@@ -461,10 +476,18 @@ def export(master, key: str, given: dict | None = None, depth: int = 0) -> dict:
"변수표": table(row, master),
"합계": total,
**sub,
**defs,
}
)
def _unused(row: dict):
"""열쇠 → 어느 식에도 안 쓰이나(정의 영역 · 지울 수 있는 입력)."""
import master_equation_edit as me
return lambda key: key != mm.REGION and not me.uses(row, key)
def _item(ctx: _Ctx, at: int, item: dict, line: dict) -> dict:
"""호표 줄 = 「단가 × 수량식 = 금액」 · 로직 줄 단가 = 블록."""
name = str(item.get("이름") or "")
@@ -10,6 +10,7 @@
{할일: 지움, 열쇠} — 쓰는 식이 있으면 막고 쓰는 곳 목록
{할일: 변수, 열쇠, 이름?, 기호?, 위?, 아래?, 단위?} — 식은 열쇠로 부르니 안 깨짐
{할일: 표시, 열쇠, 표시: 0~6 | null}
{할일: 연결, 열쇠, 갈래: 입력|표|요소|로직|상수, …} — 새 기호의 연결 지정(`master_equation_link.py` 머리)
고친 중간 줄은 `표시` 없으면 `SHOW`(보이는 자리에서 잘라 셈 · 요구 658) — 고치지 않은 줄은 그대로.
키보드 한 줄 식 = 변수 글(기호_아래^위 · 이름 · 열쇠) · `× ÷ −` · 나머지는 엔진 식 말 그대로
(`[노임 …]` · `[별칭]` 은 M01 · M02 층이 `M02_Sangeun_Formula.to_engine` 으로 먼저 풂).
@@ -22,6 +23,7 @@ import re
from decimal import Decimal, InvalidOperation
import master_equation as meq
import master_equation_link as link
import master_formula as mf
KEY_FORM = re.compile(r"^[^\W\d][\w]{0,19}$") # 열쇠 = 글자로 시작 · 20 자까지
@@ -227,12 +229,39 @@ def _text_of(ed: dict, row: dict, words) -> tuple[str, list[str]]:
return words(text, row) if words and text else (text, [])
def unknown(text: str, row: dict) -> list[str]:
"""엔진 식 글 안 모르는 변수 글(변수 · 열쇠 · 예약어 · 함수 · 칸 · 인자 이름 · 요소 키 빼고)."""
known = set(_names(row)[0]) | _all_keys(row) | RESERVED
out, at = [], 0
while at < len(text):
if text[at] in "'[":
end = text.find("'" if text[at] == "'" else "]", at + 1)
at = len(text) if end < 0 else end + 1
continue
m = None if at and text[at - 1].isalnum() else _TOKEN.match(text, at)
if not m:
at += 1
continue
word, before, rest = m.group(), text[:at].rstrip(), text[m.end() :].lstrip()
at = m.end()
arg = rest.startswith("=") and not rest.startswith("==")
skip = before.endswith(".") or rest.startswith("(") or arg
if not (skip or word in known or mf.mk.KEY.fullmatch(word) or word in out):
out.append(word)
return out
def _set_formula(row: dict, ed: dict, why: list, spots: set, words=None) -> None:
spot = str(ed.get("자리") or "")
text, bad = _text_of(ed, row, words)
if bad:
why += [f"{spot} · {w}" for w in bad]
return
if not isinstance(ed.get("나무"), dict) and (miss := unknown(text, row)):
why.append(
f"{spot} · 모르는 기호 「{'」 「'.join(miss)}」 — 변수 정의 영역에서 연결을 지정"
)
return
if not text:
why.append(f"{spot} · 식이 빔")
return
@@ -383,6 +412,9 @@ def apply(master, row: dict, edits: list, words=None) -> tuple[dict, list[str],
_look(new, key, ed, why)
elif job == "표시":
_show(new, key, ed, why)
elif job == "연결":
add, bad = link.plan(master, ed)
_add(new, add, why, spots, words) if add else why.append(bad)
else:
why.append(f"모르는 할일 「{job}」")
except (mf.FormulaError, KeyError, TypeError, ValueError) as e:
@@ -0,0 +1,125 @@
# -*- coding: utf-8 -*-
"""수식 변수 연결 지정(PLAN 800-3 · 요구 660-5) — 새 기호가 무엇과 이어지는지 고르는 서버 몫.
고침 `{할일: 연결, 열쇠, 갈래, 이름?, 기호?, 위?, 아래?, 뒤?, …}` 을 기존 `더함` 한 건으로 풂(`plan`):
입력 {기본값?, 단위?} → 입력 변수(옵션 칸에 생김)
표 {표, 칸, 조건: {열: 식글}} → 중간 줄 「찾기(표, 열=식…).칸」
요소 {요소: 키} → 중간 줄 「키」(인력 · 재료 · 기계 단가)
로직 {로직: 키, 인자?: {칸: 식}} → 중간 줄 「로직(키, 칸=식…)」
상수 {값: 수} → 중간 줄 수
후보 목록 = `candidates`(표 · 요소 · 로직) — 읽기만 · 값 · 열쇠는 이름 옆 보관용(화면은 이름만 보임).
셈 · 검사는 `master_equation_edit.apply` 가 한 벌로(여기는 글만 짓고 막을 것만 막음).
"""
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation
LIMIT = 30
KINDS = ("입력", "표", "요소", "로직", "상수")
ELEMS = {"인력": ("LB",), "재료": ("MT", "MO"), "기계": ("EQ",)}
TABLES = ("QC", "QF")
_KEY = re.compile(r"[A-Z]{2}\d{6}")
_COL = re.compile(r"^[^\W\d][\w]*$")
def _q(word: str) -> str:
return word if _COL.match(word) else f"'{word}'"
def _row(master, key: str):
try:
return master.get(key)
except Exception: # noqa: BLE001 — 마스터 없는 열쇠 = 어떤 오류든 「없음」
return None
def plan(master, ed: dict) -> tuple[dict | None, str]:
"""연결 고침 → (더함 고침, 까닭) — 까닭이 있으면 더함 없음."""
kind = ed.get("갈래")
add = {
"할일": "더함",
**{k: ed[k] for k in ("열쇠", "이름", "기호", "위", "아래", "단위", "뒤") if k in ed},
}
if kind not in KINDS:
return None, f"연결 갈래 「{kind}」 — {' · '.join(KINDS)}"
if kind == "입력":
add |= {"갈래": "입력"} | (
{"기본값": ed["기본값"]} if ed.get("기본값") not in (None, "") else {}
)
return add, ""
add["갈래"] = "중간"
if kind == "상수":
try:
add["식"] = format(Decimal(str(ed.get("값"))).normalize(), "f")
except (InvalidOperation, ValueError):
return None, "상수는 수로 적어야 함"
elif kind == "요소":
key = str(ed.get("요소") or "")
if (
not _KEY.fullmatch(key)
or key[:2] not in sum(ELEMS.values(), ())
or not _row(master, key)
):
return None, "요소를 골라야 함(인력 · 재료 · 기계)"
add["식"] = key
elif kind == "로직":
key = str(ed.get("로직") or "")
if not _KEY.fullmatch(key) or not key.startswith("G") or not _row(master, key):
return None, "부를 로직을 골라야 함"
args = "".join(
f", {_q(k)}={v}" for k, v in (ed.get("인자") or {}).items() if str(v).strip()
)
add["식"] = f"로직({key}{args})"
else:
key, col = str(ed.get("표") or ""), str(ed.get("칸") or "")
table = _row(master, key) if _KEY.fullmatch(key) else None
if not table or "조건" not in table or col not in (table.get("값칸") or {}):
return None, "표와 칸을 골라야 함"
given = ed.get("조건") or {}
miss = [c for c in table["조건"] if not str(given.get(c) or "").strip()]
if miss:
return None, f"표 조건을 채워야 함 — {' · '.join(miss)}"
cond = "".join(f", {_q(c)}={given[c]}" for c in table["조건"])
add["식"] = f"찾기({key}{cond}).{_q(col)}"
return add, ""
def _hit(text: object, words: list[str]) -> bool:
low = str(text or "").lower()
return all(w in low for w in words)
def candidates(master, ask: dict) -> dict:
"""후보 {갈래: 표 | 요소 | 로직, 검색, 분류?(요소)} → {후보: [{키, 이름, …}]} — 최대 `LIMIT` 건."""
kind, words = ask.get("갈래"), str(ask.get("검색") or "").lower().split()
out: list[dict] = []
if kind == "표":
pools = [(k, r) for p in TABLES for k, r in master.index.get(p, {}).items() if "조건" in r]
for key, r in pools:
if _hit(r.get("이름"), words):
out.append(
{
"키": key,
"이름": r.get("이름"),
"조건": list(r["조건"]),
"칸": list(r.get("값칸") or {}),
}
)
elif kind == "요소":
for p in ELEMS.get(str(ask.get("분류") or "인력"), ()):
for key, r in master.index.get(p, {}).items():
if (words or p != "MT") and _hit(f"{r.get('이름')} {r.get('규격') or ''}", words):
out.append({"키": key, "이름": r.get("이름"), "규격": r.get("규격") or ""})
elif kind == "로직":
for key, r in master.index.get("GC", {}).items():
if words and _hit(r.get("이름"), words):
out.append(
{
"키": key,
"이름": r.get("이름"),
"입력": [s["이름"] for s in r.get("입력") or []],
}
)
return {"후보": out[:LIMIT], "더있음": len(out) > LIMIT}
@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""수식 변수 정의 영역(PLAN 800-2 · 요구 660-2 · 3 · 6) — 수식 나무 → 오른쪽 「변수 정의」 목록.
줄 구분 없이 한 목록 · 같은 변수는 한 번만(겹침 합침) · 기호 : 국문 이름 — 연결(입력 · 표 찾기 ·
요소 · 로직 · 계산을 사람 말로) · 값 · 단위 · DB 열쇠 글은 담지 않음.
지금 고른 옵션 갈래만 — 만약의 탄 갈래 표지 `걸림`(1 참 · 2 거짓 · 0 모름 = 둘 다)을 그대로 씀(새 판정 없음) ·
`안씀` = 어느 식에도 안 쓰이는 입력(지울 수 있음) · 쓰는 변수 지움 막기는 `master_equation_edit._drop`.
"""
from __future__ import annotations
CAT = {"LB": "인력", "EQ": "기계", "MT": "재료", "MO": "재료"}
WHAT = {
"입력": "입력",
"중간": "계산",
"결과": "계산 결과",
"찾기": "표 찾기",
"요소": "요소",
"블록": "로직 부르기",
}
def visible(node: dict | None, out: list[dict]) -> list[dict]:
"""나무에서 보이는(탄 갈래) 변수 · 블록 마디를 차례대로."""
if not isinstance(node, dict):
return out
kind = node.get("꼴")
if kind in ("변수", "블록"):
out.append(node)
elif kind == "조건":
visible(node.get("조건"), out)
for key, at in (("참", 1), ("거짓", 2)):
if node.get("걸림") in (0, None, at):
visible(node.get(key), out)
else:
for key in ("좌", "우", "안", "위", "아래"):
visible(node.get(key), out)
for arg in node.get("인자") or []:
visible(arg, out)
return out
def _link(node: dict, spec: dict | None, leaf: dict | None) -> str:
"""연결 설명 — 사람 말(열쇠 · 값 · 단위 없음)."""
kind = node.get("갈래") or ("블록" if node.get("꼴") == "블록" else "")
if kind == "입력":
return "입력 — " + ("옵션에서 고름" if (spec or {}).get("고르기") else "직접 적음")
if kind == "중간" and leaf is not None and leaf is not node:
return _link(leaf, None, None)
if kind == "상수":
return f"상수 — {node.get('글')}"
if kind == "찾기":
cond = " · ".join(f"{k} = {v}" for k, v in (node.get("조건식") or {}).items())
head = f"표 찾기 — {node.get('표이름') or '표'}"
return head + (f" · 조건 {cond}" if cond else "") + f" · 칸 {node.get('칸') or ''}".rstrip()
if kind == "요소":
pick = node.get("고르기")
if isinstance(pick, dict):
return "요소 · 재료 고름 — " + " · ".join(str(v) for v in pick.values() if v)
return f"요소 · {CAT.get(str(node.get('식') or '')[:2], '단가')} — {node.get('이름') or ''}"
if kind == "블록":
return f"로직 부르기 — {node.get('이름') or ''}"
return WHAT.get(kind, "계산") + (" — 수식 영역의 식" if kind in ("중간", "결과") else "")
def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[dict]:
"""보이는 식의 변수 정의 목록 — `rows` = 변수 표 · `unused(열쇠)` = 어느 식에도 안 쓰임."""
specs = {str(s.get("이름")): s for k in ("입력", "중간") for s in row.get(k) or []}
table = {v["열쇠"]: v for v in rows}
got: dict[str, dict] = {}
leaf: dict[str, dict] = {}
for line in lines:
head, tree = line.get("결과"), line.get("이름식")
if line.get("갈래") in ("중간", "결과") and isinstance(tree, dict):
if tree.get("꼴") == "수": # 상수 — 연결 설명에 그 수
leaf[str(line.get("열쇠"))] = {"갈래": "상수", "글": tree.get("글")}
elif tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식
leaf[str(line.get("열쇠"))] = tree
found = ([head] if head and line.get("갈래") in ("중간", "결과") else []) + visible(
tree, []
)
for node in found:
key = str(node.get("열쇠") or "")
if key and key not in got and node.get("갈래") in (*WHAT, "합", None):
got[key] = node
out: dict[tuple, dict] = {} # 같은 기호 글 = 같은 정의(겹침 합침) — 연결은 모아 보임
for key, node in got.items():
mine = table.get(key)
look = mine or node
kind = node.get("갈래") or "블록"
if kind == "합":
kind = "결과" if key == "결과" else "중간"
if key.startswith("줄."): # 위 호표 줄 금액 부름
name, link = key[2:].strip("'"), f"호표 줄 금액 — {key[2:].strip(chr(39))}"
else:
link = _link({**node, "갈래": kind}, specs.get(key), leaf.get(key))
if kind == "블록" and not leaf.get(key):
link = "로직 부르기" # 이름이 곧 그 로직 — 되풀이 안 함
name = (look.get("이름") or key) if mine else look.get("이름") or ""
name = node.get("칸") or name if kind == "찾기" else name
one = {
"열쇠": key if mine else "",
"기호": (look.get("기호") or name) if not key.startswith("줄.") else name,
"위": look.get("위") or "",
"아래": look.get("아래") or "",
"이름": name,
"갈래": kind,
"연결": [link],
"고침": bool(mine),
}
same = out.setdefault((one["기호"], one["위"], one["아래"]), one)
if same is not one:
same["연결"] += [x for x in one["연결"] if x not in same["연결"]]
if one["열쇠"] and not same["열쇠"]:
same |= {k: one[k] for k in ("열쇠", "이름", "갈래", "고침")}
out = list(out.values())
seen = {d["열쇠"] for d in out}
for v in rows: # 어느 식에도 안 쓰이는 입력 — 지울 수 있게
if v["갈래"] == "입력" and v["열쇠"] not in seen and unused(v["열쇠"]):
out.append(
{
"열쇠": v["열쇠"],
"기호": v["기호"],
"위": v["위"],
"아래": v["아래"],
"이름": v["이름"],
"갈래": "입력",
"연결": ["입력 — 쓰는 식 없음"],
"고침": True,
"안씀": True,
}
)
return out
+1 -1
View File
@@ -296,7 +296,7 @@ def test_이름_기호_첨자_바꿈_전파():
def test_검사_까닭():
cases = {
"없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 이름"),
"없는 이름": ("중간:환산거리", "환산계수 × 없는값", "모르는 기호"),
"괄호": ("중간:환산거리", "(환산계수 × L", "없음"),
"아래 줄": ("중간:무게", "일운반회수 × 2", "아래 줄"),
"순환": ("중간:무게", "무게 + 1", "자기 자신"),
@@ -32,7 +32,6 @@ def test_부품_상자만_날_단추_입력_없음():
# 수식 칩 = 글 속 부품(span role) · 단추 · 입력 · 고르기는 부품 상자 것
page = _text(PAGE[0]) + _text(PAGE[1])
for part in (
"createIconButton",
"createButton",
"createToolbar",
"createFormulaCell",
@@ -84,15 +83,17 @@ def test_수식_탭이_첫_탭_단가산출_상세는_그대로():
assert "equation.canLeave()" in lab # 저장 전 떠나면 버림 물음
def test_아이콘_줄은_고른_줄에만_고침은_관리자만():
def test_식_바로_고침은_관리자만():
# 800-3 — 줄을 눌러 여는 아래 편집 · 아이콘 줄을 걷고 식 칸을 그 자리에서 고침
line = _text(PARTS[1])
assert "o.selected === id ? o.slot?.(line, id) : null" in line # 고른 줄에만 식 칸 · 아이콘 줄
assert "o.selected" not in line and "o.slot" not in line and "onSelect" not in line
assert "o.editing === id && o.editor" in line # 이름 식 칸 자리에 식 칸
page = _text(PAGE[0])
for icon in ('"frac"', '"paren"', '"sub"', '"sup"', '"percent"'):
assert icon in page, icon # 분수 · 괄호 · 첨자 · %
for icon in ('"frac"', '"paren"', '"sub"', '"sup"'):
assert icon not in page, icon # 아이콘 줄 걷음
assert 'label: t("Add")' in page # 변수 더함
assert "opt.admin && !opt.blocked()" in page # 관리자 + 시트 쪽 저장 안 한 고침 없을 때만
assert "onSelect: edit" in page and "onVar: edit" in page and "slot: edit" in page
assert "onEdit: edit" in page and "editor: edit" in page
assert "createFormulaCell" in page and "../A00_Common/sheet_formula_cell" in page
@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""PLAN 800-2 · 800-3 — 수식 탭 변수 정의 영역 · 바로 고침 · 새 기호 연결 (서버 몫 · 임시 쓰기 없음)."""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path[:0] = [str(ROOT), str(ROOT / "resources/master_data/scripts")]
from M01_MasterData import M01_MasterData_Equation as eq # noqa: E402
from M01_MasterData import M01_MasterData_Store as store # noqa: E402
KEY = "GC000211"
OFF = {"폼타이": "아님", "측압": "3t/㎡"}
ON = {"폼타이": "사용", "측압": "4t/㎡"}
def _defs(inputs):
return eq.read(KEY, inputs)["수식"]["정의"]
def _text(defs):
return json.dumps(defs, ensure_ascii=False)
def test_defs_merge_and_clean():
defs = _defs(OFF)
signs = [(d["기호"], d["위"], d["아래"]) for d in defs]
assert len(signs) == len(set(signs)), "같은 변수는 한 번만"
assert not re.search(r"[A-Z]{2}\d{6}", _text(defs)), "DB 열쇠 글 0"
assert all(set(d) <= {"열쇠", "기호", "위", "아래", "이름", "갈래", "연결", "고침", "안씀"} for d in defs)
by = {d["기호"]: d for d in defs}
assert len(by["보통인부"]["연결"]) >= 3 # 중간 줄 + 표 둘 — 한 줄로 합침
assert by["사용횟수"]["연결"] == ["입력 — 옵션에서 고름"]
def test_unpicked_branches_hidden():
off, on = _text(_defs(OFF)), _text(_defs(ON))
for col in ("4t/㎡", "5t/㎡", "6t/㎡", "폼타이 소요량"):
assert col not in off
assert "칸 4t/㎡" in on and "칸 3t/㎡" not in on and "칸 5t/㎡" not in on
assert "측압" in on and "측압" not in off # 폼타이 아님이면 측압 갈래도 안 탐
def test_live_edit_preview_changes_total():
spot = {"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2"}
got = eq.preview(KEY, [spot], OFF)
assert got["되나"] and got["후"]["계"] > got["전"]["계"]
def test_unknown_symbol_asks_link_then_links():
spot = {"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2 + Z"}
got = eq.preview(KEY, [spot], OFF)
assert not got["되나"] and "모르는 기호 「Z」" in got["까닭"][0]
for link in (
{"갈래": "상수", "값": "0"},
{"갈래": "입력", "기본값": "0"},
{"갈래": "표", "표": "QC000431", "칸": "형틀목공", "조건": {"유형": "유형"}},
{"갈래": "요소", "요소": "LB000007"},
):
add = {"할일": "연결", "열쇠": "Z", "이름": "새값", **link}
got = eq.preview(KEY, [add, spot], OFF)
assert got["되나"], (link, got["까닭"])
assert any(d["기호"] == "Z" for d in got["수식"]["정의"])
def test_link_bad_input_blocked():
bad = [
{"갈래": "표", "표": "QC000431", "칸": "없는칸", "조건": {"유형": "유형"}},
{"갈래": "표", "표": "QC000431", "칸": "형틀목공", "조건": {}},
{"갈래": "요소", "요소": "GC000211"},
{"갈래": "상수", "값": "abc"},
]
for link in bad:
got = eq.preview(KEY, [{"할일": "연결", "열쇠": "Z", **link}], OFF)
assert not got["되나"], link
def test_drop_guard_and_unused_input():
got = eq.preview(KEY, [{"할일": "지움", "열쇠": "사용횟수"}], OFF)
assert not got["되나"] and "쓰는 곳" in got["까닭"][0]
add = {"할일": "연결", "열쇠": "Y", "갈래": "입력"}
got = eq.preview(KEY, [add], OFF)
unused = [d for d in got["수식"]["정의"] if d["기호"] == "Y"]
assert unused and unused[0].get("안씀")
assert eq.preview(KEY, [add, {"할일": "지움", "열쇠": "Y"}], OFF)["되나"]
def test_candidates():
whole = store.loaded()[1]
got = eq.links({"갈래": "표", "검색": "합판거푸집 폼타이", "분류": ""})
assert got["후보"] and "조건" in got["후보"][0]
assert eq.links({"갈래": "요소", "검색": "형틀목공", "분류": "인력"})["후보"]
assert eq.links({"갈래": "로직", "검색": "합판거푸집", "분류": ""})["후보"]
assert whole is not None
def test_groups_add_up_to_total():
"""800-4 — 묶음(블록 · 자원 · 덧줄) 소계 합 + 끝수 = 합계(비목마다) · 한 묶음 소계 = 노 + 재 + 경."""
for key in ("GC001148", "GC000211"):
eqn = eq.read(key, {})["수식"]
sub, total = eqn["소계"], eqn["합계"]
for k in ("노무비", "재료비", "경비"):
got = sum(float(r[k]) for r in sub["줄"]) + float(sub["끝수"][k])
assert abs(got - float(total[k])) < 1e-6, (key, k)
assert all(
abs(float(r["계"]) - sum(float(r[k]) for k in ("노무비", "재료비", "경비"))) < 1e-6
for r in sub["줄"]
)
def test_screen_source_groups_and_inline_edit():
ui = ROOT / "ui_template"
sum_ts = (ui / "ui_template_equation_sum.ts").read_text(encoding="utf-8")
line_ts = (ui / "ui_template_equation_line.ts").read_text(encoding="utf-8")
page = (ROOT / "M01_MasterData/M01_MasterData_UI_LogicLab_Equation.ts").read_text(encoding="utf-8")
assert "ORDER" in sum_ts and "ui-eq__grp" in sum_ts # 가 · 나 · 다 묶음
assert "o.after?.(line)" in line_ts and "after:" in page # 블록 식 줄 바로 아래
assert "block-key" not in line_ts # 줄 곁 DB 열쇠 글 없음
assert "ui-eq__solve" not in line_ts and "ui-eq__step" not in line_ts # 풀이 · 줄 번호 걷음
+142 -78
View File
@@ -158,37 +158,17 @@
font-weight: var(--font-weight-medium);
}
/* ── 줄 ── */
/* ── 줄(800-2 — 구분선 · 번호 · 풀이 없음 · 식이 차례로 이어짐) ── */
.ui-eq__line {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-4) 0;
min-width: 0;
}
.ui-eq__line[data-selected] {
box-shadow: inset 3px 0 0 var(--color-accent);
background: var(--color-accent-soft);
}
.ui-eq__line.is-pick > .ui-eq__main {
cursor: pointer;
}
.ui-eq__main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
gap: var(--spacing-12);
align-items: start;
}
.ui-eq__step {
align-self: flex-start;
min-width: 1.4em;
color: var(--color-text-muted);
font-size: var(--text-caption);
min-width: 0;
}
/* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */
@@ -226,51 +206,24 @@
font-size: var(--text-caption);
}
/* 변수 풀이 */
.ui-eq__solve {
display: flex;
flex-direction: column;
gap: 2px;
/* 바로 고침 — 이름 식 칸을 누르면 그 자리에서 식 칸 */
.ui-eq__name.is-edit {
cursor: text;
}
.ui-eq__name.is-edit:hover {
border-color: var(--color-accent);
}
.ui-eq__name.is-open {
flex: 1 1 24rem;
align-items: flex-start;
border-color: var(--color-accent);
}
.ui-eq__name.is-open > * {
flex: 1 1 auto;
min-width: 0;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.ui-eq__solve-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0 var(--spacing-4);
min-width: 0;
}
.ui-eq__solve-row.is-pick {
cursor: pointer;
}
.ui-eq__solve-row.is-pick:hover {
background: var(--color-accent-soft);
}
.ui-eq__solve-name,
.ui-eq__solve-val {
max-width: 12em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-eq__solve-val {
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
}
.ui-eq__solve-src {
color: var(--color-text-muted);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 하위 로직 블록 — 접힘 */
@@ -300,11 +253,6 @@
color: var(--color-text-secondary);
}
.ui-eq__block-key {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-eq__block-body {
margin-top: var(--spacing-4);
}
@@ -313,12 +261,6 @@
padding: var(--spacing-4);
}
@container (max-width: 640px) {
.ui-eq__main {
grid-template-columns: minmax(0, 1fr);
}
}
:is(.ui-eq__block-body, .ui-eq__bad)[hidden] {
display: none !important;
}
@@ -411,3 +353,125 @@
.ui-eq__sum-row[data-eq-sum-kind="합계"] {
font-weight: var(--font-weight-semibold);
}
/* ── 변수 정의 영역(800-2) — 오른쪽 한 덩어리 ── */
.ui-eq__defs {
position: sticky;
top: var(--spacing-8);
align-self: start;
max-height: calc(100vh - var(--spacing-24));
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
padding: var(--spacing-8);
border-left: 1px solid var(--color-border);
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
}
.ui-eq__defs-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-eq__def {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-4);
border-radius: var(--radius-sm);
min-width: 0;
}
.ui-eq__def-top {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.ui-eq__def.is-pick > .ui-eq__def-top {
cursor: pointer;
}
.ui-eq__def.is-pick > .ui-eq__def-top:hover,
.ui-eq__def[data-flash],
.ui-eq__def[data-editing] {
background: var(--color-accent-soft);
}
.ui-eq__def-main {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 var(--spacing-4);
color: var(--color-text-body);
}
.ui-eq__def-name {
overflow-wrap: anywhere;
}
.ui-eq__def-tag {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-eq__def-link {
color: var(--color-text-muted);
font-size: var(--text-caption);
overflow-wrap: anywhere;
}
.ui-eq__def-empty {
color: var(--color-text-muted);
}
/* ── 기준 단가산출별 묶음(800-4 · 요구 661) — 가. 이름 → 노 · 재 · 경 → 소 계 ── */
.ui-eq__grp {
font-size: var(--text-body-sm);
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8) 0 var(--spacing-4);
border-bottom: 1px solid var(--color-border);
}
.ui-eq__grp-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
padding: 0 var(--spacing-8);
font-weight: var(--font-weight-semibold);
}
.ui-eq__grp-line {
display: flex;
align-items: baseline;
gap: var(--spacing-4);
padding: 0 var(--spacing-8) 0 var(--spacing-24);
font-variant-numeric: tabular-nums;
}
.ui-eq__grp-key {
min-width: 4.5em;
color: var(--color-text-secondary);
}
.ui-eq__grp-total .ui-eq__sum-cell::before {
content: attr(data-eq-sum-col) " ";
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.ui-eq__grp-total {
border-bottom: 0;
font-weight: var(--font-weight-medium);
}
+19
View File
@@ -29,6 +29,7 @@ export interface EqNode {
표?: string;
표이름?: string;
행?: Record<string, unknown>;
조건식?: Record<string, string>;
좌?: EqNode;
우?: EqNode;
안?: EqNode;
@@ -89,6 +90,24 @@ export interface EqTree {
소계?: { 줄: EqSumRow[]; 끝수: Record<string, unknown>; 끝수까닭?: string };
/** M02 SK — 셈 답이 안 서서 바탕 줄 합계를 보일 때의 까닭 */
답까닭?: string;
/** 800-2 — 오른쪽 변수 정의 영역(고른 옵션 갈래만 · 같은 기호 한 줄 · 값 · 단위 · DB 열쇠 없음) */
정의?: EqDef[];
}
/** 변수 정의 한 줄 — 기호 : 이름 — 연결(사람 말 · 여럿이면 줄 바꿈) */
export interface EqDef {
/** 변수 열쇠(입력 · 중간 변수만 · 찾기 · 요소는 빈 글) — 화면 글에는 안 나옴 */
열쇠: string;
기호: string;
위: string;
아래: string;
이름: string;
갈래: string;
연결: string[];
/** 이름 · 기호 · 첨자 고침 · 지움이 되는 변수 */
고침: boolean;
/** 어느 식에도 안 쓰이는 입력(지울 수 있음) */
안씀?: boolean;
}
export interface EqVarRow {
+93
View File
@@ -0,0 +1,93 @@
/* =============================================================================
* ui_template_equation_defs.ts
* 부품 「수식」 넷째 파일(800-2 · 요구 660-2 · 3 · 6) — 오른쪽 「변수 정의 영역」.
* 한 덩어리 목록 · 같은 기호는 한 줄(서버가 합침) · 줄 = 기호 : 국문 이름 — 연결 설명(입력 · 표 찾기 · 요소 · 로직 · 계산을 사람 말로)
* 값 · 단위 · DB 열쇠 글은 안 그림(값은 왼쪽 값 식에 있음) · 고른 옵션 갈래의 변수만(서버 `정의`)
* 상태 = 고침 열린 줄 `data-editing`(줄 아래 `editor` 칸) · 연결 안 한 새 기호 줄 `data-draft`(`drafts` 요소 — 부르는 쪽이 만듦) · 눌러 보인 줄 `data-flash`
* 쓰는 법 = `createEqDefs(defs, { editing, onEdit, editor, drafts, head })` → 요소 · `flashEqDef(root, node)` = 식의 변수를 눌렀을 때 그 정의 줄로 이동
* ========================================================================== */
import { symbolOf, type EqDef, type EqNode } from "./ui_template_equation";
export interface EqDefsOptions {
/** 고침이 열린 정의의 기호 글(`defSign`) */
editing?: string | null;
/** 정의 줄을 누름(없으면 고칠 수 없음 — 고침 가능한 줄만 눌림) */
onEdit?: (def: EqDef) => void;
/** 고침이 열린 줄 아래 칸 */
editor?: (def: EqDef) => HTMLElement;
/** 연결을 지정할 새 기호 줄들 */
drafts?: HTMLElement[];
/** 머리 오른쪽 단추 자리 */
head?: HTMLElement[];
}
const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => {
const out = document.createElement(tag);
out.className = className;
if (text) out.textContent = text;
return out;
};
/** 정의의 기호 글(기호 · 아래 · 위) — 줄 이름표 · 식의 변수와 맞대는 열쇠 */
export const defSign = (d: Pick<EqDef, "기호" | "위" | "아래">): string =>
`${d.기호}${d.아래 ? `_${d.아래}` : ""}${d.위 ? `^${d.위}` : ""}`;
/** 식 변수 마디의 기호 글 — `defSign` 과 같은 꼴 */
export const nodeSign = (n: Pick<EqNode, "기호" | "열쇠" | "위" | "아래">): string =>
defSign({
기호: n.기호 || n.열쇠 || "",
위: typeof n.위 === "string" ? n.위 : "",
아래: typeof n.아래 === "string" ? n.아래 : "",
});
function defRow(d: EqDef, o: EqDefsOptions): HTMLElement {
const sign = defSign(d);
const row = node("div", "ui-eq__def");
row.dataset.eqDef = sign;
row.dataset.kind = d.갈래;
const main = node("div", "ui-eq__def-main");
main.append(symbolOf({ 열쇠: d.열쇠, 기호: d.기호, 위: d.위, 아래: d.아래 }));
if (d.이름 && d.이름 !== d.기호)
main.append(node("span", "ui-eq__op", ":"), node("span", "ui-eq__def-name", d.이름));
if (d.안씀) main.append(node("span", "ui-eq__def-tag", "쓰는 식 없음"));
const links = node("div", "ui-eq__def-links");
for (const text of d.연결) links.append(node("div", "ui-eq__def-link", text));
const top = node("div", "ui-eq__def-top", "");
top.append(main, links);
if (o.onEdit && d.고침) {
row.classList.add("is-pick");
top.title = "눌러서 이름 · 기호 · 첨자 고침 · 지움";
top.addEventListener("click", () => o.onEdit?.(d));
}
row.append(top);
if (o.editing === sign && o.editor) {
row.dataset.editing = "1";
row.append(o.editor(d));
}
return row;
}
export function createEqDefs(defs: EqDef[], o: EqDefsOptions = {}): HTMLElement {
const root = node("div", "ui-eq__defs");
root.dataset.eqDefs = "";
const head = node("div", "ui-eq__defs-head");
head.append(node("span", "ui-eq__defs-title", "변수 정의"), ...(o.head ?? []));
root.append(head);
for (const draft of o.drafts ?? []) root.append(draft); // 새 기호는 맨 위 — 식을 고치는 곳 옆에서 바로 보이게
for (const d of defs) root.append(defRow(d, o));
if (!defs.length && !o.drafts?.length) root.append(node("div", "ui-eq__def-empty", "—"));
return root;
}
/** 식의 변수를 눌렀을 때 — 그 정의 줄로 이동 · 잠깐 표시 */
export function flashEqDef(root: HTMLElement, n: EqNode): void {
const sign = nodeSign(n);
const row = [...root.querySelectorAll<HTMLElement>("[data-eq-def]")].find(
(r) => r.dataset.eqDef === sign,
);
if (!row) return;
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
row.dataset.flash = "1";
window.setTimeout(() => delete row.dataset.flash, 1200);
}
+33 -82
View File
@@ -1,13 +1,13 @@
/* =============================================================================
* ui_template_equation_line.ts
* 부품 「수식」 둘째 파일(796-2) — 줄 · 변수 풀이 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`.
* 줄 = 번호 · 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 오른쪽 변수 풀이(기호 : 이름 = 값 단위 · 출처)
* 부품 「수식」 둘째 파일(796-2 · 800-2) — 수식 영역 줄 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`.
* 줄 = 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 줄 번호 · 구분선 · 줄마다 풀이 없음(변수 정의 = `ui_template_equation_defs.ts`)
* 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글)
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 중간 계산이 한눈에 · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
* 크기 = 글 `--text-body` · 풀이 `--text-caption` · 접힘 단추 22
* 상태 = 고른 줄 `data-selected`(왼쪽 띠 · 식 칸 자리 `slot` 이 줄 아래에 열림) · 펼친 블록 `data-open`
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), selected, onSelect, onVar, slot })`
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음)
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
* 크기 = 글 `--text-body` · 접힘 단추 22
* 상태 = 바로 고침 — 이름 식 칸을 누르면 그 자리가 식 칸으로 바뀜(`onEdit` → 부르는 쪽이 `editing` 을 정해 다시 그림 · `editor` 가 칸을 줌) · 펼친 블록 `data-open`
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), onVar, editing, onEdit, editor })`
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) · 줄 눌러 여는 아래 편집 칸(800-3 에 걷음)
* ========================================================================== */
import { createBadge } from "./ui_template_badge";
@@ -27,18 +27,18 @@ export interface EqLineOptions {
path: string;
/** 펼친 블록 이름표 모음 — 다시 그려도 유지 */
open: Set<string>;
/** 고른 줄 이름표(`lineId`) */
selected?: string | null;
/** 줄을 누름(없으면 안 눌림) */
onSelect?: (line: EqLine, id: string) => void;
/** 변수 누름(없으면 눌리지 않음) */
onVar?: (node: EqNode) => void;
/** 고른 줄 아래에 열 칸(식 칸 · 아이콘 줄) */
slot?: (line: EqLine, id: string) => HTMLElement | null;
/** 바로 고치는 줄 이름표(`lineId`) — 이 줄의 이름 식 칸 자리에 `editor` 칸이 들어감 */
editing?: string | null;
/** 이름 식 칸을 누름(없으면 고칠 수 없음) */
onEdit?: (line: EqLine, id: string) => void;
/** 고치는 줄의 식 칸 */
editor?: (line: EqLine, id: string) => HTMLElement;
/** 줄 바로 아래에 끼울 것(하위 로직 블록 소계 묶음 · 800-4) */
after?: (line: EqLine) => HTMLElement | null;
/** 결과 줄 값 뒤 단위 */
resultUnit?: string;
/** 줄 번호 1 부터 보임 */
numbered?: boolean;
}
const BUNDLE: [string, string][] = [
@@ -67,52 +67,6 @@ export const lineId = (line: EqLine): string =>
? `${line.갈래}:${line.차례 ?? 0}`
: `${line.갈래}:${line.열쇠}`;
/** 식 안 변수(찾기 · 입력 · 중간 · 요소) — 열쇠마다 하나 · 블록 속은 안 들어감 */
export function varsOf(node: EqNode | undefined, out = new Map<string, EqNode>()): EqNode[] {
if (!node) return [];
if (node.꼴 === "변수" && ["입력", "중간", "찾기", "요소"].includes(node.갈래 ?? "")) {
if (!out.has(node.열쇠 ?? "")) out.set(node.열쇠 ?? "", node);
}
for (const k of ["좌", "우", "안", "조건", "참", "거짓"] as const) varsOf(node[k], out);
for (const k of ["위", "아래"] as const) {
const child = node[k];
if (typeof child === "object") varsOf(child, out);
}
(node.인자 ?? []).forEach((a) => varsOf(a, out));
return [...out.values()];
}
const SOURCE: Record<string, string> = { 입력: "입력", 중간: "중간 줄", 요소: "요소" };
function solveRow(node: EqNode, o: EqLineOptions): HTMLElement {
const value = eqText(node.값);
const where =
node.갈래 === "찾기"
? [node.표, ...Object.entries(node.행 ?? {}).map(([k, v]) => `${k}=${String(v)}`)]
.filter(Boolean)
.join(" · ")
: (SOURCE[node.갈래 ?? ""] ?? "");
const name = span("ui-eq__solve-name", node.이름 || node.열쇠 || "");
name.title = name.textContent ?? "";
const val = span("ui-eq__solve-val", value ? `${value}${node.단위 ? ` ${node.단위}` : ""}` : "—");
val.title = val.textContent ?? "";
const row = div(
"ui-eq__solve-row",
symbolOf(node),
span("ui-eq__op", ":"),
name,
span("ui-eq__op", "="),
val,
...(where ? [span("ui-eq__solve-src", where)] : []),
);
row.dataset.eqSolve = node.열쇠 ?? "";
if (o.onVar && node.갈래 !== "찾기" && node.갈래 !== "요소") {
row.classList.add("is-pick");
row.addEventListener("click", () => o.onVar?.(node));
}
return row;
}
function bundle(node: EqNode): HTMLElement {
const box = span("ui-eq__bundle");
const got = node.묶음;
@@ -149,7 +103,6 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
path: id,
open: o.open,
resultUnit: node.결과단위 ?? node.나무.결과단위,
numbered: false,
}),
);
} else o.open.delete(id);
@@ -157,10 +110,7 @@ function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
body.hidden = !on;
};
body.hidden = true;
box.append(
div("ui-eq__block-row", head, bundle(node), span("ui-eq__block-key", node.열쇠 ?? "")),
body,
);
box.append(div("ui-eq__block-row", head, bundle(node)), body);
if (o.open.has(id)) toggle(true);
return box;
}
@@ -183,7 +133,7 @@ function markOf(line: EqLine): HTMLElement | null {
}
/** 줄 하나 */
export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTMLElement {
export function createEqLine(line: EqLine, o: EqLineOptions): HTMLElement {
const id = lineId(line);
const root = div("ui-eq__line");
root.dataset.eqLine = id;
@@ -201,7 +151,6 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM
},
};
const body = div("ui-eq__body");
if (o.numbered !== false) body.append(span("ui-eq__step", String(index + 1)));
const head = line.결과;
if (head)
body.append(
@@ -225,10 +174,19 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM
line.글 || "—",
...(resUnit ? [span("ui-eq__unit", resUnit)] : []),
);
let vars: EqNode[] = [];
if (line.이름식) {
vars = varsOf(line.이름식);
const name = span("ui-eq__box ui-eq__name", renderEq(line.이름식, hand));
const name = span("ui-eq__box ui-eq__name");
if (o.editing === id && o.editor) {
name.classList.add("is-open");
name.append(o.editor(line, id));
} else {
name.append(renderEq(line.이름식, hand));
if (o.onEdit) {
name.classList.add("is-edit");
name.title = "눌러서 바로 고침";
name.addEventListener("click", () => o.onEdit?.(line, id));
}
}
body.append(span("ui-eq__op", "="), name);
const same =
plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식);
@@ -245,21 +203,14 @@ export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTM
const mark = markOf(line);
if (mark) body.append(mark);
if (line.뺌) root.dataset.removed = "1";
const solve = div("ui-eq__solve", ...vars.map((v) => solveRow(v, o)));
const main = div("ui-eq__main", body, solve);
if (o.onSelect) {
root.classList.add("is-pick");
main.addEventListener("click", () => o.onSelect?.(line, id));
}
if (o.selected === id) root.dataset.selected = "1";
root.append(main);
root.append(div("ui-eq__main", body));
blocks.forEach((b, i) => root.append(blockPanel(b, i + 1, o)));
const slot = o.selected === id ? o.slot?.(line, id) : null;
if (slot) root.append(slot);
const extra = o.after?.(line);
if (extra) root.append(extra);
return root;
}
/** 여러 줄 위에서 아래로 이음 */
export function createEqChain(lines: EqLine[], o: EqLineOptions): HTMLElement {
return div("ui-eq", ...lines.map((l, i) => createEqLine(l, i, o)));
return div("ui-eq", ...lines.map((l) => createEqLine(l, o)));
}
+90 -42
View File
@@ -1,10 +1,12 @@
/* =============================================================================
* ui_template_equation_sum.ts
* 부품 「수식」 셋째 파일(799-1 · 796-2c · 요구 655-1 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로).
* 표 = 줄(하위 로직 블록마다 한 줄 · 자기 자원 · 덧줄) × 노무비 · 재료비 · 경비 · 계 → 합계(엔진 비목 합) → 끝수(합계 − 줄 합)
* 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 끝수가 0 이면 줄을 안 그림 · 소계가 없으면(결과 로직) 아무것도 안 그림
* M02 SK 줄 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0
* 쓰는 법 = `createEqSum(tree, open?, hooks?)` → 요소 | null — 줄 이음(`createEqChain`) 아래에 붙임 · `open` = 펼친 블록 이름표 모음 ·
* 부품 「수식」 셋째 파일(799-1 · 796-2c · 800-4 · 요구 655-1 · 661 · 요구서_655 2 장 4) — 단가산출 소계 · 합계(보이기만 · 금액 셈 그대로).
* 묶음 = 기준 단가산출마다(하위 로직 블록 · 자기 자원 · 덧줄) 「가. 이름」 머리 → 노무비 · 재료비 · 경비 줄 → 소 계(노 · 재 · 경 · 계 칸)
* 맨 끝 「소 계」 묶음 = 묶음 소계를 더한 식(노무비 : 2,299.4 + 5,162 = 7,461.4) → 끝수(합계 − 묶음 합 · 0 이면 줄 없음) → 합계(엔진 비목 합)
* 값 = 서버 `소계`(M01 `master_equation.subtotals` · M02 `M02_Sangeun_Equation_Full` = SK 셈 답) · 소계가 없으면(결과 로직) 아무것도 안 그림
* M02 SK 묶음 = 상태 알약(더함 · 덮음 · 뺌) · 덮음 갈래 · 원래 금액 · 수량 · 더한 기초는 접힘 수식 블록 · 뺌 = 흐리게 · 금액 0
* 자리 = 하위 로직 블록 묶음은 그 블록 식 줄 바로 아래(`inline` · 줄 열쇠 = 블록 이름 · `createEqChain` 의 `after`) · 나머지(자기 자원 · 덧줄 · 더한 기초) · 맨 끝 소 계 · 끝수 · 합계는 `tail`
* 쓰는 법 = `createEqSum(tree, open?, hooks?, lineKeys?)` → { inline, tail } | null — `lineKeys` = 수식 영역 호표 줄 열쇠들 · `open` = 펼친 블록 이름표 모음 ·
* `hooks` = 페이지가 더한 기초 줄 머리 단추(`head`) · 접힘 속(`body`)을 바꿔 끼움(796-2e 연결 끊기)
* ========================================================================== */
@@ -14,8 +16,14 @@ import { eqText, type EqSumHooks, type EqSumRow, type EqTree } from "./ui_templa
import { createEqChain } from "./ui_template_equation_line";
const COLS = ["노무비", "재료비", "경비", "계"] as const;
const PARTS = ["노무비", "재료비", "경비"] as const;
type Col = (typeof COLS)[number];
const KIND: Record<string, string> = { 자원: "자기 자원", 덧줄: "덧줄" };
const TONE: Record<string, BadgeTone> = { 더함: "success", 덮음: "warning", 뺌: "neutral" };
const ORDER = "가나다라마바사아자차카타파하";
/** 묶음 번호 — 가 · 나 · 다 … (열네 개를 넘으면 숫자) */
const mark = (at: number): string => (at < ORDER.length ? (ORDER[at] ?? "") : String(at + 1));
const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => {
const out = document.createElement(tag);
@@ -24,7 +32,12 @@ const node = (tag: "div" | "span", className: string, text = ""): HTMLElement =>
return out;
};
function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): void {
const num = (v: unknown): number => {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
function cells(line: HTMLElement, get: (k: Col) => unknown): void {
for (const k of COLS) {
const cell = node("span", "ui-eq__sum-cell", eqText(get(k)) || "0");
cell.dataset.eqSumCol = k;
@@ -32,7 +45,7 @@ function cells(line: HTMLElement, get: (k: (typeof COLS)[number]) => unknown): v
}
}
function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unknown): HTMLElement {
function row(kind: string, name: string, get: (k: Col) => unknown): HTMLElement {
const line = node("div", "ui-eq__sum-row");
line.dataset.eqSumKind = kind;
line.append(node("span", "ui-eq__sum-name", name));
@@ -40,19 +53,33 @@ function row(kind: string, name: string, get: (k: (typeof COLS)[number]) => unkn
return line;
}
/** M02 SK 줄 하나 — 이름 + 알약 + 원래 · 수량 / 더한 기초는 접힘 수식 */
function itemRow(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks): HTMLElement {
const item = node("div", "ui-eq__sum-item");
const line = node("div", "ui-eq__sum-row");
line.dataset.eqSumKind = r.갈래;
const name = node("span", "ui-eq__sum-name");
/** 묶음 안 한 줄 「노무비 : 값」 */
const part = (label: string, text: string): HTMLElement => {
const line = node("div", "ui-eq__grp-line");
line.append(
node("span", "ui-eq__grp-key", label),
node("span", "ui-eq__op", ":"),
node("span", "ui-eq__grp-val", text),
);
line.dataset.eqGrpPart = label;
return line;
};
/** 기준 단가산출 묶음 하나 — 「가. 이름」 머리(+ 상태 · 접힘 수식) → 노 · 재 · 경 줄 → 소 계 */
function group(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks): HTMLElement {
const box = node("div", "ui-eq__grp");
box.dataset.eqSumItem = r.갈래;
box.dataset.eqSumState = r.상태 ?? "바탕";
const name = node("div", "ui-eq__grp-head");
const title = r.갈래 === r.이름 ? (KIND[r.갈래] ?? r.이름) : r.이름;
const tree = r.나무;
const id = `sum:${at}:${r.이름}`;
const body = node("div", "ui-eq__sum-tree");
body.hidden = true;
name.append(node("span", "ui-eq__grp-mark", `${mark(at)}.`));
if (tree) {
const head = createButton({
label: r.이름,
label: title,
variant: "ghost",
size: 22,
iconStart: "chevronRight",
@@ -66,68 +93,89 @@ function itemRow(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks)
if (!body.childElementCount)
body.append(
hooks?.body?.(r, at) ??
createEqChain(tree.줄, {
path: id,
open,
resultUnit: tree.결과단위,
numbered: false,
}),
createEqChain(tree.줄, { path: id, open, resultUnit: tree.결과단위 }),
);
} else open.delete(id);
item.dataset.open = on ? "1" : "";
box.dataset.open = on ? "1" : "";
body.hidden = !on;
};
name.append(head);
if (open.has(id)) toggle(true);
} else name.append(node("span", "ui-eq__sum-label", r.이름));
} else name.append(node("span", "ui-eq__sum-label", title));
if (r.상태 && r.상태 !== "바탕") {
const detail = r.덮개?.length ? ` · ${r.덮개.join(" · ")}` : "";
name.append(createBadge(`${r.상태}${detail}`, TONE[r.상태] ?? "neutral", { size: 18 }));
}
name.append(...(hooks?.head?.(r, at) ?? []));
if (r.합밖) {
name.append(createBadge("합 밖", "neutral", { size: 18 }));
line.dataset.eqSumOut = "1";
}
const out = !!r.합밖;
if (out) name.append(createBadge("합 밖", "neutral", { size: 18 }));
const notes: string[] = [];
if (r.합밖) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`);
if (out) notes.push(`합계에 안 듦 ${eqText(r.밖금액) || "0"}`);
if (r.상태 === "더함" && r.수량 !== undefined && r.수량 !== null)
notes.push(`× ${eqText(r.수량)}`);
if (r.원래 !== undefined && r.원래 !== null && r.상태 !== "더함")
notes.push(`원래 ${eqText(r.원래)}`);
if (r.까닭) notes.push(r.까닭);
if (notes.length) name.append(node("span", "ui-eq__sum-was", notes.join(" · ")));
line.dataset.eqSumState = r.상태 ?? "바탕";
line.append(name);
cells(line, (k) => r[k]);
item.dataset.eqSumItem = r.갈래;
item.append(line, body);
return item;
box.append(name, body);
for (const k of PARTS) if (num(r[k]) !== 0) box.append(part(k, eqText(r[k])));
const total = row("소계", "소 계", (k) => r[k]);
total.classList.add("ui-eq__grp-total");
if (out) total.dataset.eqSumOut = "1";
box.append(total);
return box;
}
/** 맨 끝 「소 계」 묶음 — 묶음 소계를 더한 식 */
function final(rows: EqSumRow[], at: number): HTMLElement {
const box = node("div", "ui-eq__grp");
box.dataset.eqSumItem = "소계";
const head = node("div", "ui-eq__grp-head");
head.append(
node("span", "ui-eq__grp-mark", `${mark(at)}.`),
node("span", "ui-eq__sum-label", "소 계"),
);
box.append(head);
for (const k of PARTS) {
const adds = rows.map((r) => num(r[k])).filter((v) => v !== 0);
if (!adds.length) continue;
const text = adds.map((v) => eqText(v)).join(" + ");
const sum = eqText(adds.reduce((a, b) => a + b, 0));
box.append(part(k, adds.length > 1 ? `${text} = ${sum}` : text));
}
return box;
}
export interface EqSumParts {
/** 블록 이름 → 그 블록 식 줄 아래에 끼울 묶음 */
inline: Map<string, HTMLElement>;
/** 수식 영역 맨 끝 — 남은 묶음 · 소 계 · 끝수 · 합계 */
tail: HTMLElement;
}
export function createEqSum(
tree: Pick<EqTree, "합계" | "소계" | "답까닭">,
open: Set<string> = new Set(),
hooks?: EqSumHooks,
): HTMLElement | null {
lineKeys: string[] = [],
): EqSumParts | null {
const sub = tree.소계;
if (!sub?.줄.length) return null;
const inline = new Map<string, HTMLElement>();
const box = node("div", "ui-eq__sum");
box.dataset.eqSum = "";
const head = node("div", "ui-eq__sum-row ui-eq__sum-head");
head.append(node("span", "ui-eq__sum-name", "소계"));
for (const k of COLS) head.append(node("span", "ui-eq__sum-cell", k));
box.append(head);
sub.줄.forEach((r, at) => {
if (r.상태) box.append(itemRow(r, open, at, hooks));
else
box.append(row(r.갈래, r.이름 === r.갈래 ? (KIND[r.갈래] ?? r.이름) : r.이름, (k) => r[k]));
const one = group(r, open, at, hooks);
if (r.갈래 === "블록" && lineKeys.includes(r.이름) && !inline.has(r.이름))
inline.set(r.이름, one);
else box.append(one);
});
box.append(final(sub.줄, sub.줄.length));
const rest = sub.끝수;
const odd = COLS.some((k) => Number(rest[k] ?? 0) !== 0);
if (odd) box.append(row("끝수", "끝수", (k) => rest[k]));
box.append(row("합계", "합계", (k) => tree.합계[k]));
const note = [odd ? sub.끝수까닭 : "", tree.답까닭].filter(Boolean).join(" · ");
if (note) box.append(node("div", "ui-eq__sum-note", note));
return box;
return { inline, tail: box };
}