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:
2026-11-07 16:58:55 +09:00
co-authored by Claude Sonnet 5.5
parent a262b43e83
commit f47991f698
11 changed files with 808 additions and 280 deletions
@@ -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
+99 -73
View File
@@ -158,37 +158,17 @@
font-weight: var(--font-weight-medium);
}
/* ── 줄 ── */
/* ── 줄(800-2 — 구분선 · 번호 · 풀이 없음 · 식이 차례로 이어짐) ── */
.ui-eq__line {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
padding: var(--spacing-4) 0;
min-width: 0;
}
.ui-eq__line[data-selected] {
box-shadow: inset 3px 0 0 var(--color-accent);
background: var(--color-accent-soft);
}
.ui-eq__line.is-pick > .ui-eq__main {
cursor: pointer;
}
.ui-eq__main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(12rem, 22rem);
gap: var(--spacing-12);
align-items: start;
}
.ui-eq__step {
align-self: flex-start;
min-width: 1.4em;
color: var(--color-text-muted);
font-size: var(--text-caption);
min-width: 0;
}
/* 네 칸 상자 — 결과명 = 이름 식 = 값 식 = 결과 값 */
@@ -226,51 +206,24 @@
font-size: var(--text-caption);
}
/* 변수 풀이 */
.ui-eq__solve {
display: flex;
flex-direction: column;
gap: 2px;
/* 바로 고침 — 이름 식 칸을 누르면 그 자리에서 식 칸 */
.ui-eq__name.is-edit {
cursor: text;
}
.ui-eq__name.is-edit:hover {
border-color: var(--color-accent);
}
.ui-eq__name.is-open {
flex: 1 1 24rem;
align-items: flex-start;
border-color: var(--color-accent);
}
.ui-eq__name.is-open > * {
flex: 1 1 auto;
min-width: 0;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.ui-eq__solve-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 0 var(--spacing-4);
min-width: 0;
}
.ui-eq__solve-row.is-pick {
cursor: pointer;
}
.ui-eq__solve-row.is-pick:hover {
background: var(--color-accent-soft);
}
.ui-eq__solve-name,
.ui-eq__solve-val {
max-width: 12em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-eq__solve-val {
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
}
.ui-eq__solve-src {
color: var(--color-text-muted);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 하위 로직 블록 — 접힘 */
@@ -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);
}
+19
View File
@@ -29,6 +29,7 @@ export interface EqNode {
표?: string;
표이름?: string;
행?: Record<string, unknown>;
조건식?: Record<string, string>;
좌?: EqNode;
우?: EqNode;
안?: EqNode;
@@ -89,6 +90,24 @@ export interface EqTree {
소계?: { 줄: EqSumRow[]; 끝수: Record<string, unknown>; 끝수까닭?: string };
/** M02 SK — 셈 답이 안 서서 바탕 줄 합계를 보일 때의 까닭 */
답까닭?: string;
/** 800-2 — 오른쪽 변수 정의 영역(고른 옵션 갈래만 · 같은 기호 한 줄 · 값 · 단위 · DB 열쇠 없음) */
정의?: EqDef[];
}
/** 변수 정의 한 줄 — 기호 : 이름 — 연결(사람 말 · 여럿이면 줄 바꿈) */
export interface EqDef {
/** 변수 열쇠(입력 · 중간 변수만 · 찾기 · 요소는 빈 글) — 화면 글에는 안 나옴 */
열쇠: string;
기호: string;
위: string;
아래: string;
이름: string;
갈래: string;
연결: string[];
/** 이름 · 기호 · 첨자 고침 · 지움이 되는 변수 */
고침: boolean;
/** 어느 식에도 안 쓰이는 입력(지울 수 있음) */
안씀?: boolean;
}
export interface EqVarRow {
+93
View File
@@ -0,0 +1,93 @@
/* =============================================================================
* ui_template_equation_defs.ts
* 부품 「수식」 넷째 파일(800-2 · 요구 660-2 · 3 · 6) — 오른쪽 「변수 정의 영역」.
* 한 덩어리 목록 · 같은 기호는 한 줄(서버가 합침) · 줄 = 기호 : 국문 이름 — 연결 설명(입력 · 표 찾기 · 요소 · 로직 · 계산을 사람 말로)
* 값 · 단위 · DB 열쇠 글은 안 그림(값은 왼쪽 값 식에 있음) · 고른 옵션 갈래의 변수만(서버 `정의`)
* 상태 = 고침 열린 줄 `data-editing`(줄 아래 `editor` 칸) · 연결 안 한 새 기호 줄 `data-draft`(`drafts` 요소 — 부르는 쪽이 만듦) · 눌러 보인 줄 `data-flash`
* 쓰는 법 = `createEqDefs(defs, { editing, onEdit, editor, drafts, head })` → 요소 · `flashEqDef(root, node)` = 식의 변수를 눌렀을 때 그 정의 줄로 이동
* ========================================================================== */
import { symbolOf, type EqDef, type EqNode } from "./ui_template_equation";
export interface EqDefsOptions {
/** 고침이 열린 정의의 기호 글(`defSign`) */
editing?: string | null;
/** 정의 줄을 누름(없으면 고칠 수 없음 — 고침 가능한 줄만 눌림) */
onEdit?: (def: EqDef) => void;
/** 고침이 열린 줄 아래 칸 */
editor?: (def: EqDef) => HTMLElement;
/** 연결을 지정할 새 기호 줄들 */
drafts?: HTMLElement[];
/** 머리 오른쪽 단추 자리 */
head?: HTMLElement[];
}
const node = (tag: "div" | "span", className: string, text = ""): HTMLElement => {
const out = document.createElement(tag);
out.className = className;
if (text) out.textContent = text;
return out;
};
/** 정의의 기호 글(기호 · 아래 · 위) — 줄 이름표 · 식의 변수와 맞대는 열쇠 */
export const defSign = (d: Pick<EqDef, "기호" | "위" | "아래">): string =>
`${d.기호}${d.아래 ? `_${d.아래}` : ""}${d.위 ? `^${d.위}` : ""}`;
/** 식 변수 마디의 기호 글 — `defSign` 과 같은 꼴 */
export const nodeSign = (n: Pick<EqNode, "기호" | "열쇠" | "위" | "아래">): string =>
defSign({
기호: n.기호 || n.열쇠 || "",
위: typeof n.위 === "string" ? n.위 : "",
아래: typeof n.아래 === "string" ? n.아래 : "",
});
function defRow(d: EqDef, o: EqDefsOptions): HTMLElement {
const sign = defSign(d);
const row = node("div", "ui-eq__def");
row.dataset.eqDef = sign;
row.dataset.kind = d.갈래;
const main = node("div", "ui-eq__def-main");
main.append(symbolOf({ 열쇠: d.열쇠, 기호: d.기호, 위: d.위, 아래: d.아래 }));
if (d.이름 && d.이름 !== d.기호)
main.append(node("span", "ui-eq__op", ":"), node("span", "ui-eq__def-name", d.이름));
if (d.안씀) main.append(node("span", "ui-eq__def-tag", "쓰는 식 없음"));
const links = node("div", "ui-eq__def-links");
for (const text of d.연결) links.append(node("div", "ui-eq__def-link", text));
const top = node("div", "ui-eq__def-top", "");
top.append(main, links);
if (o.onEdit && d.고침) {
row.classList.add("is-pick");
top.title = "눌러서 이름 · 기호 · 첨자 고침 · 지움";
top.addEventListener("click", () => o.onEdit?.(d));
}
row.append(top);
if (o.editing === sign && o.editor) {
row.dataset.editing = "1";
row.append(o.editor(d));
}
return row;
}
export function createEqDefs(defs: EqDef[], o: EqDefsOptions = {}): HTMLElement {
const root = node("div", "ui-eq__defs");
root.dataset.eqDefs = "";
const head = node("div", "ui-eq__defs-head");
head.append(node("span", "ui-eq__defs-title", "변수 정의"), ...(o.head ?? []));
root.append(head);
for (const draft of o.drafts ?? []) root.append(draft); // 새 기호는 맨 위 — 식을 고치는 곳 옆에서 바로 보이게
for (const d of defs) root.append(defRow(d, o));
if (!defs.length && !o.drafts?.length) root.append(node("div", "ui-eq__def-empty", "—"));
return root;
}
/** 식의 변수를 눌렀을 때 — 그 정의 줄로 이동 · 잠깐 표시 */
export function flashEqDef(root: HTMLElement, n: EqNode): void {
const sign = nodeSign(n);
const row = [...root.querySelectorAll<HTMLElement>("[data-eq-def]")].find(
(r) => r.dataset.eqDef === sign,
);
if (!row) return;
row.scrollIntoView({ block: "nearest", behavior: "smooth" });
row.dataset.flash = "1";
window.setTimeout(() => delete row.dataset.flash, 1200);
}
+28 -78
View File
@@ -1,13 +1,13 @@
/* =============================================================================
* ui_template_equation_line.ts
* 부품 「수식」 둘째 파일(796-2) — 줄 · 변수 풀이 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`.
* 줄 = 번호 · 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 오른쪽 변수 풀이(기호 : 이름 = 값 단위 · 출처)
* 부품 「수식」 둘째 파일(796-2 · 800-2) — 수식 영역 줄 · 하위 로직 블록 · 여러 줄 이음. 식 그림 = `ui_template_equation.ts`.
* 줄 = 결과 변수명 = 이름 식 = 값 식 = 결과 값(단위) — 네 칸(PPT 꼴) · 줄 번호 · 구분선 · 줄마다 풀이 없음(변수 정의 = `ui_template_equation_defs.ts`)
* 값 식이 결과와 같으면 값 식 칸 생략 · 식을 못 읽은 줄 = 원문 + 까닭(붉은 글)
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 중간 계산이 한눈에 · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
* 크기 = 글 `--text-body` · 풀이 `--text-caption` · 접힘 단추 22
* 상태 = 고른 줄 `data-selected`(왼쪽 띠 · 식 칸 자리 `slot` 이 줄 아래에 열림) · 펼친 블록 `data-open`
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), selected, onSelect, onVar, slot })`
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음)
* 이음 = 여러 줄 위에서 아래로(`createEqChain`) · 하위 로직 부르기 = 줄 아래 접힘 블록(노 · 재 · 경 · 계 소계 · 펼치면 그 로직 줄 이음)
* 크기 = 글 `--text-body` · 접힘 단추 22
* 상태 = 바로 고침 — 이름 식 칸을 누르면 그 자리가 식 칸으로 바뀜(`onEdit` → 부르는 쪽이 `editing` 을 정해 다시 그림 · `editor` 가 칸을 줌) · 펼친 블록 `data-open`
* 쓰는 법 = `createEqChain(tree.줄, { path: "", open: new Set(), onVar, editing, onEdit, editor })`
* 대신한 옛 변종 = 시트 줄(이름 | 식 | 값 한 줄 · 분수 · 풀이 없음 · 799-2 에 걷음) · 줄 눌러 여는 아래 편집 칸(800-3 에 걷음)
* ========================================================================== */
import { createBadge } from "./ui_template_badge";
@@ -27,18 +27,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)));
}
-1
View File
@@ -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);