Files
Aislo/M01_MasterData/M01_MasterData_UI_LogicLab_EquationDefs.ts
T
eomsangdonandClaude Sonnet 5.5 f47991f698 feat(800-2): 수식 영역 / 변수 정의 영역 화면 · 식 바로 고침 · 새 기호 연결 지정(800-3)
- 수식 영역 줄 번호 · 구분선 · 줄마다 풀이 걷음 · 오른쪽 변수 정의 영역 부품 ui_template_equation_defs
- 식 칸을 눌러 그 자리 고침(줄 선택 · 아이콘 줄 걷음) · 틀린 식 그 자리 빨강 + 까닭 · 미리 셈
- 새 기호 = 정의 영역 맨 위 줄에서 연결(입력 · 표 · 요소 · 로직 · 상수) · 이름 · 기호 · 첨자 고침 · 지움(쓰는 변수 막음)
- 낡은 시험 test_796_2_equation_screen 두 건: 아이콘 줄 · 줄 선택 고침 → 바로 고침 꼴로 바꿈(동작 걷음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:58:55 +09:00

324 lines
11 KiB
TypeScript

/* =============================================================================
* 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 } : {}) };
},
};
}