- 로직을 열 때 왼쪽 시험 계산 입력(자동값 포함)을 이름이 같은 흐름 변수 예시값으로 - 왼쪽 입력을 바꾸면 흐름 예시값도 · 흐름 검증 칸 예시값을 바꾸면 왼쪽 입력도 바꾸고 다시 셈 - 예시값만 바뀐 것은 고친 것(drafts)으로 안 셈(sameShape) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt
308 lines
11 KiB
TypeScript
308 lines
11 KiB
TypeScript
/* =============================================================================
|
|
* 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<FlowAnswer | null> {
|
|
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<FlowAnswer>;
|
|
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<CfLogic> {
|
|
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<string, string> {
|
|
const out: Record<string, string> = {};
|
|
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<string, string>): 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<string, string>): void;
|
|
destroy(): void;
|
|
}
|
|
|
|
/** 오른쪽 패널을 띄우고 왼쪽 보기 · 입력 값 · 다른 로직을 이음 */
|
|
export async function mountLogicFlow(opts: LogicFlowOpts): Promise<LogicFlow> {
|
|
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<string, CfLogic> = {};
|
|
let logicSig = "";
|
|
let gone = false;
|
|
|
|
/** 다른 로직 — 흐름도 저장본(DF-) · 마스터 로직(흐름 받기) */
|
|
const getLogic = async (key: string): Promise<CfLogic | null> =>
|
|
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<string, Record<string, unknown>> = {};
|
|
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();
|
|
},
|
|
};
|
|
}
|