Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user