Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
490 lines
17 KiB
TypeScript
490 lines
17 KiB
TypeScript
/* =============================================================================
|
||
* M02_DF_Verify.ts
|
||
* 흐름도 검증 칸(오른쪽 아래) — 끊긴 곳 목록(누르면 그 상자 · 선을 고름) + 「설계 때 정할 것」 전부:
|
||
* 변수 예시값 · 재료 선택 자리(범위 안 자재 목록에서 고르기 · 찾기 · 쪽) · 재료 조건 자리(지금 잡힌 품목 · 0 · 여럿 까닭)
|
||
* · 이어지지 않은 다른 로직의 받는 알약. 바꾸면 on.change(고친 다이어그램) · 줄을 누르면 on.select.
|
||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 4 · 5 · 6 · 8 · 13. 이 폴더째 지워도 됨.
|
||
* ========================================================================== */
|
||
|
||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||
import { breaks, type CfEvalOptions, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
|
||
import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material";
|
||
import {
|
||
designInputs,
|
||
elementPort,
|
||
type CfBox,
|
||
type CfElementRef,
|
||
type CfLogic,
|
||
type CfVariable,
|
||
} from "./M02_DF_Model";
|
||
import "./M02_DF_Verify.css";
|
||
|
||
const TEXT = {
|
||
Breaks: ["끊긴 곳", "Broken spots"],
|
||
BreaksHint: ["누르면 그 상자 · 선을 고름", "Click to select the box or line"],
|
||
Rest: ["쉬는 갈래", "Resting branches"],
|
||
RestHint: [
|
||
"조건이 안 맞아 쉬는 곳 — 누르면 그 선을 고름",
|
||
"Idle because a condition is not met — click to select the line",
|
||
],
|
||
Design: ["설계 때 정할 것", "To decide at design time"],
|
||
DesignHint: ["여기서 바꾸면 그 상자도 바뀜", "Changing here changes the box too"],
|
||
Vars: ["변수 예시값", "Variable samples"],
|
||
Slots: ["재료 선택 자리", "Material choice"],
|
||
Rules: ["재료 조건 자리", "Material by condition"],
|
||
Logics: ["이어지지 않은 다른 로직 값(예시값)", "Unlinked values of other logics (samples)"],
|
||
Choose: ["고르기", "Choose"],
|
||
Close: ["닫기", "Close"],
|
||
Find: ["찾기", "Find"],
|
||
Prev: ["이전", "Prev"],
|
||
Next: ["다음", "Next"],
|
||
Search: ["재료 이름 · 규격", "Material name or size"],
|
||
Page: ["쪽 {p} / {n} · 모두 {t}", "Page {p} / {n} · {t} in all"],
|
||
NoHit: ["찾은 재료 없음", "No material found"],
|
||
NoDefault: ["아직 고르지 않음", "Nothing chosen yet"],
|
||
Price: ["단가", "Unit price"],
|
||
Won: ["원", "KRW"],
|
||
Loading: ["찾는 중", "Searching"],
|
||
NeedValue: ["조건 값이 아직 없음", "The condition value is not there yet"],
|
||
Caught: ["잡힌 품목", "Matched item"],
|
||
Zero: ["맞는 재료 없음(0)", "No matching material (0)"],
|
||
Many: ["여럿 맞음({n}) — 첫째를 씀", "{n} match — the first is used"],
|
||
Cond: ["{field}에 「{v}」 {op}", "{field} {op} “{v}”"],
|
||
Pick: ["선택", "Choose"],
|
||
Bad: ["숫자를 넣어야 함", "Enter a number"],
|
||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||
Cant: ["지금은 바꿀 수 없음", "Cannot change this now"],
|
||
OwnSample: ["비우면 그 로직 예시값", "Empty = the logic's own sample"],
|
||
OtherLogic: [
|
||
"이어 주지 않아 그 로직의 예시값을 씀",
|
||
"Not linked — the logic's own sample is used",
|
||
],
|
||
} as const;
|
||
|
||
type TextKey = keyof typeof TEXT;
|
||
const tv = (key: TextKey, fill: Record<string, string | number> = {}): string =>
|
||
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(/\{(\w+)\}/g, (m, k: string) =>
|
||
k in fill ? String(fill[k]) : m,
|
||
);
|
||
|
||
/** 고른 상자 · 알약 · 선 — 다이어그램이 이 모양으로 고름 */
|
||
export interface CfSel {
|
||
box: string;
|
||
port?: string;
|
||
link?: string;
|
||
}
|
||
|
||
export interface CfVerifyOn {
|
||
change(lg: CfLogic): void;
|
||
select?(sel: CfSel): void;
|
||
}
|
||
|
||
const PAGE = 10;
|
||
|
||
/** 재료 요소 하나 고치기 — 나머지는 그대로 */
|
||
function withElement(
|
||
lg: CfLogic,
|
||
boxId: string,
|
||
name: string,
|
||
patch: Partial<CfElementRef>,
|
||
): CfLogic {
|
||
return {
|
||
...lg,
|
||
boxes: lg.boxes.map((b) =>
|
||
b.id === boxId && b.input
|
||
? {
|
||
...b,
|
||
input: {
|
||
...b.input,
|
||
elements: b.input.elements.map((e) => (e.name === name ? { ...e, ...patch } : e)),
|
||
},
|
||
}
|
||
: b,
|
||
),
|
||
};
|
||
}
|
||
|
||
const matText = (m: CfMat): string =>
|
||
`${m.name}${m.spec ? ` ${m.spec}` : ""}${m.unit ? ` · ${m.unit}` : ""}`;
|
||
|
||
/** 변수 예시값 칸 */
|
||
function sampleField(
|
||
v: CfVariable,
|
||
put: (value: number | string) => void,
|
||
msg: HTMLElement,
|
||
): HTMLElement {
|
||
const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name });
|
||
let control: HTMLElement;
|
||
if (v.type === "choice" && v.choices?.length) {
|
||
control = createSelectField({
|
||
options: [
|
||
{ value: "", text: tv("Pick") },
|
||
...v.choices.map((c) => ({ value: String(c), text: String(c) })),
|
||
],
|
||
value: v.example === null ? "" : String(v.example),
|
||
compact: true,
|
||
ariaLabel: v.name,
|
||
onChange: (s) => {
|
||
const hit = v.choices?.find((c) => String(c) === s);
|
||
if (hit !== undefined) put(hit);
|
||
},
|
||
}).root;
|
||
} else {
|
||
const box = el("input", {
|
||
className: "m02-dfver__input",
|
||
attrs: {
|
||
type: "text",
|
||
inputmode: v.type === "number" ? "decimal" : "text",
|
||
"aria-label": v.name,
|
||
},
|
||
});
|
||
if (v.range) box.placeholder = tv("Range", { a: v.range[0], b: v.range[1] });
|
||
box.value = v.example === null ? "" : String(v.example);
|
||
box.addEventListener("change", () => {
|
||
const raw = box.value.trim();
|
||
if (raw === "") return;
|
||
if (v.type !== "number") return put(raw);
|
||
const n = Number(raw);
|
||
if (Number.isNaN(n)) msg.textContent = tv("Bad");
|
||
else put(n);
|
||
});
|
||
control = box;
|
||
}
|
||
return el("label", { className: "m02-dfver__field", children: [label, control] });
|
||
}
|
||
|
||
/** 재료 선택 자리 한 줄 — 지금 품목 · 단가 · 「고르기」(찾기 · 쪽) */
|
||
function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTMLElement {
|
||
const now = el("span", { className: "m02-dfver__now", text: e.default ? "…" : tv("NoDefault") });
|
||
const price = el("span", { className: "m02-dfver__price" });
|
||
if (e.default) {
|
||
void materialOf(e.default).then((m) => (now.textContent = m ? matText(m) : (e.default ?? "")));
|
||
void priceOf(e.default).then((p) => {
|
||
price.textContent = p === null ? "" : `${tv("Price")} ${formatMoney(p)} ${tv("Won")}`;
|
||
});
|
||
}
|
||
const panel = el("div", { className: "m02-dfver__panel" });
|
||
panel.hidden = true;
|
||
const q = el("input", {
|
||
className: "m02-dfver__input",
|
||
attrs: { type: "text", placeholder: tv("Search"), "aria-label": tv("Search") },
|
||
});
|
||
q.value = e.scope?.찾는말 ?? "";
|
||
const list = el("ul", { className: "m02-dfver__list" });
|
||
const pager = el("div", { className: "m02-dfver__pager" });
|
||
let page = 1;
|
||
const load = async (): Promise<void> => {
|
||
list.replaceChildren(el("li", { className: "m02-dfver__muted", text: tv("Loading") }));
|
||
const got = await findMaterials({
|
||
q: q.value.trim(),
|
||
sub: e.scope?.구분,
|
||
detail: e.scope?.상세구분,
|
||
page,
|
||
size: PAGE,
|
||
});
|
||
const pages = Math.max(1, Math.ceil(got.total / PAGE));
|
||
list.replaceChildren(
|
||
...(got.items.length
|
||
? got.items.map((m) =>
|
||
el("li", {
|
||
children: [
|
||
createButton({
|
||
label: matText(m),
|
||
variant: "ghost",
|
||
size: "sm",
|
||
onClick: () => on.change(withElement(lg, box.id, e.name, { default: m.ref })),
|
||
}),
|
||
],
|
||
}),
|
||
)
|
||
: [el("li", { className: "m02-dfver__muted", text: tv("NoHit") })]),
|
||
);
|
||
pager.replaceChildren(
|
||
createButton({
|
||
label: tv("Prev"),
|
||
size: "sm",
|
||
variant: "ghost",
|
||
disabled: page <= 1,
|
||
onClick: () => {
|
||
page--;
|
||
void load();
|
||
},
|
||
}),
|
||
el("span", {
|
||
className: "m02-dfver__muted",
|
||
text: tv("Page", { p: got.page, n: pages, t: got.total }),
|
||
}),
|
||
createButton({
|
||
label: tv("Next"),
|
||
size: "sm",
|
||
variant: "ghost",
|
||
disabled: page >= pages,
|
||
onClick: () => {
|
||
page++;
|
||
void load();
|
||
},
|
||
}),
|
||
);
|
||
};
|
||
q.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter") {
|
||
page = 1;
|
||
void load();
|
||
}
|
||
});
|
||
const toggle = createButton({
|
||
label: tv("Choose"),
|
||
size: "sm",
|
||
onClick: () => {
|
||
panel.hidden = !panel.hidden;
|
||
toggle.querySelector(".ui-btn__label")!.textContent = panel.hidden
|
||
? tv("Choose")
|
||
: tv("Close");
|
||
if (!panel.hidden) void load();
|
||
},
|
||
});
|
||
panel.append(
|
||
el("div", {
|
||
className: "m02-dfver__search",
|
||
children: [
|
||
q,
|
||
createButton({
|
||
label: tv("Find"),
|
||
size: "sm",
|
||
onClick: () => {
|
||
page = 1;
|
||
void load();
|
||
},
|
||
}),
|
||
],
|
||
}),
|
||
list,
|
||
pager,
|
||
);
|
||
return el("div", {
|
||
className: "m02-dfver__item",
|
||
children: [
|
||
el("div", {
|
||
className: "m02-dfver__line",
|
||
children: [el("strong", { text: elementPort(e) }), now, price, toggle],
|
||
}),
|
||
panel,
|
||
],
|
||
});
|
||
}
|
||
|
||
/** 재료 조건 자리 한 줄 — 지금 잡힌 품목 · 맞는 것 0 · 여럿이면 까닭 */
|
||
function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElement {
|
||
const rule = e.rule!;
|
||
const raw = result.ports[rule.from.box]?.[rule.from.name];
|
||
const value = Array.isArray(raw) || raw === null || raw === undefined ? "" : String(raw);
|
||
const state = el("span", {
|
||
className: "m02-dfver__now",
|
||
text: value ? tv("Loading") : tv("NeedValue"),
|
||
});
|
||
const note = el("span", { className: "m02-dfver__muted" });
|
||
if (value) {
|
||
void findMaterials({
|
||
q: e.scope?.찾는말,
|
||
sub: e.scope?.구분,
|
||
detail: e.scope?.상세구분,
|
||
size: 100,
|
||
}).then((got) => {
|
||
const hit = got.items.filter((m) => {
|
||
const t = rule.field === "규격" ? m.spec : m.name;
|
||
return rule.op === "같음" ? t === value : t.includes(value);
|
||
});
|
||
const first = hit[0];
|
||
state.className = `m02-dfver__now${hit.length === 1 ? "" : " m02-dfver__warn"}`;
|
||
state.textContent = !first ? tv("Zero") : `${tv("Caught")}: ${matText(first)}`;
|
||
note.textContent = hit.length > 1 ? tv("Many", { n: hit.length }) : "";
|
||
});
|
||
}
|
||
const from = lg.boxes.find((b) => b.id === rule.from.box)?.name ?? "";
|
||
return el("div", {
|
||
className: "m02-dfver__item",
|
||
children: [
|
||
el("div", {
|
||
className: "m02-dfver__line",
|
||
children: [el("strong", { text: elementPort(e) }), state, note],
|
||
}),
|
||
el("span", {
|
||
className: "m02-dfver__muted",
|
||
text: `${from ? `${from} · ` : ""}${tv("Cond", { field: rule.field, v: value || rule.from.name, op: rule.op })}`,
|
||
}),
|
||
],
|
||
});
|
||
}
|
||
|
||
/** 검증 칸 — values = 받은 입력 값(끊긴 곳 검사가 씀) */
|
||
export function renderVerify(
|
||
host: HTMLElement,
|
||
lg: CfLogic,
|
||
result: CfEvalResult,
|
||
on: CfVerifyOn,
|
||
opts: { values?: CfValues; logics?: CfEvalOptions["logics"] } = {},
|
||
): void {
|
||
const msg = el("p", { className: "m02-dfver__msg" });
|
||
const safe = (make: () => CfLogic): void => {
|
||
msg.textContent = "";
|
||
try {
|
||
on.change(make());
|
||
} catch {
|
||
msg.textContent = tv("Cant");
|
||
}
|
||
};
|
||
|
||
/* 끊긴 곳 */
|
||
const spots = breaks(lg, opts.values ?? {}, { logics: opts.logics });
|
||
const spotList = (list: typeof spots, hint: TextKey): HTMLElement[] => [
|
||
el("ul", {
|
||
className: "m02-dfver__list",
|
||
children: list.map((s) =>
|
||
el("li", {
|
||
children: [
|
||
createButton({
|
||
label: s.text,
|
||
variant: "ghost",
|
||
size: "sm",
|
||
disabled: !s.box || !on.select,
|
||
onClick: () => on.select?.({ box: s.box, port: s.port, link: s.link }),
|
||
}),
|
||
],
|
||
}),
|
||
),
|
||
}),
|
||
el("p", { className: "m02-dfver__muted", text: tv(hint) }),
|
||
];
|
||
const problems = spots.filter((s) => s.level !== "쉼");
|
||
const resting = spots.filter((s) => s.level === "쉼");
|
||
const spotBlock = [
|
||
...(problems.length
|
||
? [
|
||
el("div", {
|
||
className: "m02-dfver__block m02-dfver__breaks",
|
||
children: [el("strong", { text: tv("Breaks") }), ...spotList(problems, "BreaksHint")],
|
||
}),
|
||
]
|
||
: []),
|
||
...(resting.length
|
||
? [
|
||
el("div", {
|
||
className: "m02-dfver__block m02-dfver__rest",
|
||
children: [el("strong", { text: tv("Rest") }), ...spotList(resting, "RestHint")],
|
||
}),
|
||
]
|
||
: []),
|
||
];
|
||
|
||
/* 설계 때 정할 것 — Model `designInputs`(변수 · 재료 자리 · 다른 로직) + 재료 조건 자리 */
|
||
const designs = designInputs(lg);
|
||
const boxOf = (id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id);
|
||
/** 변수 예시값을 그 상자(입력 · 함수)의 변수에 되적음 */
|
||
const putExample = (box: string, name: string, value: number | string): CfLogic => ({
|
||
...lg,
|
||
boxes: lg.boxes.map((x) => {
|
||
if (x.id !== box) return x;
|
||
const set = (list: CfVariable[]): CfVariable[] =>
|
||
list.map((y) => (y.name === name ? { ...y, example: value } : y));
|
||
return {
|
||
...x,
|
||
...(x.input ? { input: { ...x.input, variables: set(x.input.variables) } } : {}),
|
||
...(x.func ? { func: { ...x.func, variables: set(x.func.variables) } } : {}),
|
||
};
|
||
}),
|
||
});
|
||
const vars = designs.flatMap((d) =>
|
||
d.kind === "변수" && d.variable
|
||
? [sampleField(d.variable, (value) => safe(() => putExample(d.box, d.port, value)), msg)]
|
||
: [],
|
||
);
|
||
const slots = designs.flatMap((d) => {
|
||
const box = boxOf(d.box);
|
||
return d.kind === "재료 자리" && d.element && box
|
||
? [slotRow(box, d.element, { ...on, change: (n) => safe(() => n) }, lg)]
|
||
: [];
|
||
});
|
||
const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input);
|
||
const rules = inputs.flatMap((b) =>
|
||
(b.input?.elements ?? [])
|
||
.filter((e) => e.mode === "rule" && e.rule)
|
||
.map((e) => ruleRow(e, lg, result)),
|
||
);
|
||
/** 다른 로직의 받는 알약 — 예시값을 적으면 logic.examples · 비우면 그 로직 자기 예시값 */
|
||
const putLogicExample = (box: string, port: string, raw: string): CfLogic => ({
|
||
...lg,
|
||
boxes: lg.boxes.map((x) => {
|
||
if (x.id !== box || !x.input?.logic) return x;
|
||
const examples = { ...x.input.logic.examples };
|
||
const text = raw.trim();
|
||
if (text === "") delete examples[port];
|
||
else examples[port] = Number.isNaN(Number(text)) ? text : Number(text);
|
||
return { ...x, input: { ...x.input, logic: { ...x.input.logic, examples } } };
|
||
}),
|
||
});
|
||
const logics = designs.flatMap((d) => {
|
||
if (d.kind !== "다른 로직") return [];
|
||
const input = el("input", {
|
||
className: "m02-dfver__input",
|
||
attrs: { type: "text", placeholder: tv("OwnSample"), "aria-label": d.port },
|
||
});
|
||
input.value = d.example === null || d.example === undefined ? "" : String(d.example);
|
||
input.addEventListener("change", () => safe(() => putLogicExample(d.box, d.port, input.value)));
|
||
const unit = d.unit && d.unit !== "-" ? ` (${d.unit})` : "";
|
||
return [
|
||
el("li", {
|
||
className: "m02-dfver__line",
|
||
children: [
|
||
createButton({
|
||
label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${unit}`,
|
||
variant: "ghost",
|
||
size: "sm",
|
||
disabled: !on.select,
|
||
onClick: () => on.select?.({ box: d.box, port: d.port }),
|
||
}),
|
||
input,
|
||
],
|
||
}),
|
||
];
|
||
});
|
||
const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] =>
|
||
body.length
|
||
? [
|
||
el("div", {
|
||
className: "m02-dfver__group",
|
||
children: [el("strong", { text: tv(title) }), ...body],
|
||
}),
|
||
]
|
||
: [];
|
||
const design = [
|
||
...group(
|
||
"Vars",
|
||
[el("div", { className: "m02-dfver__fields", children: vars })].filter(() => vars.length),
|
||
),
|
||
...group("Slots", slots),
|
||
...group("Rules", rules),
|
||
...group(
|
||
"Logics",
|
||
logics.length ? [el("ul", { className: "m02-dfver__list", children: logics })] : [],
|
||
),
|
||
];
|
||
host.replaceChildren(
|
||
...spotBlock,
|
||
...(design.length
|
||
? [
|
||
el("div", {
|
||
className: "m02-dfver__block",
|
||
children: [
|
||
el("strong", { className: "m02-dfver__title", text: tv("Design") }),
|
||
...design,
|
||
el("p", { className: "m02-dfver__muted", text: tv("DesignHint") }),
|
||
msg,
|
||
],
|
||
}),
|
||
]
|
||
: []),
|
||
);
|
||
}
|