/* ============================================================================= * 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 { fetchAuto, fetchLogic, 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, rowRef, 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 => 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 | null = null; const loadAll = (): Promise => (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(); /** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */ async function flowOf( item: Pick, ): 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 => { const it = items.find((x) => x.키 === key); return it ? (await flowOf(it)).flow : null; }; const pick = async (i: MasterLogicBrief): Promise => { 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 empty = got.converted && got.flow.boxes.length === 0; const row = empty ? await fetchLogic(i.키).catch(() => null) : null; const picked = row ? rowRef(i.키, i.이름, i.결과단위, row.logic.입력 ?? [], await fetchAuto(i.키)) : 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(); 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)[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)), ); }