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:
@@ -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 } : {}) };
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user