feat(800-2): 수식 영역 / 변수 정의 영역 화면 · 식 바로 고침 · 새 기호 연결 지정(800-3)
- 수식 영역 줄 번호 · 구분선 · 줄마다 풀이 걷음 · 오른쪽 변수 정의 영역 부품 ui_template_equation_defs - 식 칸을 눌러 그 자리 고침(줄 선택 · 아이콘 줄 걷음) · 틀린 식 그 자리 빨강 + 까닭 · 미리 셈 - 새 기호 = 정의 영역 맨 위 줄에서 연결(입력 · 표 · 요소 · 로직 · 상수) · 이름 · 기호 · 첨자 고침 · 지움(쓰는 변수 막음) - 낡은 시험 test_796_2_equation_screen 두 건: 아이콘 줄 · 줄 선택 고침 → 바로 고침 꼴로 바꿈(동작 걷음) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(16rem, 24rem);
|
||||
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: 900px) {
|
||||
.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 {
|
||||
@@ -467,23 +521,28 @@ export function createLabEquation(opt: LabEquationOptions) {
|
||||
}
|
||||
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 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,
|
||||
}),
|
||||
...(sum ? [sum] : []), // 799-1 소계 · 끝수 · 합계
|
||||
],
|
||||
});
|
||||
defsBox = buildDefs();
|
||||
body.replaceChildren(el("div", { className: "m01-eq__split", children: [left, defsBox] }));
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
@@ -494,7 +553,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 +573,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 +588,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 [];
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ def _link(node: dict, spec: dict | None, leaf: dict | None) -> str:
|
||||
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 '표'}"
|
||||
@@ -70,7 +72,9 @@ def definitions(lines: list[dict], row: dict, rows: list[dict], unused) -> list[
|
||||
for line in lines:
|
||||
head, tree = line.get("결과"), line.get("이름식")
|
||||
if line.get("갈래") in ("중간", "결과") and isinstance(tree, dict):
|
||||
if tree.get("갈래") in ("찾기", "요소") or tree.get("꼴") == "블록": # 한 마디 식
|
||||
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, []
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 하위 로직 블록 — 접힘 */
|
||||
@@ -313,12 +266,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 +358,82 @@
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,16 @@ 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;
|
||||
/** 결과 줄 값 뒤 단위 */
|
||||
resultUnit?: string;
|
||||
/** 줄 번호 1 부터 보임 */
|
||||
numbered?: boolean;
|
||||
}
|
||||
|
||||
const BUNDLE: [string, string][] = [
|
||||
@@ -67,52 +65,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 +101,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);
|
||||
@@ -183,7 +134,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 +152,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 +175,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 +204,12 @@ 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);
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ function itemRow(r: EqSumRow, open: Set<string>, at: number, hooks?: EqSumHooks)
|
||||
path: id,
|
||||
open,
|
||||
resultUnit: tree.결과단위,
|
||||
numbered: false,
|
||||
}),
|
||||
);
|
||||
} else open.delete(id);
|
||||
|
||||
Reference in New Issue
Block a user