/* ============================================================================= * M01_MasterData_UI_LogicLab_Flow.ts * 로직 상세 두 칸 — 왼쪽 지금 내용(보기) · 가운데 끌기 막대 · 오른쪽 흐름도 패널 * 너비 — 패널이 없으면 두 칸 비율(끌기 막대 · localStorage) · 패널이 있으면 패널이 알린 너비(px · 패널 가장자리 끌기) * 잇기(mountLogicFlow) — 패널(M02_DF_Panel) · 왼쪽 보기(LogicLab_FlowView) · 입력 값 · 다른 로직 흐름 받기 * DataFlow 파일은 import() 로 늦게(엔진이 M01 첫 묶음에 안 들어가게). 정함 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 2 · 5 · 6 · 7 * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { el } from "@ui/ui_template_elements"; import { API_BASE_URL } from "@config/config_frontend"; import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model"; import type { CfValues } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval"; import "./M01_MasterData_UI_LogicLab_Flow.css"; const TEXT = { Resize: ["두 칸 너비 — 끌어서 조절", "Drag to resize the two columns"], New: ["신규 로직", "New logic"], Waiting: ["흐름도 불러오는 중", "Loading the flow diagram"], Converted: [ "옛 식을 흐름도로 바꿔 보임 — 저장하면 흐름도가 정본", "Converted from the old formula — saving makes the flow the master", ], Failed: ["흐름도를 못 띄움 — {v}", "Couldn't open the flow diagram — {v}"], } as const; export const tf = (key: keyof typeof TEXT, v = ""): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v); const STORE = "m01.logic.flowRatio"; const MIN = 0.25; const MAX = 0.75; const clamp = (r: number): number => Math.min(MAX, Math.max(MIN, r)); function readRatio(): number { try { const r = Number(localStorage.getItem(STORE)); return r ? clamp(r) : 0.5; } catch { return 0.5; } } export interface SplitHandle { root: HTMLElement; /** 오른쪽 칸 — 흐름도 패널을 붙이는 곳 */ right: HTMLElement; /** 패널이 정한 너비(px) — 끌기 막대는 숨김(패널 가장자리로 조절) · null = 비율로 돌아감 */ setRight(px: number | null): void; } /** 두 칸 만들기 — left 는 지금 화면 그대로 왼쪽에 둠 */ export function buildSplit(left: HTMLElement): SplitHandle { const right = el("div", { className: "m01lab-flow__right" }); const bar = el("div", { className: "m01lab-flow__bar", attrs: { role: "separator", "aria-orientation": "vertical", "aria-label": tf("Resize"), title: tf("Resize"), tabindex: "0", }, }); const leftCol = el("div", { className: "m01lab-flow__left", children: [left] }); const root = el("div", { className: "m01lab-flow", children: [leftCol, bar, right] }); let ratio = readRatio(); let panelPx: number | null = null; const apply = (): void => { root.style.gridTemplateColumns = panelPx === null ? `minmax(0, ${ratio}fr) auto minmax(0, ${1 - ratio}fr)` : `minmax(0, 1fr) ${panelPx}px`; }; const keep = (): void => { try { localStorage.setItem(STORE, String(ratio)); } catch { /* 기억 못 해도 지금 화면은 그대로 */ } }; bar.addEventListener("pointerdown", (ev) => { if (ev.button !== 0) return; ev.preventDefault(); const box = root.getBoundingClientRect(); const move = (m: PointerEvent): void => { ratio = clamp((m.clientX - box.left) / box.width); apply(); }; const up = (): void => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); keep(); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); }); // 키보드 — 왼쪽 · 오른쪽 화살표로 조금씩 bar.addEventListener("keydown", (ev) => { const step = ev.key === "ArrowLeft" ? -0.05 : ev.key === "ArrowRight" ? 0.05 : 0; if (!step) return; ev.preventDefault(); ratio = clamp(ratio + step); apply(); keep(); }); const setRight = (px: number | null): void => { panelPx = px; bar.hidden = px !== null; apply(); }; apply(); return { root, right, setRight }; } /* ── 흐름 받기 · 잇기 ─────────────────────────────────────────────────── */ export interface FlowAnswer { flow: CfLogic; converted: boolean; notes: string[]; } /** 마스터 로직의 흐름 — 줄에 흐름이 있으면 그대로 · 없으면 서버가 옛 식을 바꿔 줌 · 길이 없거나 못 받으면 null */ export async function fetchFlow(key: string, file = ""): Promise { try { const q = new URLSearchParams({ key, ...(file ? { file } : {}) }); const r = await fetch(`${API_BASE_URL}/m01/logic/flow?${q}`, { credentials: "include" }); if (!r.ok) return null; const got = (await r.json()) as Partial; return got.flow ? { flow: got.flow, converted: !!got.converted, notes: got.notes ?? [] } : null; } catch { return null; } } const loadFlowMods = () => Promise.all([ import("../M02_MasterTemplete/DataFlow/M02_DF_Panel"), import("./M01_MasterData_UI_LogicLab_FlowView"), import("../M02_MasterTemplete/DataFlow/M02_DF_Model"), import("../M02_MasterTemplete/DataFlow/M02_DF_Eval"), import("../M02_MasterTemplete/DataFlow/M02_DF_Api"), import("../M02_MasterTemplete/DataFlow/M02_DF_Entry_Detail"), ]).then(([panel, view, model, ev, api, detail]) => ({ panel, view, model, ev, api, detail })); /** 빈 흐름 — 신규 로직 · 흐름을 못 받은 로직 */ export async function emptyFlow(name: string, unit: string): Promise { return (await import("../M02_MasterTemplete/DataFlow/M02_DF_Model")).newLogic(name, unit); } /* ── 시험 입력 한 벌 — 왼쪽 시험 계산 입력 ↔ 흐름 변수 예시값(이름이 같은 변수끼리) ── */ const varsOf = (lg: CfLogic) => lg.boxes.flatMap((b) => [...(b.input?.variables ?? []), ...(b.func?.variables ?? [])]); /** 흐름 변수 예시값 — 이름 → 글(빈 값은 뺌) */ export function examplesOf(lg: CfLogic): Record { const out: Record = {}; for (const v of varsOf(lg)) if (v.example !== null && v.example !== undefined && v.example !== "") out[v.name] = String(v.example); return out; } /** 예시값을 넣은 새 흐름 — 수처럼 생긴 글은 수로 · 빈 값은 그대로 둠 */ export function withExamples(lg: CfLogic, vals: Record): CfLogic { const next = JSON.parse(JSON.stringify(lg)) as CfLogic; for (const v of varsOf(next)) { const raw = (vals[v.name] ?? "").trim(); if (raw) v.example = /^-?\d+(\.\d+)?$/.test(raw) ? Number(raw) : raw; } return next; } /** 예시값만 다른가 — 그 밖이 같으면 고친 것으로 안 셈 */ export function sameShape(a: CfLogic, b: CfLogic): boolean { const strip = (lg: CfLogic): string => { const c = JSON.parse(JSON.stringify(lg)) as CfLogic; for (const v of varsOf(c)) v.example = null; return JSON.stringify(c); }; return strip(a) === strip(b); } export interface LogicFlowOpts { split: SplitHandle; flow: CfLogic; title: string; readOnly: boolean; /** 변환 알림 — 패널 위 한 줄 */ notes: string[]; converted: boolean; /** 흐름 로직이면 왼쪽 「지금 내용」 자리(LogicLab_Detail 의 flowHost) */ leftHost(): HTMLElement | null; onChange(lg: CfLogic): void; } export interface LogicFlow { /** 밖에서 바꿈(고친 것 버리기) — 패널 · 왼쪽 함께 */ set(lg: CfLogic): void; /** 왼쪽만 다시(왼쪽 화면을 새로 그린 뒤) */ redrawLeft(): void; /** 왼쪽 시험 계산 입력이 바뀜 — 같은 이름 변수의 예시값으로(고친 것 아님) */ setExamples(vals: Record): void; destroy(): void; } /** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */ export async function mountLogicFlow(opts: LogicFlowOpts): Promise { const M = await loadFlowMods(); const host = el("div", { className: "m01lab-flow__panel" }); const note = el("p", { className: "m01lab-flow__note" }); const notes = [...(opts.converted ? [tf("Converted")] : []), ...opts.notes]; note.textContent = notes.join(" · "); note.hidden = !notes.length; opts.split.right.replaceChildren(note, host); let lg = opts.flow; let values: CfValues = {}; let valueSig = ""; let logics: Record = {}; let logicSig = ""; let gone = false; /** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */ const getLogic = async (key: string): Promise => key.startsWith("DF-") ? M.api.cfGet(key) : ((await fetchFlow(key))?.flow ?? null); const left = (): void => { const at = opts.leftHost(); if (!at || gone) return; M.view.buildFlowView(at, lg, values, { logics }); }; /** 입력 값 · 다른 로직 — 물음이 바뀐 때만 다시 받음 · 받으면 왼쪽 다시 */ const refresh = (): void => { let ports: Record> = {}; try { ports = M.ev.evalLogic(lg, values, { logics }).ports; } catch { /* 셈이 막혀도 물음은 보냄 */ } const pill = (p: { box: string; name: string }): unknown => ports[p.box]?.[p.name] ?? null; const asks = M.detail.queriesOf(lg, pill, M.model.elementPort); const sig = JSON.stringify(asks.map((a) => a.q)); if (sig !== valueSig) { valueSig = sig; void M.detail.fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then( (got) => { if (sig !== valueSig || gone) return; values = got.values; left(); refresh(); // 받은 값으로 조건 찾기 값이 바뀌면 한 번 더 }, () => undefined, ); } const keys = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? "")); if (keys !== logicSig) { logicSig = keys; void M.detail.loadLogics(lg, getLogic).then((got) => { if (keys !== logicSig || gone) return; logics = got.logics; left(); }); } }; const panel = M.panel.mountFlowPanel(host, { logic: lg, title: opts.title, readOnly: opts.readOnly, logics: getLogic, onResize: (px) => opts.split.setRight(px), onChange: (next) => { lg = next; opts.onChange(next); left(); refresh(); }, }); left(); refresh(); return { set: (next) => { lg = next; panel.set(next); left(); refresh(); }, redrawLeft: left, setExamples: (vals) => { const next = withExamples(lg, vals); if (JSON.stringify(next) === JSON.stringify(lg)) return; lg = next; panel.set(next); left(); refresh(); }, destroy: () => { gone = true; panel.destroy(); opts.split.setRight(null); opts.split.right.replaceChildren(); }, }; }