Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU
366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* M02_DF_InputPanel_Logic.ts
|
|
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면
|
|
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
|
|
* 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
|
|
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
|
import {
|
|
createButton,
|
|
createInputField,
|
|
createSelectField,
|
|
el,
|
|
showToast,
|
|
} from "@ui/ui_template_elements";
|
|
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
|
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
|
|
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
|
|
import {
|
|
filterLogics,
|
|
logicBooks,
|
|
logicChapters,
|
|
logicRefOf,
|
|
masterRef,
|
|
pageRange,
|
|
setKind,
|
|
setLogicInput,
|
|
wouldCycle,
|
|
type MasterLogicBrief,
|
|
} from "./M02_DF_InputPanel_Build";
|
|
|
|
const TEXT = {
|
|
Help: [
|
|
"마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
|
|
"Use any master logic or a logic of mine as an input",
|
|
],
|
|
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
|
|
Book: ["원문", "Source"],
|
|
Chapter: ["장", "Chapter"],
|
|
All: ["전체", "All"],
|
|
Loading: ["찾는 중…", "Searching…"],
|
|
None: ["찾는 로직 없음", "No logic found"],
|
|
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
|
|
Prev: ["이전", "Prev"],
|
|
Next: ["다음", "Next"],
|
|
Mine: ["내가 만든 것", "Mine"],
|
|
Blocked: ["읽을 수 없음", "Unreadable"],
|
|
Chosen: ["고른 로직", "Chosen logic"],
|
|
Converted: [
|
|
"옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜",
|
|
"Old logic read as a flow — the file is unchanged",
|
|
],
|
|
Notes: ["못 옮긴 곳", "Not carried over"],
|
|
Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"],
|
|
Designs: [
|
|
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)",
|
|
"Pills it takes — its design inputs",
|
|
],
|
|
NoOut: ["출력 상자가 없음", "No output box"],
|
|
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
|
|
Change: ["다른 로직 고르기", "Pick another"],
|
|
Remove: ["빼기", "Remove"],
|
|
Self: ["이 로직 자신은 못 고름", "Can't pick itself"],
|
|
Cycle: [
|
|
"돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름",
|
|
"That would loop — a logic that uses this one can't be picked",
|
|
],
|
|
NoFlow: [
|
|
"이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음",
|
|
"Can't read this logic's flow yet",
|
|
],
|
|
Failed: ["불러오지 못함", "Could not load"],
|
|
Wait: ["값 셈 중…", "Calculating…"],
|
|
} as const;
|
|
|
|
type TKey = keyof typeof TEXT;
|
|
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
|
Object.entries(v).reduce(
|
|
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
|
TEXT[key][currentLanguageIndex] as string,
|
|
);
|
|
|
|
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
|
|
|
|
const plain = (e: unknown): string => {
|
|
try {
|
|
return cfPlain(e);
|
|
} catch {
|
|
return e instanceof Error ? e.message : t("Failed");
|
|
}
|
|
};
|
|
|
|
const PAGE = 12;
|
|
const MINE = "흐름도";
|
|
|
|
/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */
|
|
let cache: Promise<MasterLogicBrief[]> | null = null;
|
|
const loadAll = (): Promise<MasterLogicBrief[]> =>
|
|
(cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])])
|
|
.then(([master, mine]) => [
|
|
...mine
|
|
.filter((i) => !i.sample)
|
|
.map((i): MasterLogicBrief => ({
|
|
file: MINE,
|
|
구분: MINE,
|
|
상세구분: t("Mine"),
|
|
키: i.key,
|
|
원문번호: "",
|
|
이름: i.name,
|
|
결과단위: i.resultUnit,
|
|
})),
|
|
...master,
|
|
])
|
|
.catch((e: unknown) => ((cache = null), Promise.reject(e))));
|
|
|
|
/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
|
|
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
|
|
|
|
/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */
|
|
async function flowOf(
|
|
item: Pick<MasterLogicBrief, "file" | "키">,
|
|
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
|
|
if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] };
|
|
return cfLogicFlow(item.file, item.키);
|
|
}
|
|
|
|
/** 다른 로직 입력 판 */
|
|
export function renderLogicInput(
|
|
host: HTMLElement,
|
|
lg: CfLogic,
|
|
boxId: string,
|
|
on: { change(lg: CfLogic): void },
|
|
): void {
|
|
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
|
|
if (ref) return void showChosen(host, lg, boxId, ref, on);
|
|
|
|
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
|
const all = { value: "", text: t("All") };
|
|
let book = "";
|
|
let chapter = "";
|
|
let page = 1;
|
|
let items: MasterLogicBrief[] = [];
|
|
const bookBox = createSelectField({
|
|
label: t("Book"),
|
|
options: [all],
|
|
value: "",
|
|
compact: true,
|
|
onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()),
|
|
});
|
|
const chapterBox = createSelectField({
|
|
label: t("Chapter"),
|
|
options: [all],
|
|
value: "",
|
|
compact: true,
|
|
onChange: (v) => ((chapter = v), (page = 1), draw()),
|
|
});
|
|
const fillChapters = (): void =>
|
|
chapterBox.setOptions(
|
|
[all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))],
|
|
chapter,
|
|
);
|
|
const list = el("div", { className: "m02-dfip__list" });
|
|
const info = el("span", { className: "m02-dfip__muted" });
|
|
const prev = createButton({
|
|
label: t("Prev"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => ((page -= 1), draw()),
|
|
});
|
|
const next = createButton({
|
|
label: t("Next"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => ((page += 1), draw()),
|
|
});
|
|
|
|
const draw = (): void => {
|
|
const rows = filterLogics(
|
|
items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌
|
|
{ text: q.input.value, book, chapter },
|
|
);
|
|
const range = pageRange(rows.length, page, PAGE);
|
|
page = Math.min(page, range.pages);
|
|
const shown = rows.slice((page - 1) * PAGE, page * PAGE);
|
|
info.textContent = t("Total", {
|
|
n: rows.length,
|
|
a: range.from,
|
|
b: range.to,
|
|
p: page,
|
|
m: range.pages,
|
|
});
|
|
prev.disabled = page <= 1;
|
|
next.disabled = page >= range.pages;
|
|
list.replaceChildren(
|
|
...(shown.length
|
|
? shown.map((i) => {
|
|
const b = el("button", {
|
|
className: "m02-dfip__row",
|
|
attrs: {
|
|
type: "button",
|
|
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
|
|
},
|
|
children: [
|
|
el("span", {
|
|
className: "m02-dfip__name",
|
|
children: [
|
|
el("strong", { text: i.이름 }),
|
|
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
|
|
el("span", {
|
|
className: "m02-dfip__tag",
|
|
text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분,
|
|
}),
|
|
],
|
|
}),
|
|
el("span", { className: "m02-dfip__muted", text: i.결과단위 }),
|
|
],
|
|
});
|
|
b.addEventListener("click", () => void pick(i));
|
|
return b;
|
|
})
|
|
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
|
|
);
|
|
};
|
|
|
|
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
|
|
const loadForCycle = async (key: string): Promise<CfLogic | null> => {
|
|
const it = items.find((x) => x.키 === key);
|
|
return it ? (await flowOf(it)).flow : null;
|
|
};
|
|
|
|
const pick = async (i: MasterLogicBrief): Promise<void> => {
|
|
try {
|
|
if (i.키 === lg.key) return void showToast(t("Self"), "error");
|
|
if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error");
|
|
const got = await flowOf(i);
|
|
readNotes.set(i.키, { converted: got.converted, notes: got.notes });
|
|
const picked = got.converted
|
|
? masterRef(i.키, got.flow, true, i.결과단위)
|
|
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
|
|
const done = setLogicInput(lg, boxId, picked);
|
|
// 기본 이름이면 고른 로직 이름으로
|
|
on.change({
|
|
...done,
|
|
boxes: done.boxes.map((b) =>
|
|
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
|
|
),
|
|
});
|
|
} catch (e) {
|
|
showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error");
|
|
}
|
|
};
|
|
|
|
let timer = 0;
|
|
q.input.addEventListener("input", () => {
|
|
window.clearTimeout(timer);
|
|
timer = window.setTimeout(() => ((page = 1), draw()), 200);
|
|
});
|
|
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
|
|
host.replaceChildren(
|
|
el("p", { className: "m02-dfip__muted", text: t("Help") }),
|
|
el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }),
|
|
info,
|
|
list,
|
|
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
|
|
);
|
|
void loadAll().then(
|
|
(rows) => {
|
|
items = rows;
|
|
bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], "");
|
|
fillChapters();
|
|
draw();
|
|
},
|
|
(e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })),
|
|
);
|
|
}
|
|
|
|
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
|
|
function showChosen(
|
|
host: HTMLElement,
|
|
lg: CfLogic,
|
|
boxId: string,
|
|
ref: CfLogicRef,
|
|
on: { change(lg: CfLogic): void },
|
|
): void {
|
|
const read = readNotes.get(ref.key);
|
|
const values = new Map<string, HTMLElement>();
|
|
const errors = el("p", { className: "m02-dfip__muted is-bad" });
|
|
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
|
|
el("div", {
|
|
className: "m02-dfip__item",
|
|
children: [
|
|
el("span", { className: "m02-dfip__name", text: name }),
|
|
...(valueNode
|
|
? [valueNode]
|
|
: extra
|
|
? [el("span", { className: "m02-dfip__muted", text: extra })]
|
|
: []),
|
|
el("span", { className: "m02-dfip__muted", text: unit || "-" }),
|
|
],
|
|
});
|
|
host.replaceChildren(
|
|
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
|
|
...(read?.converted
|
|
? [
|
|
el("p", { className: "m02-dfip__muted", text: t("Converted") }),
|
|
...(read.notes.length
|
|
? [
|
|
el("details", {
|
|
children: [
|
|
el("summary", { text: `${t("Notes")} · ${read.notes.length}` }),
|
|
...read.notes.map((n) => el("p", { text: n })),
|
|
],
|
|
}),
|
|
]
|
|
: []),
|
|
]
|
|
: []),
|
|
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
|
|
...(ref.outputs.length
|
|
? ref.outputs.map((o) => {
|
|
const v = el("strong", { className: "m02-dfip__value", text: "—" });
|
|
values.set(o.name, v);
|
|
return line(o.name, o.unit, v);
|
|
})
|
|
: [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]),
|
|
errors,
|
|
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
|
|
...(ref.designs.length
|
|
? ref.designs.map((d) =>
|
|
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
|
|
)
|
|
: [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]),
|
|
el("div", {
|
|
className: "m02-dfip__chips",
|
|
children: [
|
|
createButton({
|
|
label: t("Change"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => on.change(setKind(lg, boxId, "다른 로직")),
|
|
}),
|
|
createButton({
|
|
label: t("Remove"),
|
|
variant: "ghost",
|
|
size: "sm",
|
|
onClick: () => on.change(setLogicInput(lg, boxId, null)),
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
// 값 — 서버 계산 길(받는 알약 예시값으로)
|
|
errors.textContent = t("Wait");
|
|
void cfLogicValue(ref.key, ref.examples ?? {}).then(
|
|
(r) => {
|
|
for (const [name, node] of values) {
|
|
const v = r.outputs[name] ?? (r.cost as unknown as Record<string, unknown>)[name];
|
|
if (typeof v === "number") node.textContent = fmt(v);
|
|
else if (typeof v === "string") node.textContent = v;
|
|
}
|
|
errors.textContent = r.errors.join(" · ");
|
|
},
|
|
(e) => (errors.textContent = plain(e)),
|
|
);
|
|
}
|