Merge remote-tracking branch 'origin/dev' into sub_laptop_4

This commit is contained in:
2026-10-01 23:41:42 +09:00
40 changed files with 3098 additions and 232 deletions
+19
View File
@@ -206,6 +206,25 @@ export async function cfLogicValue(
return send("/logic-value", { key, designs });
}
/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true ·
* notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */
export async function cfLogicFlow(
file: string,
key: string,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, {
credentials: "include",
});
} catch {
throw new CfError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
return data as { flow: CfLogic; converted: boolean; notes: string[] };
}
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */
export async function cfDesignInputs(key: string): Promise<CfDesign[]> {
const qs = new URLSearchParams({ key }).toString();
@@ -0,0 +1,135 @@
/* =============================================================================
* M02_DF_CellTable.ts
* 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기
* 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfBox, CfLogic } from "./M02_DF_Model";
import { portText } from "./M02_DF_Detail";
const TEXT = {
Title: ["칸 연결 표", "Cell connections"],
Cell: ["칸", "Cell"],
Pill: ["연결 알약", "Pill"],
Kind: ["종류", "Kind"],
Line: ["이어진 선", "Line"],
In: ["입력", "Input"],
Var: ["변수", "Variable"],
Out: ["결과", "Result"],
None: ["아직 연결된 칸이 없음", "No cell is connected yet"],
Unplaced: ["칸 없음", "no cell"],
NoLine: ["-", "-"],
Release: ["풀기", "Release"],
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
} as const;
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
export type CellKind = "in" | "var" | "out";
export interface CellTableHandlers {
/** 표에서 칸을 바꿈 — key = 알약 이름 */
move(kind: CellKind, key: string, cell: string): void;
/** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */
release(kind: CellKind, key: string): void;
}
interface Row {
kind: CellKind;
key: string;
cell: string;
line: string;
}
function rowsOf(lg: CfLogic, box: CfBox): Row[] {
const f = box.func;
if (!f) return [];
const into = (port: string): string => {
const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port);
return l ? portText(lg, l.from) : tt("NoLine");
};
const out = (): string => {
const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name);
return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine");
};
return [
...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })),
...f.variables.map((v): Row => ({
kind: "var",
key: v.name,
cell: v.cell ?? "",
line: tt("NoLine"),
})),
{ kind: "out", key: f.output.name, cell: f.output.cell, line: out() },
];
}
/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */
export function renderCellTable(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
readOnly: boolean,
handlers: CellTableHandlers,
): void {
host.classList.add("m02-dfcells");
const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key);
const head = el("tr", {
children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) =>
el("th", { text: t }),
),
});
const body = rows.map((r) => {
const input = el("input", {
className: "ui-input ui-input--sm m02-dfcells__cell",
attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") },
});
input.value = r.cell;
input.readOnly = readOnly;
input.addEventListener("change", () => {
const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim());
input.classList.toggle("is-bad", !m);
input.title = m ? "" : tt("BadCell");
if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`);
});
return el("tr", {
className: `m02-dfcells__row is-${r.kind}`,
children: [
el("td", { children: [input] }),
el("td", { text: r.key }),
el("td", {
children: [
el("span", {
className: "m02-dfcells__kind",
text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"),
}),
],
}),
el("td", { text: r.line }),
el("td", {
children:
readOnly || (r.kind !== "var" && !r.cell)
? []
: [
createButton({
label: tt("Release"),
variant: "ghost",
size: "sm",
onClick: () => handlers.release(r.kind, r.key),
}),
],
}),
],
});
});
host.replaceChildren(
el("strong", { className: "m02-dfcells__title", text: tt("Title") }),
el("table", {
className: "m02-dfcells__table",
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
}),
);
}
@@ -320,7 +320,7 @@ export function renderDiagram(
return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 };
});
const r = view.getBoundingClientRect();
Object.assign(st, fitView(rects, { w: r.width, h: r.height }));
Object.assign(st, fitView(rects, { w: r.width, h: r.height }, 40, opts.fitMin));
applyView();
drawLines();
};
@@ -122,6 +122,8 @@ export interface DiagramOpts {
readOnly?: boolean;
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
fitKey?: number;
/** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */
fitMin?: number;
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
@@ -193,13 +195,14 @@ export function fitView(
rects: { x: number; y: number; w: number; h: number }[],
view: { w: number; h: number },
pad = 40,
min = FIT_MIN,
): { zoom: number; panX: number; panY: number } {
if (!rects.length) return { zoom: 1, panX: 0, panY: 0 };
const x0 = Math.min(...rects.map((r) => r.x)) - pad;
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
const zoom = clampZoom(Math.max(min, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
const place = (size: number, from: number, to: number): number =>
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
+1 -1
View File
@@ -70,7 +70,7 @@
/* 시작 화면 */
.m02-df__start {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-24);
}
+2 -24
View File
@@ -47,12 +47,11 @@ const loadMods = () =>
import("./M02_DF_FuncPanel"),
import("./M02_DF_OutputPanel"),
import("./M02_DF_Summary"),
import("./M02_DF_Sample"),
import("./M02_DF_InputPanel"),
import("./M02_DF_PillPanel"),
import("./M02_DF_LinkPanel"),
import("./M02_DF_Standards"),
]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({
]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({
model,
ev,
api,
@@ -60,7 +59,6 @@ const loadMods = () =>
func,
output,
summary,
sample,
input,
pill,
line,
@@ -78,7 +76,6 @@ const TEXT = {
Start_New: ["새로 만들기", "New"],
Start_Mine: ["내 로직", "My logics"],
Start_MineNone: ["아직 없음", "None yet"],
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
Name: ["로직 이름", "Logic name"],
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
Unit: ["결과 단위", "Result unit"],
@@ -217,7 +214,6 @@ function openDataFlow(): void {
paper = null;
strip.replaceChildren();
const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] });
const sampleHost = el("div", { className: "m02-df__list" });
body.replaceChildren(
el("div", {
className: "m02-df__start",
@@ -232,28 +228,10 @@ function openDataFlow(): void {
mineHost,
],
}),
el("section", {
className: "m02-df__start-col",
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
}),
],
}),
);
const M = await mods;
sampleHost.replaceChildren(
...M.sample.sampleList().map((s) =>
listRow(
s.name,
s.resultUnit,
() =>
run(async (M2) => {
const lg = M2.sample.sampleLoad(s.id);
if (lg) await showPaper(M2, lg, false);
}),
s.note,
),
),
);
try {
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
mineHost.replaceChildren(
@@ -379,7 +357,7 @@ function openDataFlow(): void {
if (sig === logicSig) return;
logicSig = sig;
const get = async (key: string): Promise<CfLogic | null> =>
key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key);
key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null);
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;
@@ -0,0 +1,284 @@
/* =============================================================================
* M02_DF_FormulaView.ts
* 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 ·
* IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀
* 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text";
import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { CfBox } from "./M02_DF_Model";
export type CellScalar = number | string | boolean | null;
export interface FormulaBranch {
when: string;
then: string;
/** 지금 타는 갈래 */
now: boolean;
}
export interface FormulaLine {
/** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */
name: string;
/** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */
named: string;
/** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */
valued: string;
/** IF 면 만약 / 아니면 줄 */
branches?: FormulaBranch[];
}
const TEXT = {
Title: ["식 보기", "Formulas"],
Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"],
Else: ["아니면", "Otherwise"],
If: ["만약", "If"],
Now: ["지금", "now"],
} as const;
const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */
const REF = /(?<![A-Za-z0-9_.'!])\$?([A-Z]{1,3})\$?(\d+)(?![A-Za-z0-9_(])/g;
/** 따옴표 안 글은 그대로 두고 바깥 글만 바꿈 */
function outside(text: string, fn: (part: string) => string): string {
return text
.split(/("(?:[^"]|"")*")/)
.map((p, i) => (i % 2 ? p : fn(p)))
.join("");
}
const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, "");
/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */
export function cellNames(box: CfBox): Record<string, string> {
const f = box.func;
const out: Record<string, string> = {};
if (!f) return out;
for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port;
for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name;
if (f.output.cell) out[upper(f.output.cell)] = f.output.name;
return out;
}
const refsIn = (formula: string): string[] => {
const found: string[] = [];
outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m)));
return found;
};
const OPS: [RegExp, string][] = [
[/\*/g, " × "],
[/\//g, " ÷ "],
[/<>/g, " ≠ "],
[/>=/g, " ≥ "],
[/<=/g, " ≤ "],
[/(?<![<>≠≥≤])=/g, " = "],
[/\+/g, " + "],
[/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "],
];
/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */
function pretty(text: string): string {
return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p))
.replace(/\s{2,}/g, " ")
.trim();
}
const fmt = (n: number): string =>
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
function showScalar(v: CellScalar | undefined): string {
if (v === undefined || v === null) return "0";
if (typeof v === "number") return fmt(v);
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return `"${v}"`;
}
/** 이름으로 쓴 식 */
export function namedText(formula: string, names: Record<string, string>): string {
return pretty(
outside(formula, (p) =>
p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`),
),
);
}
/** 값 넣은 식(값이 문자면 따옴표) */
export function valuedText(formula: string, values: Record<string, CellScalar>): string {
return pretty(
outside(formula, (p) =>
p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])),
),
);
}
/* ── 갈래 ───────────────────────────────────────────────────────────────── */
const num = (v: CellScalar | undefined): number | null => {
if (typeof v === "number") return v;
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
return null;
};
/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */
function holds(cond: string, values: Record<string, CellScalar>): boolean | null {
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond);
if (!m) return null;
const read = (t: string): CellScalar | undefined => {
const x = t.trim();
const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x);
if (cell) return values[`${cell[1]}${cell[2]}`];
const q = /^"((?:[^"]|"")*)"$/.exec(x);
if (q) return q[1].replace(/""/g, '"');
return Number.isFinite(Number(x)) ? Number(x) : undefined;
};
const a = read(m[1]);
const b = read(m[3]);
if (a === undefined || b === undefined) return null;
const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)];
switch (m[2]) {
case "=":
return l === r;
case "<>":
return l !== r;
case ">=":
return l >= r;
case "<=":
return l <= r;
case ">":
return l > r;
default:
return l < r;
}
}
/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */
function branchesOf(
formula: string,
names: Record<string, string>,
values: Record<string, CellScalar>,
): FormulaBranch[] | undefined {
const out: FormulaBranch[] = [];
let rest = formula;
let taken = false;
for (;;) {
const body = ifBody(rest);
if (body === null) break;
const a = splitArgs(body);
if (a.length !== 3) return undefined;
const ok = holds(a[0], values) === true && !taken;
if (ok) taken = true;
out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok });
rest = a[2];
}
if (!out.length) return undefined;
out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken });
return out;
}
/* ── 줄 ─────────────────────────────────────────────────────────────────── */
/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */
export function formulaLines(box: CfBox, values: Record<string, CellScalar>): FormulaLine[] {
const f = box.func;
if (!f?.output.cell) return [];
const names = cellNames(box);
const cells = f.sheet.칸;
const order: string[] = [];
const seen = new Set<string>();
const visit = (a1: string): void => {
const key = upper(a1);
if (seen.has(key)) return;
seen.add(key);
const formula = cells[key]?.식;
if (formula === undefined) return;
for (const r of refsIn(formula)) visit(r);
order.push(key);
};
visit(f.output.cell);
return order.map((key) => {
const formula = cells[key].식 as string;
const result = showScalar(values[key]);
const branches = branchesOf(formula, names, values);
return {
name: names[key] ?? key,
named: namedText(formula, names),
valued: `${valuedText(formula, values)} = ${result}`,
...(branches ? { branches } : {}),
};
});
}
/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */
export function cellValuesOf(
box: CfBox,
arrived: Record<string, unknown> | undefined,
): Record<string, CellScalar> {
const f = box.func;
if (!f) return {};
const sheet = structuredClone(f.sheet);
const scalar = (v: unknown): number | string | boolean | undefined =>
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined;
const put = (cell: string, v: number | string | boolean | undefined): void => {
if (!parseA1(cell) || v === undefined) return;
sheet.칸[upper(cell)] = { 값: v };
};
for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port]));
for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example);
const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
const out: Record<string, CellScalar> = {};
for (const [key, cell] of Object.entries(sheet.칸)) {
const c: CalcValue | undefined = calc[sheet.id]?.[key];
out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null;
}
return out;
}
function fromCalc(v: CalcValue): CellScalar {
if ("수" in v) return Number(v.수);
if ("글" in v) return v.글;
if ("참" in v) return v.참;
return v.오류;
}
/* ── 그리기 ─────────────────────────────────────────────────────────────── */
/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */
export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void {
host.classList.add("m02-df-formula");
host.replaceChildren(
el("strong", { className: "m02-df-formula__title", text: tv("Title") }),
...(lines.length
? lines.map((l) =>
el("div", {
className: "m02-df-formula__line",
children: [
el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }),
el("div", { className: "m02-df-formula__valued", text: l.valued }),
...(l.branches
? [
el("ul", {
className: "m02-df-formula__branches",
children: l.branches.map((b, i) =>
el("li", {
className: b.now ? "is-now" : "",
text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`,
}),
),
}),
]
: []),
],
}),
)
: [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]),
);
}
@@ -0,0 +1,85 @@
/* =============================================================================
* M02_DF_FuncExtra.ts
* 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것
* 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { showToast } from "@ui/ui_template_elements";
import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition";
import { renderCellTable } from "./M02_DF_CellTable";
import { evalSafe, type DetailOptions } from "./M02_DF_Detail";
import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView";
import { TEXT } from "./M02_DF_FuncText";
import { valueChoices, type CfFunc, type CfLogic } from "./M02_DF_Model";
export interface ExtraContext {
lg: CfLogic;
boxId: string;
detail: DetailOptions;
readOnly: boolean;
place(port: string, cell: string): void;
setOutput(cell: string): void;
patch(make: (f: CfFunc) => CfFunc): void;
used(f: CfFunc): Map<string, string>;
}
const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0];
const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId);
/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */
export function drawFormula(host: HTMLElement, x: ExtraContext): void {
const box = boxOf(x);
if (!box?.func) return;
const ev = evalSafe(x.lg, x.detail);
renderFormulaView(host, formulaLines(box, cellValuesOf(box, ev?.arrived?.[x.boxId])));
}
/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */
export function drawCells(host: HTMLElement, x: ExtraContext): void {
const box = boxOf(x);
if (!box?.func) return;
renderCellTable(host, x.lg, box, x.readOnly, {
move: (kind, key, cell) => {
if (kind === "in") return x.place(key, cell);
if (kind === "out") return x.setOutput(cell);
if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error");
x.patch((f) => ({
...f,
variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)),
}));
},
release: (kind, key) =>
x.patch((f) =>
kind === "in"
? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) }
: kind === "var"
? { ...f, variables: f.variables.filter((v) => v.name !== key) }
: { ...f, output: { ...f.output, cell: "" } },
),
});
}
/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸 */
export function conditionsOf(x: ExtraContext): ConditionOptions {
return {
valuesFor: (a1) => {
const f = boxOf(x)?.func;
const at = a1.toUpperCase();
const v = f?.variables.find((y) => y.cell?.toUpperCase() === at);
if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null;
const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port;
const link = port
? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port)
: undefined;
return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null;
},
pills: () => {
const f = boxOf(x)?.func;
return [
...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })),
...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })),
];
},
};
}
@@ -111,11 +111,11 @@ button.m02-dfpanel-chip:focus-visible {
}
.m02-dfpanel-chip.is-out {
border-color: #f59e0b;
border-color: #27ae60;
}
.m02-dfpanel-chip.is-var {
border-color: #22a06b;
border-color: #d99a00;
}
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
@@ -133,11 +133,11 @@ button.m02-dfpanel-chip:focus-visible {
}
.m02-dfpanel .aislo-cell[data-cf-kind="var"] {
box-shadow: inset 0 0 0 2px #22a06b;
box-shadow: inset 0 0 0 2px #d99a00;
}
.m02-dfpanel .aislo-cell[data-cf-kind="out"] {
box-shadow: inset 0 0 0 2px #f59e0b;
box-shadow: inset 0 0 0 2px #27ae60;
}
.m02-dfpanel .aislo-cell[data-cf-kind]::after {
@@ -170,3 +170,112 @@ button.m02-dfpanel-chip:focus-visible {
outline: 2px dashed var(--color-danger);
outline-offset: -2px;
}
/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */
.m02-dfpanel-body {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--spacing-12);
}
.m02-dfpanel-body > .m02-dfpanel-sheet {
flex: 1 1 28em;
min-width: 0;
}
.m02-dfcells {
flex: 0 1 24em;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-dfcells__title {
font-weight: var(--font-weight-bold);
}
.m02-dfcells__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m02-dfcells__table th,
.m02-dfcells__table td {
padding: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
}
.m02-dfcells__cell {
width: 5em;
}
.m02-dfcells .ui-input.is-bad {
border-color: var(--color-danger);
border-style: dashed;
}
.m02-dfcells__kind {
padding: 0 var(--spacing-8);
border: 2px solid;
border-radius: var(--radius-pills);
white-space: nowrap;
}
.m02-dfcells__row.is-in .m02-dfcells__kind {
border-color: #3b82f6;
}
.m02-dfcells__row.is-var .m02-dfcells__kind {
border-color: #d99a00;
}
.m02-dfcells__row.is-out .m02-dfcells__kind {
border-color: #27ae60;
}
/* 식 보기 */
.m02-df-formula {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
}
.m02-df-formula__title {
font-weight: var(--font-weight-bold);
}
.m02-df-formula__line {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: var(--spacing-4);
border-top: 1px dashed var(--color-border);
}
.m02-df-formula__valued {
color: var(--color-text-secondary);
}
.m02-df-formula__branches {
margin: 0;
padding-left: var(--spacing-16);
list-style: none;
}
.m02-df-formula__branches .is-now {
font-weight: var(--font-weight-bold);
}
.m02-df-formula__empty {
margin: 0;
color: var(--color-text-muted);
}
@@ -14,6 +14,8 @@ import type {
Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet";
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
import type { DetailOptions } from "./M02_DF_Detail";
import { conditionsOf, drawCells, drawFormula, type ExtraContext } from "./M02_DF_FuncExtra";
import { createRoundRow } from "./M02_DF_RoundRow";
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
@@ -37,6 +39,8 @@ interface State {
lg: CfLogic;
on: Handlers;
readOnly: boolean;
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
detail: DetailOptions;
handle: SpreadsheetHandle | null;
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
snap: CalcValues;
@@ -48,6 +52,8 @@ interface State {
suggest: number | null;
parts: {
head: HTMLElement;
formula: HTMLElement;
cells: HTMLElement;
got: HTMLElement;
vars: HTMLElement;
bar: HTMLElement;
@@ -201,6 +207,20 @@ function problems(st: State): string[] {
return out;
}
/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */
/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */
const extra = (st: State): ExtraContext => ({
lg: st.lg,
boxId: st.boxId,
detail: st.detail,
readOnly: st.readOnly,
place: (port, cell) => placePort(st, port, cell),
setOutput: (cell) => setOutput(st, cell),
patch: (make) => patchFunc(st, make),
used: (f) => usedCells(f),
});
/* ------------------------------ 칸 위 표시 ------------------------------ */
function applyMarks(st: State): void {
@@ -563,6 +583,7 @@ async function mountSheet(st: State): Promise<void> {
preview: true,
tabs: false,
readOnly: st.readOnly,
conditions: conditionsOf(extra(st)),
onChange: (b) => onBook(st, b),
onSelect: (s) => {
st.cell = s.칸;
@@ -593,13 +614,14 @@ export function renderFuncPanel(
lg: CfLogic,
boxId: string,
on: Handlers,
opts: { readOnly?: boolean } = {},
opts: DetailOptions & { readOnly?: boolean } = {},
): void {
const old = states.get(host);
const readOnly = !!opts.readOnly;
if (old && old.boxId === boxId && old.readOnly === readOnly) {
old.lg = lg;
old.on = on;
old.detail = opts;
const f = funcOf(old);
if (old.handle && f && sheetJson(f) !== old.json) {
old.json = sheetJson(f);
@@ -615,6 +637,8 @@ export function renderFuncPanel(
}
const parts: State["parts"] = {
head: el("div", { className: "m02-dfpanel-head" }),
formula: el("div"),
cells: el("div"),
got: el("div", { className: "m02-dfpanel-row" }),
vars: el("div", { className: "m02-dfpanel-vars" }),
bar: el("div", { className: "m02-dfpanel-bar" }),
@@ -626,6 +650,7 @@ export function renderFuncPanel(
lg,
on,
readOnly,
detail: opts,
handle: null,
snap: {},
json: "",
@@ -639,12 +664,22 @@ export function renderFuncPanel(
drawGot(st);
drawVars(st);
drawBar(st);
drawFormula(st.parts.formula, extra(st));
drawCells(st.parts.cells, extra(st));
applyMarks(st);
},
};
states.set(host, st);
host.classList.add("m02-dfpanel");
host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet);
host.replaceChildren(
parts.head,
parts.formula,
parts.got,
parts.vars,
parts.bar,
parts.msg,
el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }),
);
parts.msg.hidden = true;
st.redraw();
void mountSheet(st);
@@ -1,42 +1,87 @@
/* =============================================================================
* M02_DF_InputPanel_Logic.ts
* 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값)
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
* 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api";
import type { CfLogic } from "./M02_DF_Model";
import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
import {
filterLogics,
logicBooks,
logicChapters,
logicRefOf,
masterRef,
pageRange,
setKind,
setLogicInput,
wouldCycle,
type MasterLogicBrief,
} from "./M02_DF_InputPanel_Build";
const TEXT = {
Help: [
"저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀",
"Use a saved logic or sample as an input",
"마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
"Use any master logic or a logic of mine as an input",
],
Search: ["로직 이름으로 찾기", "Search by name"],
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
Book: ["원문", "Source"],
Chapter: ["장", "Chapter"],
All: ["전체", "All"],
Loading: ["찾는 중…", "Searching…"],
None: ["찾는 로직 없음", "No logic found"],
Sample: ["견본", "Sample"],
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
Prev: ["이전", "Prev"],
Next: ["다음", "Next"],
Mine: ["내가 만든 것", "Mine"],
Blocked: ["읽을 수 없음", "Unreadable"],
Chosen: ["고른 로직", "Chosen logic"],
Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"],
Converted: [
"옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜",
"Old logic read as a flow — the file is unchanged",
],
Notes: ["못 옮긴 곳", "Not carried over"],
Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"],
Designs: [
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)",
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)",
"Pills it takes — its design inputs",
],
NoOut: ["출력 상자가 없음", "No output box"],
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
Change: ["다른 로직 고르기", "Pick another"],
Remove: ["빼기", "Remove"],
Self: ["이 로직 자신은 못 씀", "Can't use itself"],
Self: ["이 로직 자신은 못 고름", "Can't pick itself"],
Cycle: [
"돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름",
"That would loop — a logic that uses this one can't be picked",
],
NoFlow: [
"이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음",
"Can't read this logic's flow yet",
],
Failed: ["불러오지 못함", "Could not load"],
Wait: ["값 셈 중…", "Calculating…"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
const plain = (e: unknown): string => {
try {
@@ -46,6 +91,40 @@ const plain = (e: unknown): string => {
}
};
const PAGE = 12;
const MINE = "흐름도";
/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */
let cache: Promise<MasterLogicBrief[]> | null = null;
const loadAll = (): Promise<MasterLogicBrief[]> =>
(cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])])
.then(([master, mine]) => [
...mine
.filter((i) => !i.sample)
.map((i): MasterLogicBrief => ({
file: MINE,
구분: MINE,
상세구분: t("Mine"),
키: i.key,
원문번호: "",
이름: i.name,
결과단위: i.resultUnit,
})),
...master,
])
.catch((e: unknown) => ((cache = null), Promise.reject(e))));
/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */
async function flowOf(
item: Pick<MasterLogicBrief, "file" | "키">,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] };
return cfLogicFlow(item.file, item.키);
}
/** 다른 로직 입력 판 */
export function renderLogicInput(
host: HTMLElement,
@@ -55,32 +134,86 @@ export function renderLogicInput(
): void {
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
if (ref) return void showChosen(host, lg, boxId, ref, on);
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-dfip__list" });
let all: CfListItem[] = [];
const draw = (): void => {
const word = q.input.value.trim().toLowerCase();
const rows = all.filter(
(i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)),
const all = { value: "", text: t("All") };
let book = "";
let chapter = "";
let page = 1;
let items: MasterLogicBrief[] = [];
const bookBox = createSelectField({
label: t("Book"),
options: [all],
value: "",
compact: true,
onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()),
});
const chapterBox = createSelectField({
label: t("Chapter"),
options: [all],
value: "",
compact: true,
onChange: (v) => ((chapter = v), (page = 1), draw()),
});
const fillChapters = (): void =>
chapterBox.setOptions(
[all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))],
chapter,
);
const list = el("div", { className: "m02-dfip__list" });
const info = el("span", { className: "m02-dfip__muted" });
const prev = createButton({
label: t("Prev"),
variant: "ghost",
size: "sm",
onClick: () => ((page -= 1), draw()),
});
const next = createButton({
label: t("Next"),
variant: "ghost",
size: "sm",
onClick: () => ((page += 1), draw()),
});
const draw = (): void => {
const rows = filterLogics(
items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌
{ text: q.input.value, book, chapter },
);
const range = pageRange(rows.length, page, PAGE);
page = Math.min(page, range.pages);
const shown = rows.slice((page - 1) * PAGE, page * PAGE);
info.textContent = t("Total", {
n: rows.length,
a: range.from,
b: range.to,
p: page,
m: range.pages,
});
prev.disabled = page <= 1;
next.disabled = page >= range.pages;
list.replaceChildren(
...(rows.length
? rows.map((i) => {
...(shown.length
? shown.map((i) => {
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
attrs: {
type: "button",
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
},
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("strong", { text: i.name }),
el("strong", { text: i.이름 }),
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
el("span", {
className: "m02-dfip__tag",
text: i.sample ? t("Sample") : t("Mine"),
text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분,
}),
],
}),
el("span", { className: "m02-dfip__muted", text: i.resultUnit }),
el("span", { className: "m02-dfip__muted", text: i.결과단위 }),
],
});
b.addEventListener("click", () => void pick(i));
@@ -89,56 +222,114 @@ export function renderLogicInput(
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
);
};
const pick = async (i: CfListItem): Promise<void> => {
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
const loadForCycle = async (key: string): Promise<CfLogic | null> => {
const it = items.find((x) => x.키 === key);
return it ? (await flowOf(it)).flow : null;
};
const pick = async (i: MasterLogicBrief): Promise<void> => {
try {
const other = await cfGet(i.key);
const next = setLogicInput(lg, boxId, logicRefOf(i.key, other));
if (i.키 === lg.key) return void showToast(t("Self"), "error");
if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error");
const got = await flowOf(i);
readNotes.set(i.키, { converted: got.converted, notes: got.notes });
const picked = got.converted
? masterRef(i.키, got.flow, true, i.결과단위)
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
const done = setLogicInput(lg, boxId, picked);
// 기본 이름이면 고른 로직 이름으로
on.change({
...next,
boxes: next.boxes.map((b) =>
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b,
...done,
boxes: done.boxes.map((b) =>
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
),
});
} catch (e) {
showToast(plain(e), "error");
showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error");
}
};
q.input.addEventListener("input", draw);
let timer = 0;
q.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => ((page = 1), draw()), 200);
});
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list);
void cfList().then(
(items) => ((all = items), draw()),
host.replaceChildren(
el("p", { className: "m02-dfip__muted", text: t("Help") }),
el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }),
info,
list,
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
);
void loadAll().then(
(rows) => {
items = rows;
bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], "");
fillChapters();
draw();
},
(e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })),
);
}
/** 고른 로직 — 내보내는 알약 · 받는 알약 */
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
function showChosen(
host: HTMLElement,
lg: CfLogic,
boxId: string,
ref: NonNullable<CfLogic["boxes"][number]["input"]>["logic"],
ref: CfLogicRef,
on: { change(lg: CfLogic): void },
): void {
if (!ref) return;
const pillLine = (p: { name: string; unit: string }): HTMLElement =>
const read = readNotes.get(ref.key);
const values = new Map<string, HTMLElement>();
const errors = el("p", { className: "m02-dfip__muted is-bad" });
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
el("div", {
className: "m02-dfip__item",
children: [
el("span", { className: "m02-dfip__name", text: p.name }),
el("span", { className: "m02-dfip__muted", text: p.unit || "-" }),
el("span", { className: "m02-dfip__name", text: name }),
...(valueNode
? [valueNode]
: extra
? [el("span", { className: "m02-dfip__muted", text: extra })]
: []),
el("span", { className: "m02-dfip__muted", text: unit || "-" }),
],
});
host.replaceChildren(
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
...(read?.converted
? [
el("p", { className: "m02-dfip__muted", text: t("Converted") }),
...(read.notes.length
? [
el("details", {
children: [
el("summary", { text: `${t("Notes")} · ${read.notes.length}` }),
...read.notes.map((n) => el("p", { text: n })),
],
}),
]
: []),
]
: []),
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
...(ref.outputs.length
? ref.outputs.map(pillLine)
? ref.outputs.map((o) => {
const v = el("strong", { className: "m02-dfip__value", text: "—" });
values.set(o.name, v);
return line(o.name, o.unit, v);
})
: [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]),
errors,
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
...(ref.designs.length
? ref.designs.map(pillLine)
? ref.designs.map((d) =>
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
)
: [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]),
el("div", {
className: "m02-dfip__chips",
@@ -158,4 +349,17 @@ function showChosen(
],
}),
);
// 값 — 서버 계산 길(받는 알약 예시값으로)
errors.textContent = t("Wait");
void cfLogicValue(ref.key, ref.examples ?? {}).then(
(r) => {
for (const [name, node] of values) {
const v = r.outputs[name] ?? (r.cost as unknown as Record<string, unknown>)[name];
if (typeof v === "number") node.textContent = fmt(v);
else if (typeof v === "string") node.textContent = v;
}
errors.textContent = r.errors.join(" · ");
},
(e) => (errors.textContent = plain(e)),
);
}
+136 -22
View File
@@ -9,8 +9,12 @@
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import {
gateConds,
makeGate,
portsOut,
reaches,
valueChoices,
type CfGateCond,
type CfGateOp,
type CfLogic,
type CfPort,
@@ -77,6 +81,12 @@ const TEXT = {
],
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
Count: ["선", "Line"],
And: ["그리고", "and"],
Or: ["또는", "or"],
If: ["조건", "If"],
AddLine: ["+ 조건 줄", "+ Condition line"],
Join: ["잇는 말", "Join with"],
OutRange: ["범위 {n} 밖", "Outside the range {n}"],
Range: ["{n} (범위)", "{n} (range)"],
} as const;
@@ -92,6 +102,7 @@ interface Ctx {
linkId: string;
readOnly: boolean;
headers: string[];
detail: DetailOptions;
change(lg: CfLogic): void;
}
@@ -240,28 +251,122 @@ function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTML
return out;
}
/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */
function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null {
const v = ctx.lg.boxes
.find((b) => b.id === p.box)
?.input?.variables.find((x) => x.name === p.name);
return v?.type === "number" && v.range ? v.range : null;
}
/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */
function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] {
const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values });
if (list?.length) {
const key = (v: string | number): string => `${typeof v}:${v}`;
const options = list.map((v) => ({ value: key(v), text: String(v) }));
return [
select(
ctx,
options,
key(c.value),
(k) => {
const hit = list.find((v) => key(v) === k);
if (hit !== undefined) onPick(hit);
},
tk("Value"),
),
];
}
const range = rangeOf(ctx, c.from);
const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))];
const n = Number(c.value);
if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) {
out.push(
el("span", {
className: "m02-dflink-warn",
text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`,
}),
);
}
return out;
}
function gateEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "gate" }>): HTMLElement[] {
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
return [
el("p", { className: "m02-dflink-note", text: tk("GateHint") }),
row(
select(
ctx,
pillOptions(ctx),
toKey(rule.from),
(v) => put({ from: fromKey(v) }),
tk("Match"),
const g = gateConds(rule);
const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join));
const edit = (i: number, patch: Partial<CfGateCond>): void =>
put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c)));
const out: HTMLElement[] = [el("p", { className: "m02-dflink-note", text: tk("GateHint") })];
g.conds.forEach((c, i) => {
out.push(
row(
el("span", {
className: "m02-dflink-label",
text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"),
}),
select(
ctx,
pillOptions(ctx),
toKey(c.from),
(v) => {
const from = fromKey(v);
edit(i, {
from,
value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0,
});
},
tk("Match"),
),
select(
ctx,
GATE_OPS.map((o) => ({ value: o, text: o })),
c.op,
(v) => edit(i, { op: v as CfGateOp }),
tk("Kinds"),
),
...gateValue(ctx, c, (v) => edit(i, { value: v })),
ctx.readOnly || g.conds.length < 2
? ""
: createButton({
label: tk("Remove"),
variant: "ghost",
size: "sm",
onClick: () => put(g.conds.filter((_, j) => j !== i)),
}),
),
select(
ctx,
GATE_OPS.map((o) => ({ value: o, text: o })),
rule.op,
(v) => put({ op: v as CfGateOp }),
tk("Kinds"),
);
});
if (!ctx.readOnly) {
const first = g.conds[0];
out.push(
row(
createButton({
label: tk("AddLine"),
variant: "ghost",
size: "sm",
onClick: () =>
put([...g.conds, { from: first?.from ?? { box: "", name: "" }, op: "=", value: 0 }]),
}),
...(g.conds.length > 1
? [
el("span", { className: "m02-dflink-label", text: tk("Join") }),
select(
ctx,
[
{ value: "그리고", text: tk("And") },
{ value: "또는", text: tk("Or") },
],
g.join,
(v) => put(g.conds, v as "그리고" | "또는"),
tk("Join"),
),
]
: []),
),
numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")),
),
];
);
}
return out;
}
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
@@ -323,7 +428,7 @@ function roundEditor(
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }),
gate: (from) => makeGate([{ from, op: "≥", value: 0 }]),
scale: () => ({ kind: "scale", op: "×", value: 1 }),
round: () => ({ kind: "round", places: 0, method: "반올림" }),
};
@@ -345,14 +450,23 @@ export function renderLinkPanel(
Array.isArray(table) && Array.isArray(table[0])
? table[0].map((h) => String(h ?? "")).filter(Boolean)
: [];
const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change };
const ctx: Ctx = {
lg,
linkId,
readOnly: !!opts.readOnly,
headers,
detail: opts,
change: on.change,
};
const rule = link.rule;
const kind: Kind = rule?.kind ?? "none";
const parts: HTMLElement[] = [
el("div", {
className: "m02-dflink-head",
children: [
el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }),
el("strong", {
text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`,
}),
el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
],
}),
@@ -0,0 +1,151 @@
/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */
.m02-dfp {
position: relative;
display: flex;
width: 100%;
min-width: 0;
height: 100%;
border-left: 1px solid var(--color-border);
background: var(--color-canvas);
}
.m02-dfp__open {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
}
.m02-dfp__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfp__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m02-dfp__tools {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */
.m02-dfp .m02-dfdg__view {
height: max(320px, 45vh);
}
.m02-dfp__lock {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.m02-dfp__cost > summary {
cursor: pointer;
font-weight: 600;
padding: var(--spacing-4) 0;
}
/* 왼쪽 가장자리 끌기 */
.m02-dfp__grip {
position: absolute;
top: 0;
bottom: 0;
left: -4px;
z-index: 2;
width: 8px;
cursor: col-resize;
touch-action: none;
}
.m02-dfp__grip:hover,
.m02-dfp__grip:focus-visible,
.m02-dfp.is-resizing .m02-dfp__grip {
background: color-mix(in srgb, var(--color-accent) 35%, transparent);
outline: none;
}
.m02-dfp.is-resizing {
user-select: none;
}
/* 접기 = 얇은 띠 */
.m02-dfp__strip {
width: 100%;
padding: var(--spacing-12) 0;
border: 0;
background: var(--color-surface);
color: var(--color-text-secondary);
font: inherit;
writing-mode: vertical-rl;
cursor: pointer;
}
.m02-dfp__strip:hover,
.m02-dfp__strip:focus-visible {
color: var(--color-accent);
}
.m02-dfp__strip[hidden],
.m02-dfp__open[hidden],
.m02-dfp__grip[hidden] {
display: none;
}
/* 크게 보기 — 화면 전체 */
.m02-dfp-full {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
overflow: auto;
background: var(--color-canvas);
outline: none;
}
.m02-dfp-full__head {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfp-full__head strong {
flex: 1;
font-size: var(--text-body);
}
.m02-dfp-full__diagram .m02-dfdg__view {
height: max(420px, 60vh);
}
.m02-dfp-full__lower {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.m02-dfp-full__lower h3 {
margin: 0 0 var(--spacing-8);
font-size: var(--text-body);
}
@media (max-width: 900px) {
.m02-dfp-full__lower {
grid-template-columns: minmax(0, 1fr);
}
}
+269
View File
@@ -0,0 +1,269 @@
/* =============================================================================
* M02_DF_Panel.ts
* 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand }
* 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) →
* 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange.
* 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage ·
* 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로).
* 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfLogic } from "./M02_DF_Model";
import { createPaper } from "./M02_DF_Paper";
import "./M02_DF_Panel.css";
const TEXT = {
Big: ["크게 보기", "Full screen"],
Fold: ["접기", "Collapse"],
Strip: ["흐름도 ▸", "Flow ▸"],
StripTitle: ["흐름도 펴기", "Open the flow panel"],
Close: ["닫기", "Close"],
Detail: ["상세 내용", "Details"],
Cost: ["원가 · 검증", "Cost · check"],
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
} as const;
const tf = (key: keyof typeof TEXT): string =>
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
export interface FlowPanelOpts {
logic: CfLogic;
readOnly?: boolean;
title: string;
/** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 */
logics?(key: string): Promise<CfLogic | null>;
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
onResize?(px: number): void;
}
export interface FlowPanel {
/** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */
set(lg: CfLogic): void;
destroy(): void;
/** 크게 보기 열기 */
expand(): void;
}
/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */
export const PANEL_MIN = 320;
export const STRIP_WIDTH = 40;
/** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */
export const PANEL_FIT_MIN = 0.25;
const WIDTH_KEY = "m02.dfpanel.width";
const FOLD_KEY = "m02.dfpanel.folded";
/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */
export function dragWidth(start: number, startX: number, x: number, viewport: number): number {
const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85));
return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x))));
}
/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */
export function storedWidth(raw: string | null): number | null {
const n = Number(raw);
return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null;
}
const readStore = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const writeStore = (key: string, value: string): void => {
try {
localStorage.setItem(key, value);
} catch {
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */
}
};
/* ── 패널 ───────────────────────────────────────────────────────────── */
export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel {
/** 크게 보기 화면 — 열려 있을 때만 */
let full: HTMLElement | null = null;
const paper = createPaper({
logic: opts.logic,
readOnly: opts.readOnly,
onChange: opts.onChange,
logics: opts.logics,
// 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림
fitMin: () => (full ? 0.9 : PANEL_FIT_MIN),
});
const { diagram, detail, summary, note, undo } = paper.parts;
let width = storedWidth(readStore(WIDTH_KEY));
let folded = readStore(FOLD_KEY) === "1";
const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth));
const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") =>
createButton({ label, size: "sm", variant, onClick });
/* 펼친 패널 */
const costBox = el("details", {
className: "m02-dfp__cost",
attrs: { open: "" },
children: [el("summary", { text: tf("Cost") })],
});
const tools = el("div", { className: "m02-dfp__tools", children: [undo] });
const open = el("div", {
className: "m02-dfp__open",
children: [
el("header", {
className: "m02-dfp__head",
children: [
el("strong", { className: "m02-dfp__title", text: opts.title }),
sm(tf("Big"), () => expand()),
sm(tf("Fold"), () => setFolded(true)),
],
}),
tools,
note,
diagram,
detail,
costBox,
],
});
costBox.append(summary);
/* 접힌 띠 */
const strip = el("button", {
className: "m02-dfp__strip",
text: tf("Strip"),
attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") },
});
strip.addEventListener("click", () => setFolded(false));
/* 왼쪽 가장자리 — 끌기 · 화살표 키 */
const grip = el("div", {
className: "m02-dfp__grip",
attrs: {
role: "separator",
"aria-orientation": "vertical",
"aria-label": tf("Grip"),
tabindex: "0",
},
});
const setWidth = (px: number, save: boolean): void => {
width = px;
if (save) writeStore(WIDTH_KEY, String(px));
tell();
};
grip.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
ev.preventDefault();
const start = root.getBoundingClientRect().width;
const startX = ev.clientX;
root.classList.add("is-resizing");
const move = (m: PointerEvent): void =>
setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false);
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
root.classList.remove("is-resizing");
setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true);
paper.refit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
grip.addEventListener("keydown", (ev) => {
const step = ev.shiftKey ? 80 : 20;
const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0;
if (!d) return;
ev.preventDefault();
const now = root.getBoundingClientRect().width;
setWidth(dragWidth(now, 0, -d, window.innerWidth), true);
});
const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] });
root.addEventListener("keydown", (ev) => {
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
ev.preventDefault();
paper.undo();
}
});
const setFolded = (on: boolean): void => {
folded = on;
writeStore(FOLD_KEY, on ? "1" : "0");
root.classList.toggle("is-folded", on);
open.hidden = on;
strip.hidden = !on;
grip.hidden = on;
tell();
if (!on) paper.refit();
};
/* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */
const expand = (): void => {
if (full) return;
const close = sm(tf("Close"), () => shrink());
full = el("div", {
className: "m02-dfp-full",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" },
children: [
el("header", {
className: "m02-dfp-full__head",
children: [el("strong", { text: opts.title }), undo, close],
}),
note,
el("div", { className: "m02-dfp-full__diagram", children: [diagram] }),
el("div", {
className: "m02-dfp-full__lower",
children: [
el("section", { children: [el("h3", { text: tf("Detail") }), detail] }),
el("section", { children: [el("h3", { text: tf("Cost") }), summary] }),
],
}),
],
});
full.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && !ev.defaultPrevented) shrink();
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
ev.preventDefault();
paper.undo();
}
});
document.body.append(full);
full.focus();
paper.refit();
};
const shrink = (): void => {
if (!full) return;
full.remove();
full = null;
tools.append(undo);
costBox.append(summary);
// 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움
open.replaceChildren(
open.firstElementChild as HTMLElement,
tools,
note,
diagram,
detail,
costBox,
);
paper.refit();
};
host.replaceChildren(root);
setFolded(folded);
return {
set: (lg) => paper.set(lg),
destroy: () => {
shrink();
root.remove();
},
expand,
};
}
@@ -53,6 +53,8 @@ export interface PaperOpts {
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */
logics?(key: string): Promise<CfLogic | null>;
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
fitMin?(): number;
}
export interface Paper {
@@ -304,6 +306,7 @@ export function createPaper(opts: PaperOpts): Paper {
breaks: broken,
readOnly,
fitKey,
fitMin: opts.fitMin?.(),
});
drawPanel();
renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values });
@@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string {
maximumFractionDigits: Math.max(0, r.places),
});
}
/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */
let totalNow: CfRound = { places: 0, method: "버림" };
void defaultTotalRound().then((r) => (totalNow = r));
export const totalRoundNow = (): CfRound => totalNow;
@@ -1,78 +0,0 @@
/* =============================================================================
* M02_DF_Sample.ts
* 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬)
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비
* ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비
* ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비
* ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비
* ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직
* 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호).
* 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음.
* 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함
* `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { CfLogic } from "./M02_DF_Model";
const FILES = import.meta.glob<CfLogic>("./_sample/*.json", { eager: true, import: "default" });
/** 견본마다 한 줄 설명 — [한, 영] */
const NOTE: Record<string, readonly [string, string]> = {
masonry: [
"변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비",
"Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost",
],
ground: [
"지반이 토사면 굴삭기로 · 아니면 브레이커로 — 조건 통과 선 둘 → 경비",
"Soil goes to the excavator, rock to the breaker — two gate lines → expense",
],
hume: [
"관경으로 흄관 품목을 찾음(재료 자리 · 규격에 관경 포함) → 본수 × 단가 → 재료비",
"Pipe size picks the hume pipe item (spec contains size) → count × price → material cost",
],
linked: [
"작업량 로직의 출력을 받아 장비 시간 → 경비",
"Takes the output of a productivity logic → machine hours → expense",
],
value: [
"q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)",
"q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)",
],
};
/** 보여 줄 차례 */
const ORDER = ["masonry", "ground", "hume", "linked", "value"];
export interface CfSampleItem {
/** 파일 이름(확장자 뺌) */
id: string;
name: string;
resultUnit: string;
/** 쉬운 말 한 줄 */
note: string;
}
const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, "");
const byId = (): Map<string, CfLogic> =>
new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg]));
/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */
export function sampleList(): CfSampleItem[] {
const all = byId();
const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length);
return [...all.entries()]
.sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name))
.map(([id, lg]) => ({
id,
name: lg.name,
resultUnit: lg.resultUnit,
note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "",
}));
}
/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */
export function sampleLoad(id: string): CfLogic | null {
const lg = byId().get(id);
return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null;
}
@@ -1,423 +0,0 @@
{
"key": "SAMPLE-ground",
"name": "견본 · 지반에 따라 굴삭기 / 브레이커",
"resultUnit": "원/㎥",
"boxes": [
{
"id": "in-ground",
"kind": "input",
"name": "지반 · 굴착량",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 60,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{
"name": "지반",
"type": "choice",
"unit": "-",
"example": "토사",
"choices": ["토사", "암"]
},
{
"name": "굴착량",
"type": "number",
"unit": "㎥",
"example": 100
}
]
}
},
{
"id": "in-cost",
"kind": "input",
"name": "장비 시간당 경비",
"color": "#2f80ed",
"unit": "원/시간",
"x": 40,
"y": 300,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{
"name": "굴삭기 시간당 경비",
"type": "number",
"unit": "원/시간",
"example": 60000
},
{
"name": "브레이커 시간당 경비",
"type": "number",
"unit": "원/시간",
"example": 80000
}
]
}
},
{
"id": "fn-exc",
"kind": "function",
"name": "굴삭기로 파기",
"color": "#7b4fd6",
"unit": "원",
"x": 380,
"y": 40,
"func": {
"sheet": {
"id": "s-fn-exc",
"이름": "굴삭기로 파기",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "굴착량"
},
"C2": {
"값": "받는 값 · 지반이 토사일 때만 옴"
},
"A3": {
"값": "시간당 경비"
},
"C3": {
"값": "받는 값 · 원/시간"
},
"A4": {
"값": "시간당 작업량"
},
"B4": {
"값": 30
},
"C4": {
"값": "㎥/시간 · 고침"
},
"A5": {
"값": "작업 시간"
},
"B5": {
"식": "B2/B4"
},
"C5": {
"값": "시간"
},
"A6": {
"값": "굴삭기 경비"
},
"B6": {
"식": "B5*B3"
},
"C6": {
"값": "원"
}
}
},
"inputs": [
{
"port": "굴착량",
"cell": "B2"
},
{
"port": "시간당 경비",
"cell": "B3"
}
],
"variables": [],
"output": {
"cell": "B6",
"name": "굴삭기 경비",
"unit": "원"
}
}
},
{
"id": "fn-brk",
"kind": "function",
"name": "브레이커로 깨기",
"color": "#7b4fd6",
"unit": "원",
"x": 380,
"y": 300,
"func": {
"sheet": {
"id": "s-fn-brk",
"이름": "브레이커로 깨기",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "굴착량"
},
"C2": {
"값": "받는 값 · 지반이 토사가 아닐 때만 옴"
},
"A3": {
"값": "시간당 경비"
},
"C3": {
"값": "받는 값 · 원/시간"
},
"A4": {
"값": "시간당 작업량"
},
"B4": {
"값": 8
},
"C4": {
"값": "㎥/시간 · 고침"
},
"A5": {
"값": "작업 시간"
},
"B5": {
"식": "B2/B4"
},
"C5": {
"값": "시간"
},
"A6": {
"값": "브레이커 경비"
},
"B6": {
"식": "B5*B3"
},
"C6": {
"값": "원"
}
}
},
"inputs": [
{
"port": "굴착량",
"cell": "B2"
},
{
"port": "시간당 경비",
"cell": "B3"
}
],
"variables": [],
"output": {
"cell": "B6",
"name": "브레이커 경비",
"unit": "원"
}
}
},
{
"id": "fn-sum",
"kind": "function",
"name": "1㎥ 경비",
"color": "#7b4fd6",
"unit": "원/㎥",
"x": 720,
"y": 170,
"func": {
"sheet": {
"id": "s-fn-sum",
"이름": "1㎥ 경비",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "굴삭기 경비"
},
"C2": {
"값": "받는 값 · 안 오면 빈 칸 = 0"
},
"A3": {
"값": "브레이커 경비"
},
"C3": {
"값": "받는 값 · 안 오면 빈 칸 = 0"
},
"A4": {
"값": "굴착량"
},
"C4": {
"값": "받는 값 · ㎥"
},
"A5": {
"값": "1㎥ 경비"
},
"B5": {
"식": "(B2+B3)/B4"
},
"C5": {
"값": "둘 중 온 것 ÷ 굴착량"
}
}
},
"inputs": [
{
"port": "굴삭기 경비",
"cell": "B2"
},
{
"port": "브레이커 경비",
"cell": "B3"
},
{
"port": "굴착량",
"cell": "B4"
}
],
"variables": [],
"output": {
"cell": "B5",
"name": "1㎥ 경비",
"unit": "원/㎥"
}
}
},
{
"id": "out-exp",
"kind": "output",
"name": "경비",
"color": "#27ae60",
"unit": "원/㎥",
"x": 1040,
"y": 170,
"output": {
"cost": "경비",
"unit": "원/㎥",
"round": {
"places": 1,
"method": "버림"
}
}
}
],
"links": [
{
"id": "l1",
"from": {
"box": "in-ground",
"name": "굴착량"
},
"to": {
"box": "fn-exc",
"name": "굴착량"
},
"rule": {
"kind": "gate",
"from": {
"box": "in-ground",
"name": "지반"
},
"op": "=",
"value": "토사"
}
},
{
"id": "l2",
"from": {
"box": "in-ground",
"name": "굴착량"
},
"to": {
"box": "fn-brk",
"name": "굴착량"
},
"rule": {
"kind": "gate",
"from": {
"box": "in-ground",
"name": "지반"
},
"op": "≠",
"value": "토사"
}
},
{
"id": "l3",
"from": {
"box": "in-cost",
"name": "굴삭기 시간당 경비"
},
"to": {
"box": "fn-exc",
"name": "시간당 경비"
}
},
{
"id": "l4",
"from": {
"box": "in-cost",
"name": "브레이커 시간당 경비"
},
"to": {
"box": "fn-brk",
"name": "시간당 경비"
}
},
{
"id": "l5",
"from": {
"box": "fn-exc",
"name": "굴삭기 경비"
},
"to": {
"box": "fn-sum",
"name": "굴삭기 경비"
}
},
{
"id": "l6",
"from": {
"box": "fn-brk",
"name": "브레이커 경비"
},
"to": {
"box": "fn-sum",
"name": "브레이커 경비"
}
},
{
"id": "l7",
"from": {
"box": "in-ground",
"name": "굴착량"
},
"to": {
"box": "fn-sum",
"name": "굴착량"
}
},
{
"id": "l8",
"from": {
"box": "fn-sum",
"name": "1㎥ 경비"
},
"to": {
"box": "out-exp",
"name": "값"
}
}
],
"userElements": []
}
@@ -1,207 +0,0 @@
{
"key": "SAMPLE-hume",
"name": "견본 · 흄관 관경 조건 찾기",
"resultUnit": "원/m",
"boxes": [
{
"id": "in-size",
"kind": "input",
"name": "관경 · 연장",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 60,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{
"name": "관경",
"type": "choice",
"unit": "mm",
"example": 600,
"choices": [400, 450, 500, 600, 700, 800, 900, 1000, 1100, 1200]
},
{
"name": "연장",
"type": "number",
"unit": "m",
"example": 10
}
]
}
},
{
"id": "in-pipe",
"kind": "input",
"name": "흄관",
"color": "#2f80ed",
"unit": "본",
"x": 40,
"y": 300,
"input": {
"mode": "group",
"group": "재료",
"variables": [],
"elements": [
{
"ref": "MT014991",
"name": "흄관",
"spec": "Φ600mm",
"unit": "본",
"mode": "rule",
"scope": {
"구분": "관·이음관",
"상세구분": "흄관",
"찾는말": "접속흄관"
},
"rule": {
"field": "규격",
"op": "포함",
"from": {
"box": "in-size",
"name": "관경"
}
},
"default": "MT014991",
"slot": "흄관"
}
]
}
},
{
"id": "fn-pipe",
"kind": "function",
"name": "흄관 재료비",
"color": "#7b4fd6",
"unit": "원/m",
"x": 380,
"y": 160,
"func": {
"sheet": {
"id": "s-fn-pipe",
"이름": "흄관 재료비",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "연장"
},
"C2": {
"값": "받는 값 · m"
},
"A3": {
"값": "흄관 단가"
},
"C3": {
"값": "받는 값 · 원/본(관경으로 찾은 품목)"
},
"A4": {
"값": "1본 길이"
},
"B4": {
"값": 2.5
},
"C4": {
"값": "m · 고침"
},
"A5": {
"값": "본수"
},
"B5": {
"식": "ROUNDUP(B2/B4,0)"
},
"C5": {
"값": "올림"
},
"A6": {
"값": "1m 재료비"
},
"B6": {
"식": "B5*B3/B2"
},
"C6": {
"값": "본수 × 단가 ÷ 연장"
}
}
},
"inputs": [
{
"port": "연장",
"cell": "B2"
},
{
"port": "흄관 단가",
"cell": "B3"
}
],
"variables": [],
"output": {
"cell": "B6",
"name": "1m 재료비",
"unit": "원/m"
}
}
},
{
"id": "out-mat",
"kind": "output",
"name": "재료비",
"color": "#27ae60",
"unit": "원/m",
"x": 720,
"y": 160,
"output": {
"cost": "재료비",
"unit": "원/m",
"round": {
"places": 1,
"method": "버림"
}
}
}
],
"links": [
{
"id": "l1",
"from": {
"box": "in-size",
"name": "연장"
},
"to": {
"box": "fn-pipe",
"name": "연장"
}
},
{
"id": "l2",
"from": {
"box": "in-pipe",
"name": "흄관"
},
"to": {
"box": "fn-pipe",
"name": "흄관 단가"
}
},
{
"id": "l3",
"from": {
"box": "fn-pipe",
"name": "1m 재료비"
},
"to": {
"box": "out-mat",
"name": "값"
}
}
],
"userElements": []
}
@@ -1,302 +0,0 @@
{
"key": "SAMPLE-linked",
"name": "견본 · 다른 로직 잇기(작업량 → 장비 시간)",
"resultUnit": "원/㎥",
"boxes": [
{
"id": "in-logic",
"kind": "input",
"name": "덤프트럭 시간당 작업량",
"color": "#2f80ed",
"unit": "㎥/시간",
"x": 40,
"y": 60,
"input": {
"mode": "logic",
"elements": [],
"variables": [],
"logic": {
"key": "value",
"name": "견본 · 덤프트럭 시간당 작업량(값)",
"outputs": [
{
"name": "시간당 작업량",
"unit": "㎥/시간"
}
],
"designs": [
{
"name": "q",
"unit": "㎥"
},
{
"name": "f",
"unit": "-"
},
{
"name": "E",
"unit": "-"
},
{
"name": "Cm",
"unit": "분"
}
]
}
}
},
{
"id": "in-var",
"kind": "input",
"name": "운반량 · 경비",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 300,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{
"name": "운반량",
"type": "number",
"unit": "㎥",
"example": 120
},
{
"name": "덤프트럭 시간당 경비",
"type": "number",
"unit": "원/시간",
"example": 50000
}
]
}
},
{
"id": "fn-hours",
"kind": "function",
"name": "운반 시간",
"color": "#7b4fd6",
"unit": "시간",
"x": 380,
"y": 60,
"func": {
"sheet": {
"id": "s-fn-hours",
"이름": "운반 시간",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "시간당 작업량"
},
"C2": {
"값": "받는 값 · 다른 로직 출력"
},
"A3": {
"값": "운반량"
},
"C3": {
"값": "받는 값 · ㎥"
},
"A4": {
"값": "운반 시간"
},
"B4": {
"식": "B3/B2"
},
"C4": {
"값": "운반량 ÷ 시간당 작업량"
}
}
},
"inputs": [
{
"port": "시간당 작업량",
"cell": "B2"
},
{
"port": "운반량",
"cell": "B3"
}
],
"variables": [],
"output": {
"cell": "B4",
"name": "운반 시간",
"unit": "시간",
"round": {
"places": 2,
"method": "반올림"
}
}
}
},
{
"id": "fn-cost",
"kind": "function",
"name": "1㎥ 운반 경비",
"color": "#7b4fd6",
"unit": "원/㎥",
"x": 700,
"y": 180,
"func": {
"sheet": {
"id": "s-fn-cost",
"이름": "1㎥ 운반 경비",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "운반 시간"
},
"C2": {
"값": "받는 값 · 시간"
},
"A3": {
"값": "시간당 경비"
},
"C3": {
"값": "받는 값 · 원/시간"
},
"A4": {
"값": "운반량"
},
"C4": {
"값": "받는 값 · ㎥"
},
"A5": {
"값": "1㎥ 운반 경비"
},
"B5": {
"식": "B2*B3/B4"
},
"C5": {
"값": "시간 × 경비 ÷ 운반량"
}
}
},
"inputs": [
{
"port": "운반 시간",
"cell": "B2"
},
{
"port": "시간당 경비",
"cell": "B3"
},
{
"port": "운반량",
"cell": "B4"
}
],
"variables": [],
"output": {
"cell": "B5",
"name": "1㎥ 운반 경비",
"unit": "원/㎥"
}
}
},
{
"id": "out-exp",
"kind": "output",
"name": "경비",
"color": "#27ae60",
"unit": "원/㎥",
"x": 1020,
"y": 180,
"output": {
"cost": "경비",
"unit": "원/㎥",
"round": {
"places": 1,
"method": "버림"
}
}
}
],
"links": [
{
"id": "l1",
"from": {
"box": "in-logic",
"name": "시간당 작업량"
},
"to": {
"box": "fn-hours",
"name": "시간당 작업량"
}
},
{
"id": "l2",
"from": {
"box": "in-var",
"name": "운반량"
},
"to": {
"box": "fn-hours",
"name": "운반량"
}
},
{
"id": "l3",
"from": {
"box": "fn-hours",
"name": "운반 시간"
},
"to": {
"box": "fn-cost",
"name": "운반 시간"
}
},
{
"id": "l4",
"from": {
"box": "in-var",
"name": "덤프트럭 시간당 경비"
},
"to": {
"box": "fn-cost",
"name": "시간당 경비"
}
},
{
"id": "l5",
"from": {
"box": "in-var",
"name": "운반량"
},
"to": {
"box": "fn-cost",
"name": "운반량"
}
},
{
"id": "l6",
"from": {
"box": "fn-cost",
"name": "1㎥ 운반 경비"
},
"to": {
"box": "out-exp",
"name": "값"
}
}
],
"userElements": []
}
@@ -1,785 +0,0 @@
{
"key": "SAMPLE-masonry",
"name": "견본 · 돌쌓기 메쌓기(인력)",
"resultUnit": "원/㎡",
"boxes": [
{
"id": "in-var",
"kind": "input",
"name": "쌓기 조건",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 40,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{
"name": "뒷길이",
"type": "choice",
"unit": "㎝",
"example": 30,
"choices": [25, 30, 35, 45, 55, 60, 75]
},
{
"name": "돌",
"type": "choice",
"unit": "-",
"example": "깬돌",
"choices": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"]
},
{
"name": "쌓기",
"type": "choice",
"unit": "-",
"example": "골쌓기",
"choices": ["골쌓기", "켜쌓기"]
},
{
"name": "높이",
"type": "number",
"unit": "m",
"example": 4.5
}
]
}
},
{
"id": "in-poom",
"kind": "input",
"name": "메쌓기 품표",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 300,
"input": {
"mode": "group",
"group": "소요량",
"elements": [],
"table": {
"key": "QF000421"
},
"variables": []
}
},
{
"id": "in-rate",
"kind": "input",
"name": "높이 증가율표",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 460,
"input": {
"mode": "group",
"group": "소요량",
"elements": [],
"table": {
"key": "QF000422"
},
"variables": []
}
},
{
"id": "in-labor",
"kind": "input",
"name": "인력",
"color": "#2f80ed",
"unit": "원/일",
"x": 40,
"y": 620,
"input": {
"mode": "group",
"group": "인력",
"variables": [],
"elements": [
{
"ref": "LB000033",
"name": "석공",
"spec": "",
"unit": "원/일"
},
{
"ref": "LB000002",
"name": "보통인부",
"spec": "",
"unit": "원/일"
}
]
}
},
{
"id": "fn-rate",
"kind": "function",
"name": "증가율 찾기",
"color": "#7b4fd6",
"unit": "%",
"x": 340,
"y": 420,
"func": {
"sheet": {
"id": "cf-rate",
"이름": "증가율 찾기",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "높이"
},
"C2": {
"값": "받는 값 · m"
},
"A3": {
"값": "표에서 찾은 증가율"
},
"B3": {
"식": "SUMIFS($G$2:$G$100,$E$2:$E$100,\"<\"&B2,$F$2:$F$100,\">=\"&B2)"
},
"C3": {
"값": "높이가 「아래 < 높이 ≤ 위」 인 줄"
},
"A4": {
"값": "증가율"
},
"B4": {
"식": "IF(B2<=3,0,B3)"
},
"C4": {
"값": "3m 까지는 할증 없음"
}
}
},
"inputs": [
{
"port": "높이",
"cell": "B2"
},
{
"port": "증가율표",
"cell": "E1"
}
],
"variables": [],
"output": {
"cell": "B4",
"name": "증가율",
"unit": "%"
}
}
},
{
"id": "fn-poom0",
"kind": "function",
"name": "석공 품",
"color": "#7b4fd6",
"unit": "인/㎡",
"x": 640,
"y": 80,
"func": {
"sheet": {
"id": "cf-poom0",
"이름": "석공 품",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "뒷길이"
},
"C2": {
"값": "받는 값 · ㎝"
},
"A3": {
"값": "돌"
},
"C3": {
"값": "받는 값"
},
"A4": {
"값": "쌓기"
},
"C4": {
"값": "받는 값"
},
"A5": {
"값": "증가율"
},
"C5": {
"값": "받는 값 · %"
},
"A6": {
"값": "표에서 찾은 품"
},
"B6": {
"식": "SUMIFS($H$2:$H$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)"
},
"C6": {
"값": "세 조건이 맞는 줄의 품"
},
"A7": {
"값": "석공 품"
},
"B7": {
"식": "B6*(1+B5/100)"
},
"C7": {
"값": "높이 할증을 얹음"
}
}
},
"inputs": [
{
"port": "뒷길이",
"cell": "B2"
},
{
"port": "돌",
"cell": "B3"
},
{
"port": "쌓기",
"cell": "B4"
},
{
"port": "증가율",
"cell": "B5"
},
{
"port": "품표",
"cell": "E1"
}
],
"variables": [],
"output": {
"cell": "B7",
"name": "석공 품",
"unit": "인/㎡"
}
}
},
{
"id": "fn-poom1",
"kind": "function",
"name": "보통인부 품",
"color": "#7b4fd6",
"unit": "인/㎡",
"x": 640,
"y": 300,
"func": {
"sheet": {
"id": "cf-poom1",
"이름": "보통인부 품",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "뒷길이"
},
"C2": {
"값": "받는 값 · ㎝"
},
"A3": {
"값": "돌"
},
"C3": {
"값": "받는 값"
},
"A4": {
"값": "쌓기"
},
"C4": {
"값": "받는 값"
},
"A5": {
"값": "증가율"
},
"C5": {
"값": "받는 값 · %"
},
"A6": {
"값": "표에서 찾은 품"
},
"B6": {
"식": "SUMIFS($I$2:$I$200,$E$2:$E$200,B2,$F$2:$F$200,B3,$G$2:$G$200,B4)"
},
"C6": {
"값": "세 조건이 맞는 줄의 품"
},
"A7": {
"값": "보통인부 품"
},
"B7": {
"식": "B6*(1+B5/100)"
},
"C7": {
"값": "높이 할증을 얹음"
}
}
},
"inputs": [
{
"port": "뒷길이",
"cell": "B2"
},
{
"port": "돌",
"cell": "B3"
},
{
"port": "쌓기",
"cell": "B4"
},
{
"port": "증가율",
"cell": "B5"
},
{
"port": "품표",
"cell": "E1"
}
],
"variables": [],
"output": {
"cell": "B7",
"name": "보통인부 품",
"unit": "인/㎡"
}
}
},
{
"id": "fn-pay0",
"kind": "function",
"name": "석공 노무비",
"color": "#7b4fd6",
"unit": "원/㎡",
"x": 940,
"y": 80,
"func": {
"sheet": {
"id": "cf-pay0",
"이름": "석공 노무비",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "석공 품"
},
"C2": {
"값": "받는 값 · 인/㎡"
},
"A3": {
"값": "석공 노임"
},
"C3": {
"값": "받는 값 · 원/인"
},
"A4": {
"값": "석공 노무비"
},
"B4": {
"식": "B2*B3"
},
"C4": {
"값": "품 × 노임"
}
}
},
"inputs": [
{
"port": "석공 품",
"cell": "B2"
},
{
"port": "석공 노임",
"cell": "B3"
}
],
"variables": [],
"output": {
"cell": "B4",
"name": "석공 노무비",
"unit": "원/㎡"
}
}
},
{
"id": "fn-pay1",
"kind": "function",
"name": "보통인부 노무비",
"color": "#7b4fd6",
"unit": "원/㎡",
"x": 940,
"y": 300,
"func": {
"sheet": {
"id": "cf-pay1",
"이름": "보통인부 노무비",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "보통인부 품"
},
"C2": {
"값": "받는 값 · 인/㎡"
},
"A3": {
"값": "보통인부 노임"
},
"C3": {
"값": "받는 값 · 원/인"
},
"A4": {
"값": "보통인부 노무비"
},
"B4": {
"식": "B2*B3"
},
"C4": {
"값": "품 × 노임"
}
}
},
"inputs": [
{
"port": "보통인부 품",
"cell": "B2"
},
{
"port": "보통인부 노임",
"cell": "B3"
}
],
"variables": [],
"output": {
"cell": "B4",
"name": "보통인부 노무비",
"unit": "원/㎡"
}
}
},
{
"id": "fn-sum",
"kind": "function",
"name": "노무비 합",
"color": "#7b4fd6",
"unit": "원/㎡",
"x": 1240,
"y": 190,
"func": {
"sheet": {
"id": "cf-sum",
"이름": "노무비 합",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "석공 노무비"
},
"C2": {
"값": "받는 값"
},
"A3": {
"값": "보통인부 노무비"
},
"C3": {
"값": "받는 값"
},
"A4": {
"값": "노무비"
},
"B4": {
"식": "B2+B3"
},
"C4": {
"값": "둘을 더함"
}
}
},
"inputs": [
{
"port": "석공 노무비",
"cell": "B2"
},
{
"port": "보통인부 노무비",
"cell": "B3"
}
],
"variables": [],
"output": {
"cell": "B4",
"name": "노무비",
"unit": "원/㎡"
}
}
},
{
"id": "out-pay",
"kind": "output",
"name": "노무비",
"color": "#27ae60",
"unit": "원/㎡",
"x": 1520,
"y": 190,
"output": {
"cost": "노무비",
"unit": "원/㎡"
}
}
],
"links": [
{
"id": "l1",
"from": {
"box": "in-var",
"name": "높이"
},
"to": {
"box": "fn-rate",
"name": "높이"
}
},
{
"id": "l2",
"from": {
"box": "in-rate",
"name": "표"
},
"to": {
"box": "fn-rate",
"name": "증가율표"
}
},
{
"id": "l3",
"from": {
"box": "in-var",
"name": "뒷길이"
},
"to": {
"box": "fn-poom0",
"name": "뒷길이"
}
},
{
"id": "l4",
"from": {
"box": "in-var",
"name": "돌"
},
"to": {
"box": "fn-poom0",
"name": "돌"
}
},
{
"id": "l5",
"from": {
"box": "in-var",
"name": "쌓기"
},
"to": {
"box": "fn-poom0",
"name": "쌓기"
}
},
{
"id": "l6",
"from": {
"box": "fn-rate",
"name": "증가율"
},
"to": {
"box": "fn-poom0",
"name": "증가율"
}
},
{
"id": "l7",
"from": {
"box": "in-poom",
"name": "표"
},
"to": {
"box": "fn-poom0",
"name": "품표"
}
},
{
"id": "l8",
"from": {
"box": "in-var",
"name": "뒷길이"
},
"to": {
"box": "fn-poom1",
"name": "뒷길이"
}
},
{
"id": "l9",
"from": {
"box": "in-var",
"name": "돌"
},
"to": {
"box": "fn-poom1",
"name": "돌"
}
},
{
"id": "l10",
"from": {
"box": "in-var",
"name": "쌓기"
},
"to": {
"box": "fn-poom1",
"name": "쌓기"
}
},
{
"id": "l11",
"from": {
"box": "fn-rate",
"name": "증가율"
},
"to": {
"box": "fn-poom1",
"name": "증가율"
}
},
{
"id": "l12",
"from": {
"box": "in-poom",
"name": "표"
},
"to": {
"box": "fn-poom1",
"name": "품표"
}
},
{
"id": "l13",
"from": {
"box": "fn-poom0",
"name": "석공 품"
},
"to": {
"box": "fn-pay0",
"name": "석공 품"
}
},
{
"id": "l14",
"from": {
"box": "in-labor",
"name": "석공"
},
"to": {
"box": "fn-pay0",
"name": "석공 노임"
}
},
{
"id": "l15",
"from": {
"box": "fn-poom1",
"name": "보통인부 품"
},
"to": {
"box": "fn-pay1",
"name": "보통인부 품"
}
},
{
"id": "l16",
"from": {
"box": "in-labor",
"name": "보통인부"
},
"to": {
"box": "fn-pay1",
"name": "보통인부 노임"
}
},
{
"id": "l17",
"from": {
"box": "fn-pay0",
"name": "석공 노무비"
},
"to": {
"box": "fn-sum",
"name": "석공 노무비"
}
},
{
"id": "l18",
"from": {
"box": "fn-pay1",
"name": "보통인부 노무비"
},
"to": {
"box": "fn-sum",
"name": "보통인부 노무비"
}
},
{
"id": "l19",
"from": {
"box": "fn-sum",
"name": "노무비"
},
"to": {
"box": "out-pay",
"name": "값"
}
}
],
"userElements": []
}
@@ -1,200 +0,0 @@
{
"key": "SAMPLE-value",
"name": "견본 · 덤프트럭 시간당 작업량(값)",
"resultUnit": "㎥/시간",
"boxes": [
{
"id": "in-var",
"kind": "input",
"name": "덤프트럭 조건",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 80,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{
"name": "q",
"type": "number",
"unit": "㎥",
"example": 6
},
{
"name": "f",
"type": "number",
"unit": "-",
"example": 1.2
},
{
"name": "E",
"type": "number",
"unit": "-",
"example": 0.9
},
{
"name": "Cm",
"type": "number",
"unit": "분",
"example": 25
}
]
}
},
{
"id": "fn-q",
"kind": "function",
"name": "시간당 작업량",
"color": "#7b4fd6",
"unit": "㎥/시간",
"x": 340,
"y": 80,
"func": {
"sheet": {
"id": "cf-q",
"이름": "시간당 작업량",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "q"
},
"C2": {
"값": "적재량 · ㎥"
},
"A3": {
"값": "f"
},
"C3": {
"값": "토량환산계수"
},
"A4": {
"값": "E"
},
"C4": {
"값": "작업효율"
},
"A5": {
"값": "Cm"
},
"C5": {
"값": "1회 사이클 시간 · 분"
},
"A6": {
"값": "시간당 작업량"
},
"B6": {
"식": "ROUND(60*B2*B3*B4/B5,3)"
},
"C6": {
"값": "60 × q × f × E ÷ Cm · 소수 셋째 자리"
}
}
},
"inputs": [
{
"port": "q",
"cell": "B2"
},
{
"port": "f",
"cell": "B3"
},
{
"port": "E",
"cell": "B4"
},
{
"port": "Cm",
"cell": "B5"
}
],
"variables": [],
"output": {
"cell": "B6",
"name": "시간당 작업량",
"unit": "㎥/시간"
}
}
},
{
"id": "out-q",
"kind": "output",
"name": "시간당 작업량",
"color": "#27ae60",
"unit": "㎥/시간",
"x": 640,
"y": 80,
"output": {
"cost": "값",
"unit": "㎥/시간"
}
}
],
"links": [
{
"id": "l1",
"from": {
"box": "in-var",
"name": "q"
},
"to": {
"box": "fn-q",
"name": "q"
}
},
{
"id": "l2",
"from": {
"box": "in-var",
"name": "f"
},
"to": {
"box": "fn-q",
"name": "f"
}
},
{
"id": "l3",
"from": {
"box": "in-var",
"name": "E"
},
"to": {
"box": "fn-q",
"name": "E"
}
},
{
"id": "l4",
"from": {
"box": "in-var",
"name": "Cm"
},
"to": {
"box": "fn-q",
"name": "Cm"
}
},
{
"id": "l5",
"from": {
"box": "fn-q",
"name": "시간당 작업량"
},
"to": {
"box": "out-q",
"name": "값"
}
}
],
"userElements": []
}