feat(796-2a): 수식 탭 화면 — 공통 수식 부품 · M01 수식 탭(첫 탭) · 고침 · 변수 창 · 옵션 가로
- ui_template_equation(.ts · _line.ts · .css): 분수 · 첨자 · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이 · 부품 모음 견본 한 줄 - M01 LogicLab_Equation: 옵션 가로(도구줄 + 값 칸 · 찾기형 · 말줄임) · 줄 고르면 식 칸 + 숨은 아이콘 줄 · 서버 미리 셈 다시 그림 · 고침 목록 캐시 → [수식 저장](개정 확인) · 떠나기 전 버림 물음 - 변수 창(이름 · 기호 · 위 · 아래첨자 · 단위 · 값 · 보이는 자리) · 변수 더함 · 지움(쓰는 곳 있으면 서버 까닭 그대로) - 보기 고르기 = 수식 첫 탭 · 단가산출 상세 · 시트 · 인력재료경비 · 시험계산 · 텍스트 수식은 뒤로(걷지 않음) - test_796_2_equation_screen 새로 · test_794_1 기대 갱신(첫 탭 수식 · LogicLab 700 줄 상한 — 수식 이음 14 줄) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ import { saveRevised } from "./M01_MasterData_UI_LogicLab_Revise";
|
||||
import { buildLogicSide } from "./M01_MasterData_UI_LogicLab_Side";
|
||||
import { createViewPicker } from "./M01_MasterData_UI_LogicLab_View";
|
||||
import { createLabSheet } from "./M01_MasterData_UI_LogicLab_Sheet";
|
||||
import { createLabEquation } from "./M01_MasterData_UI_LogicLab_Equation";
|
||||
import { buildHeadBar } from "./M01_MasterData_UI_Head";
|
||||
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
|
||||
import "./M01_MasterData_UI_Logic_Style.css";
|
||||
@@ -111,6 +112,14 @@ export async function mountM01LogicLab(
|
||||
edited: () => (touch(), drawEditor(), drawCalc()), // 줄 사본 → 캐시 → [저장](기존 길)
|
||||
run: () => calc.querySelector<HTMLButtonElement>("[data-calc-run]")?.click(),
|
||||
});
|
||||
const equation = createLabEquation({
|
||||
admin,
|
||||
blocked: () => !!opened && opened.id in drafts, // 시트 · 시험 쪽 저장 안 한 고침이 있으면 수식 고침 잠김
|
||||
onSaved: () => {
|
||||
const was = opened; // reload 가 고름을 비우므로 먼저 기억
|
||||
if (was?.row?.키) void reload().then(() => open(was.id, was.sub, was.row!.키));
|
||||
},
|
||||
});
|
||||
const editor = el("div", { className: "m01-logic__editor" });
|
||||
// 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」
|
||||
const panes = buildLogicSide(editor);
|
||||
@@ -128,7 +137,11 @@ export async function mountM01LogicLab(
|
||||
(item) => void open(item.id, item.sub, item.key),
|
||||
(item, alias) => void onAlias(item, alias),
|
||||
);
|
||||
const back = createButton({ label: tx("Bar_Back"), variant: "ghost", onClick: () => show(null) });
|
||||
const back = createButton({
|
||||
label: tx("Bar_Back"),
|
||||
variant: "ghost",
|
||||
onClick: () => void equation.canLeave().then((ok) => ok && show(null)),
|
||||
});
|
||||
|
||||
const persist = (): void => {
|
||||
try {
|
||||
@@ -243,7 +256,9 @@ export async function mountM01LogicLab(
|
||||
calcHost: calc,
|
||||
admin,
|
||||
sheet: sheet.root,
|
||||
equation: equation.root,
|
||||
});
|
||||
if (current.origKey) equation.draw(current.origKey, current.version, current.row as LogicRow);
|
||||
sheet.draw(current.row as LogicRow, current.original, current.values);
|
||||
};
|
||||
|
||||
@@ -321,7 +336,7 @@ export async function mountM01LogicLab(
|
||||
restore: true,
|
||||
onPick: (sub, detail) => {
|
||||
list.setPick(sub, detail);
|
||||
if (opened) show(null);
|
||||
if (opened) void equation.canLeave().then((ok) => ok && show(null));
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -502,6 +517,7 @@ export async function mountM01LogicLab(
|
||||
show(null);
|
||||
showLoadingOverlay();
|
||||
const openKeyOn = async (key: string): Promise<void> => {
|
||||
if (!(await equation.canLeave())) return;
|
||||
const found = allLogics.find((x) => x.키 === key);
|
||||
if (found) await open(logicId(found.구분, found.키), found.구분, found.키);
|
||||
else showToast(tx("Load_Failed"), "error");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Detail.ts
|
||||
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
|
||||
* 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로(수식 탭 796-2 포함)
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
|
||||
* ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`)
|
||||
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
|
||||
@@ -39,6 +39,8 @@ export interface DetailContext extends HoContext {
|
||||
cover?: { ho: HTMLElement; est: HTMLElement; open?: ReturnType<typeof lineOpener> };
|
||||
/** 794-1 M01 줄 식 시트(`_LogicLab_Sheet`) 그릇 — 있으면 「시트」 덩이(M02 는 안 넘김) */
|
||||
sheet?: HTMLElement;
|
||||
/** 796-2 수식 탭(`_LogicLab_Equation`) 그릇 — 있으면 「수식」 덩이(M02 는 795-1 사본 꼴 뒤에 이음) */
|
||||
equation?: HTMLElement;
|
||||
}
|
||||
|
||||
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
|
||||
@@ -235,6 +237,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
// 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 · 시험 계산은 숨어도 그려 둠
|
||||
host.replaceChildren(
|
||||
...infoBox(ctx),
|
||||
...(ctx.equation ? [block(ctx.equation, "equation")] : []),
|
||||
...(ctx.sheet ? [block(ctx.sheet, "sheet")] : []),
|
||||
block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"),
|
||||
block(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/* 수식 탭(796-2) — 배치만 · 식 그림은 공통 수식 부품 · 색은 공통 값 */
|
||||
.m01-eq {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
.m01-eq__options .ui-toolbar {
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-eq__opt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.m01-eq__opt > .ui-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-eq__ctl {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-eq__ctl :is(select, input) {
|
||||
width: 100%;
|
||||
max-width: 14rem;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.m01-eq__notice:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-eq__edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m01-eq__fx {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-eq__form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
gap: var(--spacing-8) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01-eq__why:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m01-eq__why {
|
||||
margin-top: var(--spacing-8);
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Equation.ts
|
||||
* 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`(개정 확인)
|
||||
* 계산 자리 = 서버 한 벌(③) — 화면은 고침 목록만 보내고 서버가 다시 셈한 나무를 다시 그림(옛 요청 끊음)
|
||||
* 시트 · 시험 쪽에 저장 안 한 고침이 있으면 수식 고침은 잠김(두 갈래가 한 줄을 따로 고치지 않게)
|
||||
* ========================================================================== */
|
||||
|
||||
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 { createEqChain } from "@ui/ui_template_equation_line";
|
||||
import { eqText, type EqLine, type EqTree, type EqVarRow } from "@ui/ui_template_equation";
|
||||
import {
|
||||
el,
|
||||
hideLoadingOverlay,
|
||||
showConfirmDialog,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { createFormulaCell, type FormulaVerdict } from "../A00_Common/sheet_formula_cell";
|
||||
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";
|
||||
import "./M01_MasterData_UI_LogicLab_Equation.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["수식", "Equation"],
|
||||
Save: ["수식 저장", "Save equation"],
|
||||
Discard: ["수식 고침 버림", "Discard edits"],
|
||||
Add: ["변수 더함", "Add variable"],
|
||||
Edits: ["수식 고침 {n} 건 · 저장 안 함", "{n} equation edits · unsaved"],
|
||||
Now: ["지금", "Now"],
|
||||
Before: ["고치기 전", "Before"],
|
||||
Blocked: [
|
||||
"시트 · 시험 쪽에 저장 안 한 고침이 있음 — 먼저 [저장] 또는 [버림] 한 뒤 수식을 고침(수식은 저장본 기준)",
|
||||
"Unsaved edits elsewhere — save or discard first (showing saved version)",
|
||||
],
|
||||
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"],
|
||||
Leave: ["저장 안 한 수식 고침 {n} 건 — 버리고 나감?", "Discard {n} unsaved equation edits?"],
|
||||
Leave_Ok: ["버림", "Discard"],
|
||||
Saved: ["수식을 저장함", "Equation saved"],
|
||||
Total: ["계", "Total"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT, n = 0): string =>
|
||||
(TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]).replace("{n}", String(n));
|
||||
|
||||
interface Read {
|
||||
ok?: boolean;
|
||||
reason?: string;
|
||||
수식?: EqTree;
|
||||
되나?: boolean;
|
||||
까닭?: string[];
|
||||
줄?: LogicRow;
|
||||
전?: Record<string, unknown>;
|
||||
후?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const r = await fetch(`${API_BASE_URL}/m01${path}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
|
||||
if (!r.ok) throw new ApiError(r.status, data.detail ?? r.statusText);
|
||||
return data;
|
||||
}
|
||||
|
||||
const CACHE = "m01_equation_edits";
|
||||
const BIG = 12; // 고르기가 이보다 많으면 찾기형(입력 + 목록)
|
||||
const loadCache = (): Record<string, EqEdit[]> => {
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(CACHE) ?? "{}") as Record<string, EqEdit[]>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export interface LabEquationOptions {
|
||||
/** 시스템 관리자 — 고침 도구 */
|
||||
admin: boolean;
|
||||
/** 시트 · 시험 쪽에 저장 안 한 고침이 있나 */
|
||||
blocked: () => boolean;
|
||||
/** 저장 뒤 — 상세를 저장본으로 다시 엶 */
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
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": "" } });
|
||||
const notice = el("div", { className: "m01-eq__notice" });
|
||||
const body = el("div", { className: "m01-eq__body" });
|
||||
const root = el("div", {
|
||||
className: "m01-eq",
|
||||
attrs: { "data-lab-equation": "" },
|
||||
children: [bar, optionsBar, notice, body],
|
||||
});
|
||||
let key = "";
|
||||
let version = "";
|
||||
let row: LogicRow | null = null;
|
||||
let rowNow: LogicRow | null = null;
|
||||
let eq: EqTree | null = null;
|
||||
let edits: EqEdit[] = [];
|
||||
let inputs: Record<string, string> = {};
|
||||
let before: Record<string, unknown> | null = null;
|
||||
let why = "";
|
||||
let selected: string | null = null;
|
||||
let ctl: AbortController | null = null;
|
||||
let timer = 0;
|
||||
let paintedBlocked = false;
|
||||
const open = new Set<string>();
|
||||
const drafts = new Map<string, string>();
|
||||
const canEdit = (): boolean => opt.admin && !opt.blocked();
|
||||
|
||||
const body0 = (): Record<string, unknown> => ({ key, inputs: inputsOf(inputs), 고침: edits });
|
||||
const persist = (): void => {
|
||||
const all = loadCache();
|
||||
if (edits.length) all[key] = edits;
|
||||
else delete all[key];
|
||||
try {
|
||||
sessionStorage.setItem(CACHE, JSON.stringify(all));
|
||||
} catch {
|
||||
/* 캐시가 막혀도 화면 안 고침은 남음 */
|
||||
}
|
||||
};
|
||||
|
||||
/** 서버 셈 — 고침 목록이 있으면 미리 셈 · 없으면 읽기 · 옛 요청 끊음 */
|
||||
async function refresh(): Promise<void> {
|
||||
ctl?.abort();
|
||||
const mine = (ctl = new AbortController());
|
||||
try {
|
||||
const got = edits.length
|
||||
? await post<Read>("/logic/equation/preview", body0(), mine.signal)
|
||||
: await post<Read>("/logic/equation", body0(), mine.signal);
|
||||
if (mine !== ctl) return;
|
||||
if (got.되나 === false || got.ok === false) {
|
||||
why = (got.까닭 ?? [got.reason ?? ""]).join(" · ");
|
||||
} else {
|
||||
why = "";
|
||||
eq = got.수식 ?? null;
|
||||
rowNow = got.줄 ?? null;
|
||||
before = edits.length ? (got.전 ?? null) : null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (mine.signal.aborted) return;
|
||||
why = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
paint();
|
||||
}
|
||||
|
||||
/** 고침 하나를 더해 보고 되면 굳힘 — 안 되면 까닭 목록 */
|
||||
async function commit(list: EqEdit[]): Promise<string[]> {
|
||||
const got = await post<Read>("/logic/equation/preview", {
|
||||
...body0(),
|
||||
고침: [...edits, ...list],
|
||||
}).catch((error: unknown): Read => ({
|
||||
되나: false,
|
||||
까닭: [error instanceof Error ? error.message : String(error)],
|
||||
}));
|
||||
if (!got.되나) return got.까닭 ?? [];
|
||||
edits = [...edits, ...list];
|
||||
persist();
|
||||
ctl?.abort();
|
||||
why = "";
|
||||
eq = got.수식 ?? eq;
|
||||
rowNow = got.줄 ?? rowNow;
|
||||
before = got.전 ?? before;
|
||||
paint();
|
||||
drawOptions();
|
||||
return [];
|
||||
}
|
||||
|
||||
const askAgain = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => void refresh(), 150);
|
||||
};
|
||||
|
||||
// ── 옵션 가로 ──────────────────────────────────────────────────────
|
||||
function optionNode(spec: NonNullable<LogicRow["입력"]>[number], auto: unknown): HTMLElement {
|
||||
const item: ItemInput = {
|
||||
이름: spec.이름,
|
||||
단위: spec.단위,
|
||||
고르기: spec.고르기,
|
||||
범위: spec.범위,
|
||||
자동값: auto,
|
||||
};
|
||||
const label = createText(spec.이름 + (spec.단위 ? ` (${spec.단위})` : ""), "muted", 12);
|
||||
const set = (v: string | number | null): void => {
|
||||
if (v === null || v === "") delete inputs[spec.이름];
|
||||
else inputs[spec.이름] = String(v);
|
||||
askAgain();
|
||||
};
|
||||
let control: HTMLElement;
|
||||
if ((spec.고르기?.length ?? 0) > BIG) {
|
||||
// 찾기형 — 입력 + 목록(칠수록 좁혀짐)
|
||||
const box = createInputField({
|
||||
value: inputs[spec.이름] ?? "",
|
||||
compact: true,
|
||||
size: 24,
|
||||
placeholder: auto === null || auto === undefined ? "" : String(auto),
|
||||
});
|
||||
const list = el("datalist", {
|
||||
attrs: { id: `m01-eq-list-${spec.이름}` },
|
||||
children: (spec.고르기 ?? []).map((o) => el("option", { attrs: { value: String(o) } })),
|
||||
});
|
||||
box.input.setAttribute("list", list.id);
|
||||
box.input.title = box.input.value || box.input.placeholder;
|
||||
box.input.addEventListener("change", () => {
|
||||
box.input.title = box.input.value;
|
||||
set(box.input.value.trim());
|
||||
});
|
||||
control = el("div", { children: [box.root, list] });
|
||||
} else {
|
||||
const made = createValueControl(item, inputs[spec.이름] ?? null, (v) => set(v));
|
||||
const tip = (): void => {
|
||||
const c = made.control;
|
||||
c.title = c instanceof HTMLSelectElement ? (c.selectedOptions[0]?.text ?? "") : c.value;
|
||||
};
|
||||
made.control.addEventListener("change", tip);
|
||||
made.control.addEventListener("input", tip);
|
||||
tip();
|
||||
control = el("div", { children: [made.control, made.error] });
|
||||
}
|
||||
control.classList.add("m01-eq__ctl");
|
||||
const cell = el("div", { className: "m01-eq__opt", children: [label, control] });
|
||||
cell.dataset.eqOption = spec.이름;
|
||||
return cell;
|
||||
}
|
||||
|
||||
function drawOptions(): void {
|
||||
const specs = (rowNow ?? row)?.입력 ?? [];
|
||||
const table = new Map((eq?.변수표 ?? []).map((v) => [v.열쇠, v]));
|
||||
optionsBar.replaceChildren(
|
||||
...(specs.length
|
||||
? [
|
||||
createToolbar({
|
||||
items: specs.map((s) => optionNode(s, table.get(s.이름)?.기본값 ?? null)),
|
||||
size: 24,
|
||||
label: t("Options"),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 고침 도구 ──────────────────────────────────────────────────────
|
||||
const ICONS: [InsertKind, string, "Frac" | "Paren" | "Sub" | "Sup" | "Percent"][] = [
|
||||
["frac", "a⁄b", "Frac"],
|
||||
["paren", "( )", "Paren"],
|
||||
["sub", "x₂", "Sub"],
|
||||
["sup", "x²", "Sup"],
|
||||
["percent", "%", "Percent"],
|
||||
];
|
||||
|
||||
const check =
|
||||
(line: EqLine) =>
|
||||
async (text: string, signal: AbortSignal): Promise<FormulaVerdict> => {
|
||||
const got = await post<Read>(
|
||||
"/logic/equation/preview",
|
||||
{ ...body0(), 고침: [...edits, formulaEdit(line, text)] },
|
||||
signal,
|
||||
);
|
||||
const now = got.후 ? eqText(got.후[total(got.후)]) : "";
|
||||
return {
|
||||
되나: !!got.되나,
|
||||
까닭: 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 cell = createFormulaCell({
|
||||
key: id,
|
||||
spot: id,
|
||||
shown,
|
||||
origin: shown,
|
||||
drafts,
|
||||
check: check(line),
|
||||
put: (text) => {
|
||||
if (text === null) return;
|
||||
void commit([formulaEdit(line, text)]).then((bad) => {
|
||||
if (bad.length) showToast(bad.join(" · "), "error");
|
||||
});
|
||||
},
|
||||
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 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;
|
||||
}
|
||||
|
||||
function varDialog(node: { 열쇠?: string }): void {
|
||||
const v = (eq?.변수표 ?? []).find((x: EqVarRow) => x.열쇠 === node.열쇠);
|
||||
if (!v || !canEdit()) return;
|
||||
openVarDialog({
|
||||
row: v,
|
||||
value: inputs[v.열쇠] ?? "",
|
||||
commit,
|
||||
onValue: (value) => {
|
||||
if (value) inputs[v.열쇠] = value;
|
||||
else delete inputs[v.열쇠];
|
||||
drawOptions();
|
||||
void refresh();
|
||||
},
|
||||
onDropped: (k) => {
|
||||
delete inputs[k];
|
||||
drawOptions();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── 그림 ──────────────────────────────────────────────────────────
|
||||
function drawBar(): void {
|
||||
const sums = eq?.합계 ?? {};
|
||||
const mark = total(sums);
|
||||
const now = sums[mark];
|
||||
const old = before?.[mark];
|
||||
const diff = Number(now) - Number(old);
|
||||
const items: (HTMLElement | "divider" | "spacer")[] = [
|
||||
createText(`${t("Now")} ${eqText(now) || "—"}`, "muted", 14),
|
||||
];
|
||||
if (before) {
|
||||
items.push(createText(`${t("Before")} ${eqText(old) || "—"}`, "muted", 14));
|
||||
if (Number.isFinite(diff) && diff)
|
||||
items.push(createBadge(`${diff > 0 ? "+" : "−"}${eqText(Math.abs(diff))}`, "warning"));
|
||||
}
|
||||
if (canEdit()) {
|
||||
items.push("spacer");
|
||||
if (edits.length) {
|
||||
items.push(
|
||||
createBadge(t("Edits", edits.length), "warning"),
|
||||
createButton({
|
||||
label: t("Discard"),
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => void discard(),
|
||||
}),
|
||||
createButton({ label: t("Save"), size: 24, onClick: () => void save() }),
|
||||
);
|
||||
} else items.push(createText(t("Hint"), "muted", 12));
|
||||
}
|
||||
bar.replaceChildren(createToolbar({ items, size: 28, label: t("Title") }));
|
||||
}
|
||||
|
||||
function paint(): void {
|
||||
paintedBlocked = opt.blocked();
|
||||
drawBar();
|
||||
notice.replaceChildren(
|
||||
...(paintedBlocked && opt.admin
|
||||
? [createBanner({ tone: "warning", text: t("Blocked") })]
|
||||
: []),
|
||||
...(why ? [createBanner({ tone: "danger", text: why })] : []),
|
||||
);
|
||||
if (!eq) {
|
||||
body.replaceChildren(createText(t("Wait"), "muted"));
|
||||
return;
|
||||
}
|
||||
const edit = canEdit();
|
||||
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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!canEdit() || !edits.length) return;
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const done = await saveRevised((ok) =>
|
||||
post("/logic/equation/save", { ...body0(), version, 개정확인: ok }),
|
||||
);
|
||||
if (!done) return; // 개정 확인 취소 = 고침 그대로
|
||||
[edits, selected] = [[], null];
|
||||
persist();
|
||||
showToast(t("Saved"), "success");
|
||||
await refresh();
|
||||
drawOptions();
|
||||
opt.onSaved();
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof ApiError ? (error.detail as { 까닭?: string[] } | null) : null;
|
||||
showToast(
|
||||
detail?.까닭?.join(" · ") ?? (error instanceof Error ? error.message : String(error)),
|
||||
"error",
|
||||
);
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function discard(): Promise<void> {
|
||||
edits = [];
|
||||
selected = null;
|
||||
drafts.clear();
|
||||
persist();
|
||||
await refresh();
|
||||
drawOptions();
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
/** 상세가 그릴 때마다 — 로직이 바뀔 때만 서버를 부름 */
|
||||
draw: (nextKey: string, nextVersion: string, nextRow: LogicRow): void => {
|
||||
version = nextVersion;
|
||||
row = nextRow;
|
||||
if (nextKey !== key) {
|
||||
[key, eq, rowNow, before, why, selected] = [nextKey, null, null, null, "", null];
|
||||
inputs = {};
|
||||
edits = loadCache()[nextKey] ?? [];
|
||||
open.clear();
|
||||
drafts.clear();
|
||||
paint();
|
||||
drawOptions();
|
||||
void refresh().then(drawOptions);
|
||||
} else if (opt.blocked() !== paintedBlocked) paint();
|
||||
},
|
||||
dirty: (): boolean => edits.length > 0,
|
||||
/** 나가기 전 — 저장 안 한 고침이 있으면 버림 물음 · 버리면 true */
|
||||
canLeave: async (): Promise<boolean> => {
|
||||
if (!edits.length) return true;
|
||||
if (!(await showConfirmDialog(t("Leave", edits.length), t("Leave_Ok")))) return false;
|
||||
await discard();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_EquationVar.ts
|
||||
* 수식 탭 변수 창(PLAN 796-2 · 요구 657) — 변수 고침 · 변수 더함 · 변수 지움(쓰는 곳 있으면 막고 목록 보임).
|
||||
* 고침 창 = 이름 · 기호 · 위첨자 · 아래첨자 · 단위 · 값(입력 변수만 · 시험 값 · 저장 안 함) · [적용] · [변수 지움]
|
||||
* 더함 창 = 갈래(입력 · 중간) · 열쇠 · 이름 · 기호 · 첨자 · 단위 · 기본값(입력) | 식 · 자리(중간)
|
||||
* 적용 = 부르는 쪽 `commit` 이 서버 미리 셈(`/logic/equation/preview`)을 거쳐 되면 [] · 안 되면 까닭 — 까닭은 창 안 띠에 그대로
|
||||
* 부품 = 모달 · 입력 · 고르기 · 단추 · 띠 알림 · 열쇠 · 식 검사 · 이름 겹침은 서버 몫
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EqVarRow } 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 { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { addEdit, varEdits, type EqEdit, type VarForm } from "./logic/M01_Logic_Equation_Model";
|
||||
|
||||
const TEXT = {
|
||||
VarTitle: ["변수 고침", "Edit variable"],
|
||||
AddTitle: ["변수 더함", "Add variable"],
|
||||
Close: ["닫기", "Close"],
|
||||
Apply: ["적용", "Apply"],
|
||||
Drop: ["변수 지움", "Delete variable"],
|
||||
Key: ["열쇠 (식이 부르는 이름 · 안 바뀜)", "Key (used by formulas · fixed)"],
|
||||
Name: ["이름", "Name"],
|
||||
Sign: ["기호", "Symbol"],
|
||||
Up: ["위첨자", "Superscript"],
|
||||
Down: ["아래첨자", "Subscript"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Value: ["값 (시험 값 · 저장 안 함)", "Value (trial · not saved)"],
|
||||
Digits: ["보이는 자리 (0~6 · 빈 칸 = 전체 자리)", "Digits (0-6 · blank = full)"],
|
||||
Kind: ["갈래", "Kind"],
|
||||
Input: ["입력", "Input"],
|
||||
Mid: ["중간", "Intermediate"],
|
||||
Default: ["기본값", "Default"],
|
||||
Formula: ["식", "Formula"],
|
||||
After: ["자리", "Place"],
|
||||
End: ["맨 끝", "End"],
|
||||
Top: ["맨 위", "Top"],
|
||||
Why: ["안 됨", "Not applied"],
|
||||
Hint: ["이름 · 기호 · 첨자를 바꾸면 쓰는 모든 식에 함께 보임", "Renames show in every formula"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
|
||||
export type Commit = (edits: EqEdit[]) => Promise<string[]>;
|
||||
|
||||
const grid = (...kids: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "m01-eq__form", children: kids });
|
||||
|
||||
/** 까닭 띠 — 있으면 보임 · 없으면 비움 */
|
||||
function reasonBox(): { root: HTMLElement; set: (why: string[]) => void } {
|
||||
const root = el("div", { className: "m01-eq__why" });
|
||||
return {
|
||||
root,
|
||||
set: (why) =>
|
||||
root.replaceChildren(
|
||||
...(why.length
|
||||
? [createBanner({ tone: "danger", title: t("Why"), text: why.join(" · ") })]
|
||||
: []),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export interface VarDialog {
|
||||
row: EqVarRow;
|
||||
/** 입력 변수 시험 값(옵션 칸 글) */
|
||||
value: string;
|
||||
commit: Commit;
|
||||
/** 시험 값을 바꿈(저장 안 함) */
|
||||
onValue: (value: string) => void;
|
||||
/** 변수를 지운 뒤 — 시험 값 비움 */
|
||||
onDropped: (key: string) => void;
|
||||
}
|
||||
|
||||
export function openVarDialog(o: VarDialog): void {
|
||||
const v = o.row;
|
||||
const field = (label: string, value: string): ReturnType<typeof createInputField> =>
|
||||
createInputField({ label, value, size: 28 });
|
||||
const name = field(t("Name"), v.이름);
|
||||
const sign = field(t("Sign"), v.기호);
|
||||
const up = field(t("Up"), v.위);
|
||||
const down = field(t("Down"), v.아래);
|
||||
const unit = field(t("Unit"), v.단위);
|
||||
const val = field(t("Value"), o.value);
|
||||
const shown = v.표시 === undefined ? "" : String(v.표시);
|
||||
const digits = field(t("Digits"), shown);
|
||||
const why = reasonBox();
|
||||
openModal({
|
||||
title: `${t("VarTitle")} · ${v.열쇠}`,
|
||||
closeLabel: t("Close"),
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
body.append(
|
||||
el("p", { className: "ui-text ui-text--muted", text: t("Hint") }),
|
||||
grid(
|
||||
name.root,
|
||||
sign.root,
|
||||
up.root,
|
||||
down.root,
|
||||
unit.root,
|
||||
...(v.갈래 === "입력" ? [val.root] : [digits.root]),
|
||||
),
|
||||
why.root,
|
||||
);
|
||||
actions.append(
|
||||
createButton({
|
||||
label: t("Drop"),
|
||||
variant: "danger-ghost",
|
||||
onClick: async () => {
|
||||
const bad = await o.commit([{ 할일: "지움", 열쇠: v.열쇠 }]);
|
||||
why.set(bad);
|
||||
if (bad.length) return;
|
||||
o.onDropped(v.열쇠);
|
||||
close();
|
||||
},
|
||||
}),
|
||||
createButton({
|
||||
label: t("Apply"),
|
||||
onClick: async () => {
|
||||
const now: VarForm = {
|
||||
열쇠: v.열쇠,
|
||||
이름: name.input.value.trim(),
|
||||
기호: sign.input.value.trim(),
|
||||
위: up.input.value.trim(),
|
||||
아래: down.input.value.trim(),
|
||||
단위: unit.input.value.trim(),
|
||||
표시: digits.input.value.trim(),
|
||||
};
|
||||
const edits = varEdits(
|
||||
{
|
||||
열쇠: v.열쇠,
|
||||
이름: v.이름,
|
||||
기호: v.기호,
|
||||
위: v.위,
|
||||
아래: v.아래,
|
||||
단위: v.단위,
|
||||
표시: shown,
|
||||
},
|
||||
now,
|
||||
);
|
||||
const bad = edits.length ? await o.commit(edits) : [];
|
||||
why.set(bad);
|
||||
if (bad.length) return;
|
||||
if (v.갈래 === "입력" && val.input.value.trim() !== o.value)
|
||||
o.onValue(val.input.value.trim());
|
||||
close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface AddDialog {
|
||||
/** 중간 줄 열쇠 차례(자리 고르기) */
|
||||
mids: string[];
|
||||
commit: Commit;
|
||||
}
|
||||
|
||||
export function openAddDialog(o: AddDialog): void {
|
||||
const field = (label: string, value = ""): ReturnType<typeof createInputField> =>
|
||||
createInputField({ label, value, size: 28 });
|
||||
const kind = createSelectField({
|
||||
label: t("Kind"),
|
||||
options: [
|
||||
{ value: "입력", text: t("Input") },
|
||||
{ value: "중간", text: t("Mid") },
|
||||
],
|
||||
value: "입력",
|
||||
size: 28,
|
||||
});
|
||||
const key = field(t("Key"));
|
||||
const name = field(t("Name"));
|
||||
const sign = field(t("Sign"));
|
||||
const up = field(t("Up"));
|
||||
const down = field(t("Down"));
|
||||
const unit = field(t("Unit"));
|
||||
const dflt = field(t("Default"));
|
||||
const expr = field(t("Formula"));
|
||||
const place = createSelectField({
|
||||
label: t("After"),
|
||||
options: [
|
||||
{ value: "", text: t("End") },
|
||||
{ value: "\u0000top", text: t("Top") },
|
||||
...o.mids.map((m) => ({ value: m, text: m })),
|
||||
],
|
||||
value: "",
|
||||
size: 28,
|
||||
});
|
||||
const why = reasonBox();
|
||||
const sync = (): void => {
|
||||
const mid = kind.select.value === "중간";
|
||||
dflt.root.hidden = mid;
|
||||
expr.root.hidden = place.root.hidden = !mid;
|
||||
};
|
||||
kind.select.addEventListener("change", sync);
|
||||
sync();
|
||||
openModal({
|
||||
title: t("AddTitle"),
|
||||
closeLabel: t("Close"),
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
body.append(
|
||||
grid(
|
||||
kind.root,
|
||||
key.root,
|
||||
name.root,
|
||||
sign.root,
|
||||
up.root,
|
||||
down.root,
|
||||
unit.root,
|
||||
dflt.root,
|
||||
expr.root,
|
||||
place.root,
|
||||
),
|
||||
why.root,
|
||||
);
|
||||
actions.append(
|
||||
createButton({
|
||||
label: t("Apply"),
|
||||
onClick: async () => {
|
||||
const mid = kind.select.value === "중간";
|
||||
const at = place.select.value;
|
||||
const edit = addEdit(
|
||||
{
|
||||
열쇠: key.input.value,
|
||||
이름: name.input.value,
|
||||
기호: sign.input.value,
|
||||
위: up.input.value,
|
||||
아래: down.input.value,
|
||||
단위: unit.input.value,
|
||||
갈래: mid ? "중간" : "입력",
|
||||
기본값: dflt.input.value.trim(),
|
||||
식: expr.input.value.trim(),
|
||||
},
|
||||
at === "\u0000top" ? "" : at || undefined,
|
||||
);
|
||||
const bad = await o.commit([edit]);
|
||||
why.set(bad);
|
||||
if (!bad.length) close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -199,6 +199,7 @@
|
||||
}
|
||||
|
||||
/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김(시험 계산은 숨어도 돎) */
|
||||
.m01-logic__editor[data-view="equation"] [data-block]:not([data-block="equation"]),
|
||||
.m01-logic__editor[data-view="sheet"] [data-block]:not([data-block="sheet"]),
|
||||
.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]),
|
||||
.m01-logic__editor[data-view="est"] [data-block]:not([data-block="est"]),
|
||||
|
||||
@@ -35,6 +35,7 @@ const TEXT = {
|
||||
Calc_Title: ["시험 계산", "Trial calculation"],
|
||||
View_Est: ["인력·재료·경비", "Labor·material·expense"],
|
||||
View_Sheet: ["시트", "Sheet"],
|
||||
View_Equation: ["수식", "Equation"],
|
||||
Edit_SheetHint: ["식은 「시트」 보기에서 고침", "Edit formulas in the Sheet view"],
|
||||
View_Label: ["보기", "View"],
|
||||
G_labor: ["인력", "Labor"],
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_View.ts
|
||||
* 「단가산출 로직」 상세 보기 고르기 — 머리줄 [신규 로직] 왼쪽 「분할 고르기 | 세로 나눔선」
|
||||
* 보기 다섯 = 시트(기본 · 794-1 줄 식 시트) · 단가산출 상세 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
|
||||
* 보기 여섯 = 수식(기본 · 796-2) · 단가산출 상세 · 시트 · 인력·재료·경비 · 시험 계산 · 텍스트 수식 — 고른 덩이만 보임
|
||||
* 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지
|
||||
* 시험 계산은 숨겨도 그려 둠(자동 계산이 숨은 칸에서도 돎) · 기본정보는 이름표 없음(늘 맨 위)
|
||||
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 시트)
|
||||
* 기억 = 이 화면 안에서만(로직을 바꿔도 유지 · 새로 들어오면 수식)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createToolbar } from "@ui/ui_template_toolbar";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
export type LabView = "sheet" | "ho" | "est" | "calc" | "formula";
|
||||
export type LabView = "equation" | "sheet" | "ho" | "est" | "calc" | "formula";
|
||||
|
||||
/** 덩이에 보기 이름표를 붙여 돌려줌 */
|
||||
export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
|
||||
@@ -21,16 +21,17 @@ export const block = <T extends HTMLElement>(node: T, view: LabView): T => {
|
||||
|
||||
/** 보기 고르기 — `host`(상세 그릇)에 `data-view` 를 둠 · `root` 는 머리줄에 놓음 */
|
||||
export function createViewPicker(host: HTMLElement): { root: HTMLElement } {
|
||||
host.dataset.view = "sheet";
|
||||
host.dataset.view = "equation";
|
||||
const segment = createSegment({
|
||||
options: [
|
||||
{ value: "sheet", label: tl("View_Sheet") },
|
||||
{ value: "equation", label: tl("View_Equation") },
|
||||
{ value: "ho", label: tl("Ho_Title") },
|
||||
{ value: "sheet", label: tl("View_Sheet") },
|
||||
{ value: "est", label: tl("View_Est") },
|
||||
{ value: "calc", label: tl("Calc_Title") },
|
||||
{ value: "formula", label: tl("Formula_Title") },
|
||||
],
|
||||
value: "sheet",
|
||||
value: "equation",
|
||||
size: 32,
|
||||
ariaLabel: tl("View_Label"),
|
||||
onChange: (v) => (host.dataset.view = v),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData/logic/M01_Logic_Equation_Model.ts
|
||||
* 수식 탭 계산(796-2 · 화면 · fetch 없음) — 줄 → 고침 자리 · 식 칸 아이콘 끼움 · 옵션 값 꼴 · 변수 고침 요청.
|
||||
* 서버 고침 꼴 = `master_equation_edit.py` 머리(할일 식 · 더함 · 지움 · 변수 · 표시) — 여기서는 요청 목록만 만듦
|
||||
* 셈 · 검사는 서버 한 벌(③ 서버 단독) — 브라우저는 모양만
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EqLine } from "@ui/ui_template_equation";
|
||||
|
||||
export interface EqEdit {
|
||||
할일: "식" | "더함" | "지움" | "변수" | "표시";
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** 식 줄의 고침 자리(`중간:<열쇠>` · `결과` · `호표:<n>` · `덧줄:<n>`) */
|
||||
export const spotOf = (line: EqLine): string =>
|
||||
line.갈래 === "호표" || line.갈래 === "덧줄"
|
||||
? `${line.갈래}:${line.차례 ?? 0}`
|
||||
: line.갈래 === "결과"
|
||||
? "결과"
|
||||
: `중간:${line.열쇠}`;
|
||||
|
||||
export const formulaEdit = (line: EqLine, text: string): EqEdit => ({
|
||||
할일: "식",
|
||||
자리: spotOf(line),
|
||||
식: text,
|
||||
});
|
||||
|
||||
/** 아이콘 줄 — 식 칸 글에 끼우는 꼴 */
|
||||
export type InsertKind = "frac" | "paren" | "sub" | "sup" | "percent";
|
||||
|
||||
/** 선택 영역 `[from, to)` 를 아이콘 꼴로 바꾼 글 · 커서 자리 */
|
||||
export function insertInto(
|
||||
kind: InsertKind,
|
||||
text: string,
|
||||
from: number,
|
||||
to: number,
|
||||
): { text: string; caret: number } {
|
||||
const head = text.slice(0, from);
|
||||
const pick = text.slice(from, to);
|
||||
const tail = text.slice(to);
|
||||
const put = (mid: string, caret: number): { text: string; caret: number } => ({
|
||||
text: head + mid + tail,
|
||||
caret: head.length + caret,
|
||||
});
|
||||
switch (kind) {
|
||||
case "frac": // 분수 = (위) / (아래) — 고른 글이 위 · 커서는 아래 괄호 안
|
||||
return pick ? put(`(${pick}) / ()`, pick.length + 6) : put("() / ()", 1);
|
||||
case "paren":
|
||||
return pick ? put(`(${pick})`, pick.length + 2) : put("()", 1);
|
||||
case "sub":
|
||||
return put(`${pick}_`, pick.length + 1);
|
||||
case "sup":
|
||||
return put(`${pick}^`, pick.length + 1);
|
||||
case "percent":
|
||||
return put(`${pick} / 100`, pick.length + 6);
|
||||
}
|
||||
}
|
||||
|
||||
/** 옵션 값 — 수 꼴 글은 수로(엔진 입력 꼴) · 빈 값은 안 보냄(자동값) */
|
||||
export function inputsOf(values: Record<string, string>): Record<string, number | string> {
|
||||
const out: Record<string, number | string> = {};
|
||||
for (const [name, raw] of Object.entries(values)) {
|
||||
if (raw === "") continue;
|
||||
out[name] = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface VarForm {
|
||||
열쇠: string;
|
||||
이름: string;
|
||||
기호: string;
|
||||
위: string;
|
||||
아래: string;
|
||||
단위: string;
|
||||
갈래?: "입력" | "중간";
|
||||
설명?: string;
|
||||
기본값?: string;
|
||||
식?: string;
|
||||
/** 보이는 자리 글(중간 줄 · 빈 글 = 전체) */
|
||||
표시?: string;
|
||||
}
|
||||
|
||||
/** 변수 고침 창 → 요청 목록(바꾼 칸만) — `보이는 자리`(중간 줄) = 0~6 · 빈 칸 = 전체 자리(`표시` 지움) */
|
||||
export function varEdits(before: VarForm, now: VarForm): EqEdit[] {
|
||||
const out: EqEdit = { 할일: "변수", 열쇠: before.열쇠 };
|
||||
let changed = false;
|
||||
for (const k of ["이름", "기호", "위", "아래", "단위"] as const)
|
||||
if (now[k] !== before[k]) {
|
||||
out[k] = now[k];
|
||||
changed = true;
|
||||
}
|
||||
const list: EqEdit[] = changed ? [out] : [];
|
||||
if (now.표시 !== before.표시) {
|
||||
const digits = now.표시 === "" || now.표시 === undefined ? null : Number(now.표시);
|
||||
list.push({ 할일: "표시", 열쇠: before.열쇠, 표시: digits });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 변수 더함 창 → 요청 — 빈 칸은 안 보냄 */
|
||||
export function addEdit(form: VarForm, after?: string): EqEdit {
|
||||
const out: EqEdit = { 할일: "더함", 갈래: form.갈래 ?? "입력", 열쇠: form.열쇠.trim() };
|
||||
for (const k of ["이름", "기호", "위", "아래", "단위", "설명"] as const) {
|
||||
const v = (form[k] ?? "").trim();
|
||||
if (v) out[k] = v;
|
||||
}
|
||||
if (form.갈래 === "중간") {
|
||||
out.식 = form.식 || "0";
|
||||
if (after !== undefined) out.뒤 = after;
|
||||
} else if (form.기본값) out.기본값 = form.기본값;
|
||||
return out;
|
||||
}
|
||||
@@ -73,10 +73,10 @@ def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None:
|
||||
assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab
|
||||
assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장]
|
||||
assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet
|
||||
assert len(lab.splitlines()) <= 637
|
||||
assert len(lab.splitlines()) <= 700 # 796-2 수식 탭 이음 14 줄
|
||||
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
|
||||
assert (
|
||||
'host.dataset.view = "sheet";' in view
|
||||
assert ( # 796-2 — 첫 탭 = 수식 · 시트는 뒤로(걷지 않음)
|
||||
'host.dataset.view = "equation";' in view
|
||||
and '{ value: "sheet", label: tl("View_Sheet") }' in view
|
||||
)
|
||||
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""796-2 수식 탭 화면 지킴(정적 · 서버 · 브라우저 없이) — 부품 상자만 · 수식 탭 첫 탭 · 아이콘 숨김 · 변수 고침 창 · 옵션 가로.
|
||||
|
||||
화면 동작(분수 · 변수 풀이 · 고침 → 금액 · 변수 더함 · 지움 · 저장)은 ORCA 사용자 길로 확인 — 서버 모델은 `test_796_1_formula_model.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M01 = ROOT / "M01_MasterData"
|
||||
UI = ROOT / "ui_template"
|
||||
PARTS = [UI / "ui_template_equation.ts", UI / "ui_template_equation_line.ts"]
|
||||
PAGE = [
|
||||
M01 / "M01_MasterData_UI_LogicLab_Equation.ts",
|
||||
M01 / "M01_MasterData_UI_LogicLab_EquationVar.ts",
|
||||
]
|
||||
MODEL = M01 / "logic" / "M01_Logic_Equation_Model.ts"
|
||||
CSS = [UI / "ui_template_equation.css", M01 / "M01_MasterData_UI_LogicLab_Equation.css"]
|
||||
RAW = re.compile(r"<button|<input|(el|createElement)\(\"(button|input)\"")
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
|
||||
|
||||
def test_부품_상자만_날_단추_입력_없음():
|
||||
for path in [*PARTS, *PAGE, MODEL]:
|
||||
src = _text(path)
|
||||
assert not RAW.search(src), f"{path.name} — 날 button · input"
|
||||
# 수식 칩 = 글 속 부품(span role) · 단추 · 입력 · 고르기는 부품 상자 것
|
||||
page = _text(PAGE[0]) + _text(PAGE[1])
|
||||
for part in (
|
||||
"createIconButton",
|
||||
"createButton",
|
||||
"createToolbar",
|
||||
"createFormulaCell",
|
||||
"openModal",
|
||||
):
|
||||
assert part in page, part
|
||||
assert "createValueControl" in page # 옵션 값 칸 = 기존 값 칸 한 벌(M02_Labor_Values)
|
||||
|
||||
|
||||
def test_색은_공통_값만_700줄_안():
|
||||
for path in CSS:
|
||||
css = _text(path)
|
||||
assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(", css), path.name
|
||||
assert "var(--color-" in css
|
||||
for path in [*PARTS, *PAGE, MODEL, *CSS, M01 / "M01_MasterData_UI_LogicLab.ts"]:
|
||||
assert len(_text(path).splitlines()) <= 700, path.name
|
||||
assert len(_text(M01 / "M01_MasterData_UI_LogicLab_Ho.ts").splitlines()) <= 700
|
||||
|
||||
|
||||
def test_계산_자리_화면은_모양만():
|
||||
model = _text(MODEL)
|
||||
assert "fetch(" not in model and "document." not in model # logic/ = 화면 · fetch 없음
|
||||
for path in PARTS:
|
||||
assert "fetch(" not in _text(path) # 부품은 그리기만
|
||||
page = _text(PAGE[0])
|
||||
assert page.count("fetch(") == 1 # 서버 길 한 곳 — 계산은 서버 한 벌(③)
|
||||
for route in ('"/logic/equation"', '"/logic/equation/preview"', '"/logic/equation/save"'):
|
||||
assert route in page, route
|
||||
assert "saveRevised(" in page and "개정확인" in page # [저장] = 개정 확인 길
|
||||
assert "edits" in page and "sessionStorage" in page # 고침 목록 = 캐시 → [저장] 한 번
|
||||
assert "ctl?.abort()" in page # 옛 요청 끊음
|
||||
assert "setInterval" not in page and "autosave" not in page.lower() # 자동저장 없음
|
||||
|
||||
|
||||
def test_수식_탭이_첫_탭_단가산출_상세는_그대로():
|
||||
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
|
||||
labels = re.findall(r'\{ value: "(\w+)", label: tl\("(\w+)"\) \}', view)
|
||||
assert [v for v, _ in labels] == ["equation", "ho", "sheet", "est", "calc", "formula"]
|
||||
assert 'host.dataset.view = "equation";' in view and 'value: "equation",\n size: 32' in view
|
||||
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert 'block(ctx.equation, "equation")' in detail
|
||||
assert 'block(section(tl("Ho_Title"), [formula]), "formula")' in detail # 옛 탭은 걷지 않음
|
||||
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
|
||||
assert '[data-view="equation"] [data-block]:not([data-block="equation"])' in css
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
assert "createLabEquation({" in lab and "equation: equation.root," in lab
|
||||
assert "equation.canLeave()" in lab # 저장 전 떠나면 버림 물음
|
||||
|
||||
|
||||
def test_아이콘_줄은_고른_줄에만_고침은_관리자만():
|
||||
line = _text(PARTS[1])
|
||||
assert "o.selected === id ? o.slot?.(line, id) : null" in line # 고른 줄에만 식 칸 · 아이콘 줄
|
||||
page = _text(PAGE[0])
|
||||
for icon in ('"frac"', '"paren"', '"sub"', '"sup"', '"percent"'):
|
||||
assert icon 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 "createFormulaCell" in page and "../A00_Common/sheet_formula_cell" in page
|
||||
|
||||
|
||||
def test_변수_고침_창_칸과_지움_막힘():
|
||||
var = _text(PAGE[1])
|
||||
for label in (
|
||||
'Name: ["이름"',
|
||||
'Sign: ["기호"',
|
||||
'Up: ["위첨자"',
|
||||
'Down: ["아래첨자"',
|
||||
'Unit: ["단위"',
|
||||
'Digits: ["보이는 자리',
|
||||
"Value:",
|
||||
):
|
||||
assert label in var, label
|
||||
assert '할일: "지움"' in var and "why.set(bad)" in var # 쓰는 곳 목록 = 서버 까닭 그대로 띠에
|
||||
assert "commit(" in var or "o.commit(" in var
|
||||
assert "openAddDialog" in var and 'value: "중간"' in var # 입력 · 중간 더함
|
||||
model = _text(MODEL)
|
||||
for job in ('"식"', '"더함"', '"지움"', '"변수"'):
|
||||
assert job in model, job
|
||||
|
||||
|
||||
def test_옵션_가로_찾기형_말줄임():
|
||||
page = _text(PAGE[0])
|
||||
assert "createToolbar({" in page and "optionNode(" in page
|
||||
assert "BIG = 12" in page and "datalist" in page # 고르기 많으면 찾기형
|
||||
assert "title" in page # 마우스 올림 전체 글
|
||||
css = _text(CSS[1])
|
||||
assert "text-overflow: ellipsis" in css and "max-width: 14rem" in css
|
||||
assert "askAgain" in page # 옵션 바꾸면 다시 셈
|
||||
|
||||
|
||||
def test_부품_모음_견본_한_줄():
|
||||
src = _text(UI / "ui_template_ui_kit_parts.ts")
|
||||
rows = re.findall(
|
||||
r'\{ key: "([^"]+)", name: "[^"]+", group: "(\w+)",[^\n]*file: "([^"]+)"', src
|
||||
)
|
||||
assert ("equation", "compound", "ui_template_equation.ts") in rows
|
||||
assert len(src.splitlines()) <= 700
|
||||
head = _text(PARTS[0])
|
||||
for word in ("분수", "첨자", "조건 칩", "크기", "상태", "쓰는 법"):
|
||||
assert word in head, word
|
||||
@@ -0,0 +1,324 @@
|
||||
/* 수식(796-2) — 분수 · 첨자 · 괄호 · 줄 · 변수 풀이 · 블록 · 색은 공통 값만 */
|
||||
.ui-eq {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
container-type: inline-size;
|
||||
font-size: var(--text-body);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* ── 식 그림 ── */
|
||||
.ui-eq__expr,
|
||||
.ui-eq__body {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0 var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-eq__num,
|
||||
.ui-eq__str {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ui-eq__str {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-eq__op {
|
||||
color: var(--color-text-secondary);
|
||||
padding: 0 1px;
|
||||
}
|
||||
|
||||
.ui-eq__sym.is-latin {
|
||||
font-style: italic;
|
||||
font-family: "Cambria Math", "STIX Two Math", "Times New Roman", serif;
|
||||
}
|
||||
|
||||
.ui-eq__script {
|
||||
font-size: 0.7em;
|
||||
line-height: 0;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.ui-eq__var {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ui-eq__var.is-pick,
|
||||
.ui-eq__block.is-pick {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ui-eq__var.is-pick:hover,
|
||||
.ui-eq__var.is-pick:focus-visible {
|
||||
outline: 1px solid var(--color-accent);
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
/* 분수 — 가로 줄 안에 위 · 아래를 세로로 쌓고 사이에 선 */
|
||||
.ui-eq__frac {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ui-eq__top {
|
||||
padding: 0 var(--spacing-4) 2px;
|
||||
border-bottom: 1px solid currentcolor;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-eq__bottom {
|
||||
padding: 2px var(--spacing-4) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 괄호 — 안 키에 맞춰 늘어나는 곡선 */
|
||||
.ui-eq__paren {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ui-eq__paren::before,
|
||||
.ui-eq__paren::after {
|
||||
content: "";
|
||||
flex: none;
|
||||
width: 5px;
|
||||
border: 1.5px solid currentcolor;
|
||||
}
|
||||
|
||||
.ui-eq__paren::before {
|
||||
border-right: 0;
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
.ui-eq__paren::after {
|
||||
border-left: 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.ui-eq__inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-eq__fn {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 조건 칩 · 하위 로직 칩 */
|
||||
.ui-eq__cond,
|
||||
.ui-eq__block {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface);
|
||||
font-size: var(--text-body-sm);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ui-eq__cond-key {
|
||||
color: var(--color-warning);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.ui-eq__block {
|
||||
border-color: var(--color-info);
|
||||
}
|
||||
|
||||
.ui-eq__block.is-pick:hover {
|
||||
background: var(--color-accent-soft);
|
||||
}
|
||||
|
||||
.ui-eq__badge {
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.ui-eq__block-name {
|
||||
max-width: 14em;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-eq__block-val {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* ── 줄 ── */
|
||||
.ui-eq__line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
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);
|
||||
}
|
||||
|
||||
/* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */
|
||||
.ui-eq__box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ui-eq__res {
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ui-eq__unit {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
|
||||
.ui-eq__raw {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--text-body-sm);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.ui-eq__bad {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 변수 풀이 */
|
||||
.ui-eq__solve {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* 하위 로직 블록 — 접힘 */
|
||||
.ui-eq__blockpanel {
|
||||
margin-left: var(--spacing-16);
|
||||
padding-left: var(--spacing-8);
|
||||
border-left: 2px solid var(--color-info);
|
||||
}
|
||||
|
||||
.ui-eq__block-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.ui-eq__blockpanel[data-open] .ui-eq__block-head .ui-btn__icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.ui-eq__bundle {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
font-variant-numeric: tabular-nums;
|
||||
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);
|
||||
}
|
||||
|
||||
.ui-eq__block-body .ui-eq__line {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/* =============================================================================
|
||||
* ui_template_equation.ts
|
||||
* 부품 「수식」(796-2 · 요구 655) — 수식 나무(서버 `master_equation.py` 나무 JSON)를 PPT 수식 꼴로 그림.
|
||||
* 꼴 = 분수(가로 줄 안 세로 쌓기 · 분수선) · 위 · 아래첨자 · × ÷ + − · 비교 · 괄호(키 큰 곡선) · % · 단위 ·
|
||||
* 조건 칩(만약 → 걸린 쪽만) · 함수 · 하위 로직 칩(블록 · 접힘은 `ui_template_equation_line.ts`)
|
||||
* 크기 = 글자 `--text-body` · 첨자 0.7 배 · 분수 안 글 같은 크기 · 변수 칩 높이는 글 줄
|
||||
* 상태 = 고르는 변수 `is-pick`(눌림 가능 · 머리 `data-size` 없음 — 글 속 부품) · 걸리지 않은 가지는 마우스 글로만
|
||||
* 쓰는 법 = `renderEq(tree, { onVar, onBlock })` → 인라인 요소 · 줄 · 풀이 · 블록 = `ui_template_equation_line.ts`
|
||||
* 대신하는 옛 변종 = `createCalcFormula` 의 `^…^` · `_…_` 글(분수 없음 · 그 부품은 그대로 둠)
|
||||
* ========================================================================== */
|
||||
|
||||
import "./ui_template_equation.css";
|
||||
|
||||
/** 서버 나무 마디 — 꼴마다 쓰는 칸이 다름(변수 `위` · `아래` 는 첨자 글 · 분수 `위` · `아래` 는 마디) */
|
||||
export interface EqNode {
|
||||
꼴: string;
|
||||
글?: string;
|
||||
따옴?: boolean;
|
||||
값?: unknown;
|
||||
열쇠?: string;
|
||||
이름?: string;
|
||||
기호?: string;
|
||||
위?: string | EqNode;
|
||||
아래?: string | EqNode;
|
||||
단위?: string;
|
||||
갈래?: string;
|
||||
식?: string;
|
||||
칸?: string | null;
|
||||
표?: string;
|
||||
표이름?: string;
|
||||
행?: Record<string, unknown>;
|
||||
좌?: EqNode;
|
||||
우?: EqNode;
|
||||
안?: EqNode;
|
||||
조건?: EqNode;
|
||||
참?: EqNode;
|
||||
거짓?: EqNode;
|
||||
걸림?: number;
|
||||
이름식?: EqNode;
|
||||
인자?: EqNode[];
|
||||
묶음?: Record<string, unknown> | null;
|
||||
결과단위?: string;
|
||||
나무?: EqTree | null;
|
||||
사슬?: boolean;
|
||||
}
|
||||
|
||||
/** 로직 하나의 수식(줄 · 변수표 · 합계) */
|
||||
export interface EqTree {
|
||||
열쇠: string;
|
||||
이름: string;
|
||||
결과단위: string;
|
||||
줄: EqLine[];
|
||||
변수표: EqVarRow[];
|
||||
합계: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface EqVarRow {
|
||||
열쇠: string;
|
||||
이름: string;
|
||||
기호: string;
|
||||
위: string;
|
||||
아래: string;
|
||||
단위: string;
|
||||
갈래: string;
|
||||
기본값: unknown;
|
||||
설명: string;
|
||||
표시?: number;
|
||||
}
|
||||
|
||||
export interface EqLine {
|
||||
갈래: string;
|
||||
열쇠: string;
|
||||
차례?: number;
|
||||
식: string;
|
||||
입력글?: string;
|
||||
까닭?: string;
|
||||
결과?: EqNode;
|
||||
이름식?: EqNode;
|
||||
값식?: EqNode;
|
||||
값?: unknown;
|
||||
글?: string;
|
||||
표시?: number;
|
||||
비목?: unknown;
|
||||
}
|
||||
|
||||
export interface EqHandlers {
|
||||
/** 변수 칩을 누름(없으면 눌리지 않음) */
|
||||
onVar?: (node: EqNode) => void;
|
||||
/** 하위 로직 칩을 누름 — 줄 아래 블록을 펴고 접음 */
|
||||
onBlock?: (node: EqNode) => void;
|
||||
/** 블록 번호 매기기용 — 이 식 안 블록 목록(부르는 쪽이 비우고 씀) */
|
||||
blocks?: EqNode[];
|
||||
}
|
||||
|
||||
const SHOW: Record<string, string> = {
|
||||
"==": "=",
|
||||
"!=": "≠",
|
||||
"<=": "≤",
|
||||
">=": "≥",
|
||||
"*": "×",
|
||||
"/": "÷",
|
||||
"-": "−",
|
||||
};
|
||||
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
const span = (className: string, ...kids: (Node | string)[]): HTMLSpanElement => {
|
||||
const node = document.createElement("span");
|
||||
node.className = className;
|
||||
node.append(...kids);
|
||||
return node;
|
||||
};
|
||||
|
||||
/** 값 → 글(수는 쉼표 · 6 자리까지) */
|
||||
export function eqText(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "";
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
return typeof value !== "boolean" && Number.isFinite(n) && String(value).trim() !== ""
|
||||
? NUM.format(n)
|
||||
: String(value);
|
||||
}
|
||||
|
||||
/** 변수 글(기호 · 아래 · 위) — 첨자가 없으면 기호만 */
|
||||
export function symbolOf(node: Pick<EqNode, "열쇠" | "기호" | "위" | "아래">): HTMLElement {
|
||||
const sign = node.기호 || node.열쇠 || "";
|
||||
const box = span("ui-eq__sym", sign);
|
||||
if (/^[A-Za-z]{1,2}$/.test(sign)) box.classList.add("is-latin");
|
||||
if (typeof node.아래 === "string" && node.아래) box.append(subOf("sub", node.아래));
|
||||
if (typeof node.위 === "string" && node.위) box.append(subOf("sup", node.위));
|
||||
return box;
|
||||
}
|
||||
|
||||
const subOf = (tag: "sub" | "sup", text: string): HTMLElement => {
|
||||
const node = document.createElement(tag);
|
||||
node.className = "ui-eq__script";
|
||||
node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
/** 한 줄 글(마우스 글 · 검사용) — 변수는 기호 · 값 꼴 */
|
||||
export function plainEq(node: EqNode | undefined): string {
|
||||
if (!node) return "";
|
||||
switch (node.꼴) {
|
||||
case "수":
|
||||
case "글":
|
||||
return node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? "");
|
||||
case "변수": {
|
||||
const sign = node.기호 || node.열쇠 || "";
|
||||
const low = typeof node.아래 === "string" && node.아래 ? `_${node.아래}` : "";
|
||||
const up = typeof node.위 === "string" && node.위 ? `^${node.위}` : "";
|
||||
return sign + low + up;
|
||||
}
|
||||
case "블록":
|
||||
return node.이름 || node.열쇠 || "";
|
||||
case "연산":
|
||||
return `${plainEq(node.좌)} ${SHOW[node.기호 ?? ""] ?? node.기호} ${plainEq(node.우)}`;
|
||||
case "분수":
|
||||
return `(${plainEq(node.위 as EqNode)}) ÷ (${plainEq(node.아래 as EqNode)})`;
|
||||
case "괄호":
|
||||
return `(${plainEq(node.안)})`;
|
||||
case "퍼센트":
|
||||
return `${plainEq(node.안)}%`;
|
||||
case "음":
|
||||
return `−${plainEq(node.안)}`;
|
||||
case "조건":
|
||||
return `만약(${plainEq(node.조건)}, ${plainEq(node.참)}, ${plainEq(node.거짓)})`;
|
||||
case "함수":
|
||||
return `${node.이름}(${(node.인자 ?? []).map(plainEq).join(", ")})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** 변수 한 개 — 눌리면 `onVar` */
|
||||
function variable(node: EqNode, h: EqHandlers): HTMLElement {
|
||||
const box = span("ui-eq__var", symbolOf(node));
|
||||
box.dataset.kind = node.갈래 ?? "";
|
||||
box.dataset.eqVar = node.열쇠 ?? "";
|
||||
const value = eqText(node.값);
|
||||
box.title = [node.이름 || node.열쇠, value ? `= ${value}${node.단위 ? ` ${node.단위}` : ""}` : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (h.onVar) {
|
||||
box.classList.add("is-pick");
|
||||
box.tabIndex = 0;
|
||||
box.setAttribute("role", "button");
|
||||
const fire = (ev: Event): void => {
|
||||
ev.stopPropagation();
|
||||
h.onVar?.(node);
|
||||
};
|
||||
box.addEventListener("click", fire);
|
||||
box.addEventListener("keydown", (ev) => (ev.key === "Enter" || ev.key === " ") && fire(ev));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function block(node: EqNode, h: EqHandlers): HTMLElement {
|
||||
const at = (h.blocks ?? []).push(node);
|
||||
const value = eqText(node.값);
|
||||
const chip = span(
|
||||
"ui-eq__block",
|
||||
span("ui-eq__badge", String.fromCodePoint(0x245f + at)), // ① ② …
|
||||
span("ui-eq__block-name", node.이름 || node.열쇠 || ""),
|
||||
value ? span("ui-eq__block-val", value) : "",
|
||||
);
|
||||
chip.dataset.eqBlock = node.열쇠 ?? "";
|
||||
chip.title = `${node.열쇠 ?? ""} ${node.이름 ?? ""}`.trim();
|
||||
if (h.onBlock) {
|
||||
chip.classList.add("is-pick");
|
||||
chip.tabIndex = 0;
|
||||
chip.setAttribute("role", "button");
|
||||
const fire = (ev: Event): void => {
|
||||
ev.stopPropagation();
|
||||
h.onBlock?.(node);
|
||||
};
|
||||
chip.addEventListener("click", fire);
|
||||
chip.addEventListener("keydown", (ev) => (ev.key === "Enter" || ev.key === " ") && fire(ev));
|
||||
}
|
||||
return chip;
|
||||
}
|
||||
|
||||
/** 수식 나무 → 인라인 요소 */
|
||||
export function renderEq(node: EqNode | undefined, h: EqHandlers = {}): HTMLElement {
|
||||
if (!node) return span("ui-eq__expr");
|
||||
const sub = (child: EqNode | string | undefined): HTMLElement =>
|
||||
typeof child === "string" ? span("ui-eq__text", child) : renderEq(child, h);
|
||||
switch (node.꼴) {
|
||||
case "수":
|
||||
return span("ui-eq__num", node.글 ?? "");
|
||||
case "글":
|
||||
return span("ui-eq__str", node.따옴 ? `'${node.글 ?? ""}'` : (node.글 ?? ""));
|
||||
case "변수":
|
||||
return variable(node, h);
|
||||
case "블록":
|
||||
return block(node, h);
|
||||
case "연산": {
|
||||
const sign = SHOW[node.기호 ?? ""] ?? node.기호 ?? "";
|
||||
return span("ui-eq__expr", sub(node.좌), span("ui-eq__op", sign), sub(node.우));
|
||||
}
|
||||
case "분수": {
|
||||
const frac = span(
|
||||
"ui-eq__frac",
|
||||
span("ui-eq__top", sub(node.위)),
|
||||
span("ui-eq__bottom", sub(node.아래)),
|
||||
);
|
||||
if (node.사슬) frac.dataset.chain = "1";
|
||||
return frac;
|
||||
}
|
||||
case "괄호":
|
||||
return span("ui-eq__paren", span("ui-eq__inner", sub(node.안)));
|
||||
case "퍼센트":
|
||||
return span("ui-eq__expr", sub(node.안), span("ui-eq__op", "%"));
|
||||
case "음":
|
||||
return span("ui-eq__expr", span("ui-eq__op", "−"), sub(node.안));
|
||||
case "조건": {
|
||||
const pick = node.걸림 ?? 0;
|
||||
const chip = span("ui-eq__cond", span("ui-eq__cond-key", "만약"), sub(node.조건));
|
||||
chip.title = `참 ${plainEq(node.참)} · 거짓 ${plainEq(node.거짓)}`;
|
||||
if (pick === 1 || pick === 2)
|
||||
return span("ui-eq__expr", chip, sub(pick === 1 ? node.참 : node.거짓));
|
||||
return span(
|
||||
"ui-eq__expr",
|
||||
chip,
|
||||
span("ui-eq__op", "참"),
|
||||
sub(node.참),
|
||||
span("ui-eq__op", "거짓"),
|
||||
sub(node.거짓),
|
||||
);
|
||||
}
|
||||
case "함수": {
|
||||
const args = span("ui-eq__expr");
|
||||
(node.인자 ?? []).forEach((a, i) =>
|
||||
args.append(...(i ? [span("ui-eq__op", ","), sub(a)] : [sub(a)])),
|
||||
);
|
||||
return span("ui-eq__expr", span("ui-eq__fn", node.이름 ?? ""), span("ui-eq__paren", args));
|
||||
}
|
||||
default:
|
||||
return span("ui-eq__expr", node.글 ?? "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/* =============================================================================
|
||||
* ui_template_equation_line.ts
|
||||
* 부품 「수식」 둘째 파일(796-2) — 줄 · 변수 풀이 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`.
|
||||
* 줄 = 번호 · 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 오른쪽 변수 풀이(기호 : 이름 = 값 단위 · 출처)
|
||||
* 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글)
|
||||
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 중간 계산이 한눈에 · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
|
||||
* 크기 = 글 `--text-body` · 풀이 `--text-caption` · 접힘 단추 22
|
||||
* 상태 = 고른 줄 `data-selected`(왼쪽 띠 · 식 칸 자리 `slot` 이 줄 아래에 열림) · 펼친 블록 `data-open`
|
||||
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), selected, onSelect, onVar, slot })`
|
||||
* 대신하는 옛 변종 = M01 · M02 시트 줄(`createCalcSheetRow`) 의 이름 | 식 | 값 한 줄(분수 · 풀이 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "./ui_template_button";
|
||||
import {
|
||||
eqText,
|
||||
plainEq,
|
||||
renderEq,
|
||||
symbolOf,
|
||||
type EqHandlers,
|
||||
type EqLine,
|
||||
type EqNode,
|
||||
} from "./ui_template_equation";
|
||||
|
||||
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;
|
||||
/** 결과 줄 값 뒤 단위 */
|
||||
resultUnit?: string;
|
||||
/** 줄 번호 1 부터 보임 */
|
||||
numbered?: boolean;
|
||||
}
|
||||
|
||||
const BUNDLE: [string, string][] = [
|
||||
["노무비", "노"],
|
||||
["재료비", "재"],
|
||||
["경비", "경"],
|
||||
["계", "계"],
|
||||
];
|
||||
|
||||
const span = (className: string, ...kids: (Node | string)[]): HTMLSpanElement => {
|
||||
const node = document.createElement("span");
|
||||
node.className = className;
|
||||
node.append(...kids);
|
||||
return node;
|
||||
};
|
||||
const div = (className: string, ...kids: Node[]): HTMLDivElement => {
|
||||
const node = document.createElement("div");
|
||||
node.className = className;
|
||||
node.append(...kids);
|
||||
return node;
|
||||
};
|
||||
|
||||
/** 줄 이름표 — 호표 · 덧줄은 차례 · 그 밖은 열쇠 */
|
||||
export const lineId = (line: EqLine): string =>
|
||||
line.갈래 === "호표" || line.갈래 === "덧줄"
|
||||
? `${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.묶음;
|
||||
if (got && typeof got === "object")
|
||||
for (const [key, label] of BUNDLE)
|
||||
if (got[key] !== undefined && got[key] !== null)
|
||||
box.append(span("ui-eq__bundle-item", `${label} ${eqText(got[key])}`));
|
||||
if (!box.childElementCount && node.값 !== undefined && node.값 !== null)
|
||||
box.append(span("ui-eq__bundle-item", eqText(node.값)));
|
||||
return box;
|
||||
}
|
||||
|
||||
function blockPanel(node: EqNode, at: number, o: EqLineOptions): HTMLElement {
|
||||
const id = `${o.path}/${node.열쇠 ?? at}`;
|
||||
const body = div("ui-eq__block-body");
|
||||
const box = div("ui-eq__blockpanel");
|
||||
box.dataset.eqBlockPanel = node.열쇠 ?? "";
|
||||
box.dataset.blockId = id;
|
||||
const head = createButton({
|
||||
label: `${String.fromCodePoint(0x245f + at)} ${node.이름 || node.열쇠 || ""}`,
|
||||
variant: "ghost",
|
||||
size: 22,
|
||||
iconStart: "chevronRight",
|
||||
onClick: () => toggle(),
|
||||
});
|
||||
head.classList.add("ui-eq__block-head");
|
||||
const toggle = (force?: boolean): void => {
|
||||
const on = force ?? !o.open.has(id);
|
||||
if (on) {
|
||||
o.open.add(id);
|
||||
if (!body.childElementCount && node.나무)
|
||||
body.append(
|
||||
createEqChain(node.나무.줄, {
|
||||
path: id,
|
||||
open: o.open,
|
||||
resultUnit: node.결과단위 ?? node.나무.결과단위,
|
||||
numbered: false,
|
||||
}),
|
||||
);
|
||||
} else o.open.delete(id);
|
||||
box.dataset.open = on ? "1" : "";
|
||||
body.hidden = !on;
|
||||
};
|
||||
body.hidden = true;
|
||||
box.append(
|
||||
div("ui-eq__block-row", head, bundle(node), span("ui-eq__block-key", node.열쇠 ?? "")),
|
||||
body,
|
||||
);
|
||||
if (o.open.has(id)) toggle(true);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 줄 하나 */
|
||||
export function createEqLine(line: EqLine, index: number, o: EqLineOptions): HTMLElement {
|
||||
const id = lineId(line);
|
||||
const root = div("ui-eq__line");
|
||||
root.dataset.eqLine = id;
|
||||
root.dataset.kind = line.갈래;
|
||||
const blocks: EqNode[] = [];
|
||||
const hand: EqHandlers = {
|
||||
onVar: o.onVar,
|
||||
blocks,
|
||||
onBlock: (node) => {
|
||||
root
|
||||
.querySelector<HTMLElement>(
|
||||
`[data-block-id="${CSS.escape(`${o.path}/${node.열쇠 ?? ""}`)}"] .ui-eq__block-head`,
|
||||
)
|
||||
?.click();
|
||||
},
|
||||
};
|
||||
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(
|
||||
span(
|
||||
"ui-eq__box ui-eq__head",
|
||||
head.꼴 !== "변수"
|
||||
? plainEq(head)
|
||||
: line.갈래 === "중간"
|
||||
? renderEq(head, hand) // 누르면 변수 창
|
||||
: symbolOf(head),
|
||||
),
|
||||
);
|
||||
const resUnit =
|
||||
line.갈래 === "호표" || line.갈래 === "덧줄"
|
||||
? "원"
|
||||
: line.갈래 === "결과"
|
||||
? (o.resultUnit ?? "")
|
||||
: (head?.단위 ?? "");
|
||||
const result = span(
|
||||
"ui-eq__box ui-eq__res",
|
||||
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));
|
||||
body.append(span("ui-eq__op", "="), name);
|
||||
const same =
|
||||
plainEq(line.값식) === (line.글 ?? "") || plainEq(line.값식) === plainEq(line.이름식);
|
||||
if (line.값식 && !same)
|
||||
body.append(span("ui-eq__op", "="), span("ui-eq__box ui-eq__val", renderEq(line.값식)));
|
||||
body.append(span("ui-eq__op", "="), result);
|
||||
} else {
|
||||
body.append(
|
||||
span("ui-eq__op", "="),
|
||||
span("ui-eq__box ui-eq__raw", line.식),
|
||||
span("ui-eq__bad", line.까닭 ?? ""),
|
||||
);
|
||||
}
|
||||
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);
|
||||
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);
|
||||
return root;
|
||||
}
|
||||
|
||||
/** 여러 줄 위에서 아래로 이음 */
|
||||
export function createEqChain(lines: EqLine[], o: EqLineOptions): HTMLElement {
|
||||
return div("ui-eq", ...lines.map((l, i) => createEqLine(l, i, o)));
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import { createTabs } from "./ui_template_tabs";
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { createText } from "./ui_template_text";
|
||||
import { createToggle } from "./ui_template_toggle";
|
||||
import { createEqChain } from "./ui_template_equation_line";
|
||||
import type { EqLine, EqNode } from "./ui_template_equation";
|
||||
// 552-3 묶음 부품
|
||||
import { createBanner } from "./ui_template_banner";
|
||||
import { createPickMenu, createRowMenu, type RowMenuItem } from "./ui_template_context_menu";
|
||||
@@ -208,6 +210,15 @@ const kitLampTable = (): HTMLElement =>
|
||||
? `ui-data-table__lamp${i ? " is-linked" : ""}`
|
||||
: `ui-data-table__lamp ui-data-table__lamp--only${i ? " is-linked" : ""}`,
|
||||
}).root;
|
||||
/** 796-2 견본 — Q = 3,600 × q × K × f × E ÷ c_m (분수 · 첨자 · 변수 풀이) */
|
||||
const kitEquation = (): HTMLElement => {
|
||||
const v = (열쇠: string, 기호: string, 값: string, 아래 = "", 갈래 = "입력", 이름 = 열쇠): EqNode => ({ 꼴: "변수", 열쇠, 이름, 기호, 아래, 값, 글: 값, 갈래 });
|
||||
const n = (글: string): EqNode => ({ 꼴: "수", 글, 값: 글 });
|
||||
const mul = (좌: EqNode, 우: EqNode): EqNode => ({ 꼴: "연산", 기호: "×", 좌, 우 });
|
||||
const name = (a: EqNode, b: EqNode): EqNode => ({ 꼴: "분수", 위: mul(mul(mul(mul(n("3,600"), a), b), v("f", "f", "0.7")), v("E", "E", "0.75")), 아래: v("c", "c", "21", "m", "중간", "1회 싸이클시간") });
|
||||
const line: EqLine = { 갈래: "결과", 열쇠: "결과", 식: "", 글: "81", 결과: { 꼴: "변수", 열쇠: "Q", 기호: "Q", 단위: "㎥/hr" }, 이름식: name(v("q", "q", "1", "", "입력", "버킷용량"), v("K", "K", "0.9", "", "찾기", "버킷계수")), 값식: { 꼴: "분수", 위: n("3,600 × 1 × 0.9 × 0.7 × 0.75"), 아래: n("21") } };
|
||||
return createEqChain([line], { path: "", open: new Set(), resultUnit: "㎥/hr" });
|
||||
};
|
||||
const kitKeys = (...keys: string[]): DocumentFragment => {
|
||||
const f = document.createDocumentFragment();
|
||||
f.append(...keys.map((k) => createBadge(k, "neutral")));
|
||||
@@ -381,6 +392,8 @@ export const KIT_PARTS: KitPart[] = [
|
||||
demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) },
|
||||
{ key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts",
|
||||
demo: (s) => (s === "default" ? kitCalc() : null) },
|
||||
{ key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts",
|
||||
demo: (s) => (s === "default" ? kitEquation() : null) },
|
||||
{ key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) },
|
||||
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts",
|
||||
demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4);
|
||||
|
||||
Reference in New Issue
Block a user