Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8

This commit is contained in:
2026-10-01 16:04:46 +09:00
6 changed files with 1092 additions and 44 deletions
@@ -0,0 +1,191 @@
/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */
.m02-cf {
position: fixed;
inset: 0;
z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */
overflow-x: hidden;
overflow-y: auto;
background: var(--color-canvas);
color: var(--color-text);
font-size: var(--text-body);
}
.m02-cf:focus {
outline: none;
}
.m02-cf__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
/* 위 띠 — 밀어도 위에 붙어 있음 */
.m02-cf__head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
padding: var(--spacing-12) var(--spacing-24);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-raised);
}
.m02-cf__title {
margin: 0;
font-size: var(--text-subheading);
}
.m02-cf__trial {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-cf__strip {
display: flex;
flex: 1 1 480px;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
}
.m02-cf__name {
flex: 1 1 260px;
}
.m02-cf__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
.m02-cf__body {
padding: var(--spacing-16) var(--spacing-24) var(--spacing-24);
}
.m02-cf__big {
min-height: 56px;
padding: 0 var(--spacing-24);
font-size: var(--text-body);
}
/* 시작 화면 */
.m02-cf__start {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--spacing-24);
}
.m02-cf__start-col {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-cf__start-col h3 {
margin: var(--spacing-8) 0 0;
}
/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */
.m02-cf__layout {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.m02-cf__part {
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.m02-cf__part h3 {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m02-cf__part-body {
padding: var(--spacing-12);
}
.m02-cf__diagram .m02-cf__part-body {
min-height: 60vh;
}
.m02-cf__lower {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.m02-cf__box .m02-cf__part-body {
min-height: 50vh;
}
.m02-cf__box-slots {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--spacing-12);
}
.m02-cf__slot {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-cf__slot h4 {
margin: 0;
}
@media (max-width: 1100px) {
.m02-cf__lower,
.m02-cf__start,
.m02-cf__box-slots {
grid-template-columns: minmax(0, 1fr);
}
}
.m02-cf__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-cf__item {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-cf__item:hover {
border-color: var(--color-accent);
}
.m02-cf__item-name {
flex: 1;
min-width: 0;
}
.m02-cf__item-unit {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
white-space: nowrap;
}
+288
View File
@@ -0,0 +1,288 @@
/* =============================================================================
* M02_CF_Entry.ts
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 틀
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). CostFlow 의 다른 파일은 import() 로 늦게.
* 1걸음 = 틀 — 다이어그램 · 고른 상자 판 · 원가 합계 · 저장 · 지우기 · 실행 취소 · 견본은 2걸음에 이음.
* 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { CfListItem } from "./M02_CF_Api";
import type { CfLogic } from "./M02_CF_Model";
import "./M02_CF_Entry.css";
const TEXT = {
Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"],
Title: ["단가산출 다이어그램", "Cost diagram"],
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
Start_New: ["새로 만들기", "New"],
Start_Mine: ["내 로직", "My logics"],
Start_MineNone: ["아직 없음", "None yet"],
Start_Samples: ["견본", "Samples"],
Name: ["로직 이름", "Logic name"],
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
Unit: ["결과 단위", "Result unit"],
Undo: ["실행 취소", "Undo"],
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
Diagram: ["다이어그램", "Diagram"],
Box: ["고른 상자", "Chosen box"],
Panel_Input: ["입력", "Input"],
Panel_Function: ["함수", "Function"],
Panel_Output: ["출력", "Output"],
Summary: ["원가", "Cost"],
NotYet: ["준비 중", "Coming soon"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
],
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
} as const;
type TextKey = keyof typeof TEXT;
const te = (key: TextKey): string =>
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string;
const muted = (text: string): HTMLElement => el("p", { className: "m02-cf__muted", text });
/* ponytail: Model(sub7) · Api(sub4) · Sample(sub6) 속이 「준비 중」 인 동안만 대신함 — 채워지면 catch 를 지움 */
const blankLogic = (name: string, unit: string): Promise<CfLogic | null> =>
import("./M02_CF_Model").then((m) => m.newLogic(name, unit)).catch(() => null);
const loadList = (): Promise<CfListItem[]> =>
import("./M02_CF_Api").then((m) => m.cfList()).catch(() => []);
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
async function unitChoices(): Promise<string[]> {
const count = new Map<string, number>();
try {
for (const x of await fetchLogics()) {
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
}
} catch {
/* 못 받으면 기본 하나 */
}
const units = [...count.entries()]
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
.sort((a, b) => b[1] - a[1])
.map(([u]) => u);
return units.length ? units : ["원/㎡"];
}
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
export function createCostFlowButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openCostFlow });
}
function openCostFlow(): void {
/** 고치는 화면이 열려 있을 때만 */
let paper: { dirty(): boolean } | null = null;
let units: string[] | null = null;
const mayLeave = async (): Promise<boolean> =>
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
const body = el("main", { className: "m02-cf__body" });
const strip = el("div", { className: "m02-cf__strip" });
const close = createButton({
label: te("Close"),
variant: "ghost",
onClick: () =>
void (async () => {
if (await mayLeave()) shut();
})(),
});
const page = el("div", {
className: "m02-cf",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") },
children: [
el("header", {
className: "m02-cf__head",
children: [
el("h2", { className: "m02-cf__title", text: te("Title") }),
el("span", { className: "m02-cf__trial", text: te("Trial") }),
strip,
close,
],
}),
body,
],
});
const prevOverflow = document.body.style.overflow;
const shut = (): void => {
page.remove();
document.body.style.overflow = prevOverflow;
};
page.addEventListener("keydown", (ev) => {
if (ev.key !== "Escape" || ev.defaultPrevented) return;
void (async () => {
if (await mayLeave()) shut();
})();
});
/* ---------- 시작 화면 ---------- */
const listRow = (item: CfListItem): HTMLElement => {
const row = el("button", {
className: "m02-cf__item",
attrs: { type: "button", title: item.saved },
children: [
el("span", { className: "m02-cf__item-name", text: item.name }),
el("span", { className: "m02-cf__item-unit", text: item.resultUnit }),
],
});
row.addEventListener("click", () => showToast(te("NotYet"), "info"));
return row;
};
const showStart = (): void => {
paper = null;
strip.replaceChildren();
const mineHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] });
const sampleHost = el("div", { className: "m02-cf__list", children: [muted(te("Loading"))] });
void loadList().then((all) => {
const mine = all.filter((x) => !x.sample);
const samples = all.filter((x) => x.sample);
mineHost.replaceChildren(
...(mine.length ? mine.map(listRow) : [muted(te("Start_MineNone"))]),
);
sampleHost.replaceChildren(
...(samples.length ? samples.map(listRow) : [muted(te("Start_MineNone"))]),
);
});
body.replaceChildren(
el("div", {
className: "m02-cf__start",
children: [
el("section", {
className: "m02-cf__start-col",
children: [
bigButton(te("Start_New"), () => void openNew()),
el("h3", { text: te("Start_Mine") }),
mineHost,
],
}),
el("section", {
className: "m02-cf__start-col",
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
}),
],
}),
);
};
/* ---------- 고치는 화면 ---------- */
const openNew = async (): Promise<void> => {
units ??= await unitChoices();
const lg = await blankLogic("", units[0]);
showPaper(lg, units);
};
const showPaper = (lg: CfLogic | null, choices: string[]): void => {
let dirty = false;
paper = { dirty: () => dirty };
const unsaved = createTag(te("Unsaved"), "warning");
unsaved.hidden = true;
const touched = (): void => {
dirty = true;
unsaved.hidden = false;
};
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
value: lg?.name ?? "",
onInput: touched,
});
name.root.classList.add("m02-cf__name");
const unit = createSelectField({
label: te("Unit"),
options: choices.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
value: lg?.resultUnit ?? choices[0],
onChange: touched,
});
const notYet = (): void => showToast(te("NotYet"), "info");
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
undo.disabled = true;
strip.replaceChildren(
name.root,
unit.root,
unsaved,
el("div", {
className: "m02-cf__actions",
children: [
undo,
createButton({ label: te("Save"), onClick: notYet }),
createButton({ label: te("Delete"), variant: "danger", onClick: notYet }),
],
}),
);
body.replaceChildren(
el("div", {
className: "m02-cf__layout",
children: [
part(te("Diagram"), "m02-cf__diagram", [muted(te("NotYet"))]),
el("div", {
className: "m02-cf__lower",
children: [
part(te("Box"), "m02-cf__box", [
el("div", {
className: "m02-cf__box-slots",
children: [
slot(te("Panel_Input")),
slot(te("Panel_Function")),
slot(te("Panel_Output")),
],
}),
]),
part(te("Summary"), "m02-cf__summary", [muted(te("NotYet"))]),
],
}),
],
}),
);
name.input.focus();
};
showStart();
document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림
document.body.append(page);
page.tabIndex = -1;
page.focus();
}
/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */
function part(title: string, className: string, children: HTMLElement[]): HTMLElement {
return el("section", {
className: `m02-cf__part ${className}`,
children: [el("h3", { text: title }), el("div", { className: "m02-cf__part-body", children })],
});
}
/** 고른 상자 판의 종류별 자리 — 2걸음에 입력 · 함수 · 출력 판이 들어옴 */
function slot(title: string): HTMLElement {
return el("div", {
className: "m02-cf__slot",
children: [el("h4", { text: title }), muted(te("NotYet"))],
});
}
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "filled", onClick });
button.classList.add("m02-cf__big");
return button;
}
+171 -11
View File
@@ -2,9 +2,14 @@
* M02_CF_Eval.ts
* 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈
* (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음
* 엔진을 정적으로 가져옴 — 화면(Entry)은 CostFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게
* ========================================================================== */
import type { CfLogic } from "./M02_CF_Model";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_CF_Model";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
export type CfValue = number | string | boolean | null | (number | string | null)[][];
@@ -12,33 +17,188 @@ export type CfValue = number | string | boolean | null | (number | string | null
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */
export type CfValues = Record<string, Record<string, CfValue>>;
type Scalar = number | string | boolean | null;
export interface CfEvalResult {
/** 상자 id → 내보내는 알약 이름 → 값 */
ports: Record<string, Record<string, CfValue>>;
/** 출력 상자 id → 값 */
outputs: Record<string, number | string | boolean | null>;
outputs: Record<string, Scalar>;
cost: {
노무비: number;
재료비: number;
경비: number;
/** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */
값들: Record<string, number | string | boolean | null>;
값들: Record<string, Scalar>;
계: number;
};
/** 쉬운 말 */
errors: string[];
}
const notReady = (): never => {
throw new Error("준비 중");
const TEXT = {
/** {box} {cell} {err} */
CellError: [
"「{box}」 {cell} 칸 계산이 막힘 — {err}",
"「{box}」 cell {cell} can't be worked out — {err}",
],
Cycle: ["돌고 도는 참조", "circular reference"],
/** {box} */
Loop: ["「{box}」 이 돌고 도는 선에 걸림", "「{box}」 is in a loop of lines"],
NotNumber: ["「{box}」 에 숫자가 아닌 값이 들어옴", "「{box}」 got a value that isn't a number"],
/** {box} {pill} */
EmptyIn: [
"「{box}」 의 받는 알약 「{pill}」 에 선이 없음",
"Pill 「{pill}」 of 「{box}」 has no line",
],
NoCell: ["「{box}」 의 출력 칸이 정해지지 않음", "Output cell of 「{box}」 is not set"],
NoOutput: ["출력 상자가 없음", "There is no output box"],
NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"],
} as const;
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
Object.entries(fill).reduce(
(s, [k, v]) => s.replace(`{${k}}`, v),
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
);
/* ── 차례 ───────────────────────────────────────────────────────────────── */
/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */
function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } {
const funcs = lg.boxes.filter((b) => b.kind === "function");
const done: CfBox[] = [];
const left = new Set(funcs.map((f) => f.id));
for (let moved = true; moved && left.size;) {
moved = false;
for (const f of funcs) {
if (!left.has(f.id)) continue;
const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box));
if (waits) continue;
done.push(f);
left.delete(f.id);
moved = true;
}
}
return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) };
}
/* ── 칸 ─────────────────────────────────────────────────────────────────── */
function putValue(칸: Record<string, Cell>, cell: string, value: Scalar): void {
const style = 칸[cell]?.서식;
if (value === null) {
if (style) 칸[cell] = { 서식: style };
else delete 칸[cell];
} else 칸[cell] = { 값: value, ...(style ? { 서식: style } : {}) };
}
/** 칸에 값 넣기 — 표는 cell 을 왼위로 펼침 */
function put(칸: Record<string, Cell>, cell: string, value: CfValue): void {
const at = parseA1(cell);
if (!at) return;
if (!Array.isArray(value)) return putValue(칸, toA1(at.r, at.c), value);
value.forEach((line, dr) => line.forEach((x, dc) => putValue(칸, toA1(at.r + dr, at.c + dc), x)));
}
const fromCalc = (v: CalcValue | undefined): Scalar => {
if (!v) return null;
if ("수" in v) return Number(v.수);
if ("글" in v) return v.글;
if ("참" in v) return v.참;
return v.오류;
};
/** 화면 계산 */
export function evalLogic(_lg: CfLogic, _values: CfValues = {}): CfEvalResult {
return notReady();
/* ── 계산 ───────────────────────────────────────────────────────────────── */
/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */
function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record<string, CfValue> {
const given = values[box.id] ?? {};
const pills = portsOut(box);
const out: Record<string, CfValue> = {};
const inp = box.input;
if (!inp) return out;
// portsOut 차례 = 요소 · 표 · 변수
const sources: CfValue[] = [
...(inp.mode === "group"
? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null)
: []),
...(inp.mode === "group" && inp.table ? [null] : []),
...inp.variables.map((v) => v.example),
];
pills.forEach((p, i) => (out[p.name] = p.name in given ? given[p.name] : (sources[i] ?? null)));
return out;
}
/** 검사 — 빈 받는 자리 · 출력 하나 아님 · 순환 · 오류(쉬운 말 목록) */
export function checkLogic(_lg: CfLogic, _values: CfValues = {}): string[] {
return notReady();
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */
function runFunc(
box: CfBox,
incoming: Record<string, CfValue>,
given: Record<string, CfValue>,
errors: string[],
): Scalar {
const func = box.func!;
const sheet: Sheet = structuredClone(func.sheet);
for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null);
for (const v of func.variables)
if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example);
const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "CF", 서식: [{}], 시트: [sheet] });
for (const [cell, v] of Object.entries(values[sheet.id] ?? {}))
if ("오류" in v) {
const err = v.오류 === "#CYCLE!" ? `${v.오류} ${tl("Cycle")}` : v.오류;
errors.push(tl("CellError", { box: box.name, cell, err }));
}
const at = func.output.cell.toUpperCase();
const c = sheet.칸[at];
if (!c) return null;
return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
}
/** 화면 계산 */
export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult {
const errors: string[] = [];
const ports: CfEvalResult["ports"] = {};
for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values);
const into = (box: string): Record<string, CfValue> =>
Object.fromEntries(
lg.links
.filter((l) => l.to.box === box)
.map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]),
);
const { funcs, loop } = order(lg);
for (const f of loop) errors.push(tl("Loop", { box: f.name }));
for (const f of funcs)
ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) };
const outputs: CfEvalResult["outputs"] = {};
const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
for (const b of lg.boxes.filter((x) => x.kind === "output")) {
const v = into(b.id)[OUTPUT_PORT] ?? null;
const scalar: Scalar = Array.isArray(v) ? null : v;
outputs[b.id] = scalar;
const kind = b.output?.cost ?? "값";
if (kind === "값") cost.값들[b.name] = scalar;
else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar;
else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name }));
}
cost.계 = cost.노무비 + cost.재료비 + cost.경비;
return { ports, outputs, cost, errors };
}
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] {
const out: string[] = [];
for (const b of lg.boxes) {
for (const p of portsIn(b))
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
out.push(tl("EmptyIn", { box: b.name, pill: p.name }));
if (b.kind === "function" && !parseA1(b.func?.output.cell ?? ""))
out.push(tl("NoCell", { box: b.name }));
}
if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput"));
const ev = evalLogic(lg, values);
for (const b of lg.boxes.filter((x) => x.kind === "input"))
for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {}))
if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill))
out.push(tl("NoValue", { box: b.name, pill }));
return [...out, ...ev.errors];
}
+164 -33
View File
@@ -4,6 +4,7 @@
* 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";
@@ -105,60 +106,190 @@ export interface CfPill {
unit: string;
}
const notReady = (): never => {
throw new Error("준비 중");
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 function newLogic(_name?: string, _resultUnit?: string): CfLogic {
return notReady();
/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */
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,
kind: CfKind,
x: number,
y: number,
): { lg: CfLogic; box: CfBox } {
return notReady();
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")
box.func = {
sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: tl("Result") } } },
inputs: [],
variables: [],
output: { cell: "B1", name: tl("Result"), unit: "-" },
};
if (kind === "output") box.output = { cost: "노무비", unit: "-" };
return { lg: { ...lg, boxes: [...lg.boxes, box] }, box };
}
/** 상자 지우기 — 이어진 선도 지움 */
export function removeBox(_lg: CfLogic, _id: string): CfLogic {
return notReady();
export function removeBox(lg: CfLogic, id: string): CfLogic {
return {
...lg,
boxes: lg.boxes.filter((b) => b.id !== id),
links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id),
};
}
export function moveBox(_lg: CfLogic, _id: string, _x: number, _y: number): CfLogic {
return notReady();
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 notReady();
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 {
return notReady();
/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */
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);
}
/** 내보내는 알약 — 입력 = 요소 · 표 · 변수마다 · 함수 = 출력 하나 · 출력 = 없음 */
export function portsOut(_box: CfBox): CfPill[] {
return notReady();
/* ── 알약 ───────────────────────────────────────────────────────────────── */
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 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 || "-" })),
];
}
/** 받는 알약 — 함수 = 입력 알약 · 출력 = 하나 · 입력 = 없음 */
export function portsIn(_box: CfBox): CfPill[] {
return notReady();
/** 받는 알약 — 함수 = 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 [];
}
/** 선 잇기 — 규칙에 어긋나면 막고 까닭(쉬운 말) */
export function link(
_lg: CfLogic,
_from: CfPort,
_to: CfPort,
): { lg: CfLogic; why: string | null } {
return notReady();
/* ── 선 ─────────────────────────────────────────────────────────────────── */
/** 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);
}
export function unlink(_lg: CfLogic, _id: string): CfLogic {
return notReady();
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음 */
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) || !portsIn(b).some((p) => p.name === to.name))
return { lg, why: tl("NoPill") };
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
if (lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
return { lg, why: tl("Taken") };
const id = nextId(
"l",
lg.links.map((l) => l.id),
);
return {
lg: { ...lg, links: [...lg.links, { id, from: { ...from }, to: { ...to } }] },
why: null,
};
}
export function unlink(lg: CfLogic, id: string): CfLogic {
return { ...lg, links: lg.links.filter((l) => l.id !== id) };
}