- 수식 영역 줄 번호 · 구분선 · 줄마다 풀이 걷음 · 오른쪽 변수 정의 영역 부품 ui_template_equation_defs - 식 칸을 눌러 그 자리 고침(줄 선택 · 아이콘 줄 걷음) · 틀린 식 그 자리 빨강 + 까닭 · 미리 셈 - 새 기호 = 정의 영역 맨 위 줄에서 연결(입력 · 표 · 요소 · 로직 · 상수) · 이름 · 기호 · 첨자 고침 · 지움(쓰는 변수 막음) - 낡은 시험 test_796_2_equation_screen 두 건: 아이콘 줄 · 줄 선택 고침 → 바로 고침 꼴로 바꿈(동작 걷음) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
324 lines
11 KiB
TypeScript
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 } : {}) };
|
|
},
|
|
};
|
|
}
|