Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Verify.ts
T

490 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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,
],
}),
]
: []),
);
}