Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_Model.ts
T
eomsangdonandClaude Opus 5.5 fc7affb019 feat(m02-df): 단가산출 흐름도 0걸음 — CostFlow 를 DataFlow 로 옮겨 이름 바꿈
- 파일 M02_CF_* → M02_DF_* · css m02-cf/m01-cf → m02-df · 서버 길 /api/m02/cf → /api/m02/df · 키 CF- → DF- · 사용자 요소 UE- 그대로
- _sample · .gitignore(_data git 밖) 옮김 · DataFlow 는 CostFlow 를 import 안 함
- 시험 test_m02_df_model · router · sample · inputpanel_build(옛 cf 시험은 그대로)
- 서버 등록은 sub1 몫 — M02_MasterTemplete.DataFlow.M02_DF_Router 의 router

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
2026-10-01 19:37:51 +09:00

330 lines
12 KiB
TypeScript

/* =============================================================================
* M02_DF_Model.ts
* 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나)
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
export type CfKind = "input" | "function" | "output";
export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율";
export type CfCost = "노무비" | "재료비" | "경비" | "값";
/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id */
export interface CfElementRef {
ref: string;
name: string;
spec: string;
unit: string;
}
/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */
export interface CfVariable {
name: string;
type: "number" | "choice" | "text";
unit: string;
choices?: (string | number)[];
range?: [number, number];
example: number | string | null;
/** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */
cell?: string;
}
export interface CfInput {
mode: "group" | "variable";
group?: CfGroup;
elements: CfElementRef[];
/** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */
table?: { key: string; row?: number };
variables: CfVariable[];
}
export interface CfFunc {
/** 우리 스프레드시트 시트 하나 */
sheet: Sheet;
/** 받는 알약 → 칸 */
inputs: { port: string; cell: string }[];
variables: CfVariable[];
/** 내보내는 알약 하나 */
output: { cell: string; name: string; unit: string };
}
export interface CfOutput {
cost: CfCost;
unit: string;
}
export interface CfBox {
id: string;
kind: CfKind;
name: string;
color: string;
/** 오른쪽 위 단위 — 기본 「-」 */
unit: string;
x: number;
y: number;
input?: CfInput;
func?: CfFunc;
output?: CfOutput;
}
/** 알약 한쪽 끝 */
export interface CfPort {
box: string;
name: string;
}
export interface CfLink {
id: string;
from: CfPort;
to: CfPort;
}
/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */
export interface CfUserElement {
id: string;
group: CfGroup;
name: string;
spec: string;
unit: string;
value: number | null;
}
export interface CfLogic {
key: string | null;
name: string;
resultUnit: string;
boxes: CfBox[];
links: CfLink[];
userElements: CfUserElement[];
}
/** 알약 하나의 모습 */
export interface CfPill {
name: string;
unit: string;
}
const TEXT = {
Input: ["입력", "Input"],
Func: ["함수", "Function"],
Output: ["출력", "Output"],
Result: ["결과", "Result"],
Value: ["값", "Value"],
NoPill: ["알약이 없음", "No such pill"],
FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"],
ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"],
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
Taken: ["이미 선이 들어온 자리", "This spot already has a line"],
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 종류별 기본 색 */
export const BOX_COLOR: Record<CfKind, string> = {
input: "#2f80ed",
function: "#7b4fd6",
output: "#27ae60",
};
/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */
export const OUTPUT_PORT = "값";
export const TABLE_PORT = "표";
const clone = <T>(v: T): T => structuredClone(v);
/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */
function nextId(prefix: string, ids: string[]): string {
const nums = ids
.filter((x) => x.startsWith(prefix))
.map((x) => Number(x.slice(prefix.length)))
.filter((n) => Number.isInteger(n));
return `${prefix}${Math.max(0, ...nums) + 1}`;
}
/** 같은 이름이면 「 2」 「 3」 */
function uniqueName(used: string[], base: string): string {
if (!used.includes(base)) return base;
for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`;
}
const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id);
/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */
export function updateBox(
lg: CfLogic,
id: string,
patch: Partial<Omit<CfBox, "id" | "kind">>,
): CfLogic {
return {
...lg,
boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)),
};
}
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic {
return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] };
}
/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */
export function addBox(
lg: CfLogic,
kind: CfKind,
x: number,
y: number,
): { lg: CfLogic; box: CfBox } {
const id = nextId(
"b",
lg.boxes.map((b) => b.id),
);
const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output");
const name = uniqueName(
lg.boxes.map((b) => b.name),
base,
);
const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y };
if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] };
if (kind === "function") {
// 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음
const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : []));
let n = 1;
while (taken.includes(`${tl("Result")} ${n}`)) n++;
const out = `${tl("Result")} ${n}`;
box.func = {
sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } },
inputs: [],
variables: [],
output: { cell: "B1", name: out, unit: "-" },
};
}
if (kind === "output") box.output = { cost: "노무비", unit: "-" };
return { lg: { ...lg, boxes: [...lg.boxes, box] }, box };
}
/** 상자 지우기 — 이어진 선도 지움 */
export function removeBox(lg: CfLogic, id: string): CfLogic {
// 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리
let next = lg;
for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id);
return { ...next, boxes: next.boxes.filter((b) => b.id !== id) };
}
export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic {
return updateBox(lg, id, { x, y });
}
export function setColor(lg: CfLogic, id: string, color: string): CfLogic {
return updateBox(lg, id, { color });
}
/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */
export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic {
const box = boxOf(lg, id);
if (!box) return lg;
const u = unit.trim() || "-";
const patch: Partial<CfBox> = { unit: u };
if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } };
if (box.output) patch.output = { ...box.output, unit: u };
return updateBox(lg, id, patch);
}
/* ── 알약 ───────────────────────────────────────────────────────────────── */
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name
* 함수 = func.output.name 하나 · 출력 = 없음 */
export function portsOut(box: CfBox): CfPill[] {
if (box.kind === "function" && box.func)
return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }];
if (box.kind !== "input" || !box.input) return [];
const inp = box.input;
return [
...(inp.mode === "group"
? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" }))
: []),
...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []),
...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })),
];
}
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */
export function portsIn(box: CfBox): CfPill[] {
if (box.kind === "function" && box.func)
return box.func.inputs.map((i) => ({ name: i.port, unit: "-" }));
if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }];
return [];
}
/* ── 선 ─────────────────────────────────────────────────────────────────── */
/** from 상자에서 선을 따라 to 상자에 닿나 */
export function reaches(lg: CfLogic, from: string, to: string): boolean {
const seen = new Set<string>();
const walk = (id: string): boolean => {
if (id === to) return true;
if (seen.has(id)) return false;
seen.add(id);
return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box));
};
return walk(from);
}
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음
* 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 ·
* 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */
export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } {
const a = boxOf(lg, from.box);
const b = boxOf(lg, to.box);
if (!a || !b) return { lg, why: tl("NoPill") };
if (a.kind === "output") return { lg, why: tl("FromOutput") };
if (b.kind === "input") return { lg, why: tl("ToInput") };
if (a.id === b.id) return { lg, why: tl("Self") };
if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") };
const has = portsIn(b).some((p) => p.name === to.name);
if (!has && b.kind !== "function") return { lg, why: tl("NoPill") };
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
return { lg, why: tl("Taken") };
let next = lg;
let port = to.name;
if (!has) {
const used = portsIn(b).map((p) => p.name);
const base = to.name.trim() || from.name;
port = used.includes(base) ? uniqueSuffix(used, base) : base;
const func = b.func!;
next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } });
}
const id = nextId(
"l",
next.links.map((l) => l.id),
);
return {
lg: {
...next,
links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }],
},
why: null,
};
}
/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */
function uniqueSuffix(used: string[], base: string): string {
for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`;
}
/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */
export function unlink(lg: CfLogic, id: string): CfLogic {
const gone = lg.links.find((l) => l.id === id);
const next = { ...lg, links: lg.links.filter((l) => l.id !== id) };
const box = gone && boxOf(next, gone.to.box);
if (!gone || !box?.func) return next;
const port = box.func.inputs.find((i) => i.port === gone.to.name);
const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name);
if (!port || port.cell.trim() || still) return next;
const inputs = box.func.inputs.filter((i) => i !== port);
return updateBox(next, box.id, { func: { ...box.func, inputs } });
}