feat(m02-df): 단가산출 흐름도 0걸음 — CostFlow 를 DataFlow 로 옮겨 이름 바꿈

- 파일 M02_CF_* → M02_DF_* · css m02-cf/m01-cf → m02-df · 서버 길 /api/m02/cf → /api/m02/df · 키 CF- → DF- · 사용자 요소 UE- 그대로
- _sample · .gitignore(_data git 밖) 옮김 · DataFlow 는 CostFlow 를 import 안 함
- 시험 test_m02_df_model · router · sample · inputpanel_build(옛 cf 시험은 그대로)
- 서버 등록은 sub1 몫 — M02_MasterTemplete.DataFlow.M02_DF_Router 의 router

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk
This commit is contained in:
2026-10-01 19:37:51 +09:00
co-authored by Claude Opus 5.5
parent 12a60baeeb
commit fc7affb019
28 changed files with 8538 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
_data/
+158
View File
@@ -0,0 +1,158 @@
/* =============================================================================
* M02_DF_Api.ts
* 단가산출 다이어그램 Api — 목록(내 것 + 견본) · 열기 · 저장(서버가 입력 값을 마스터에서 다시 받아
* 함수 상자 시트를 같은 엔진으로 셈하고 받는 칸에 값만 되돌려 넣음) · 지우기 · 입력 값 한 번에 받기 ·
* 사용자 요소. 모양 = sub7 Model(CfLogic · CfGroup · CfUserElement) · 아래는 Api 몫만.
* 서버 = `M02_DF_Router.py`(`/api/m02/df/*`) · 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { CfGroup, CfLogic, CfUserElement } from "./M02_DF_Model";
/** 돈 로직 결과에서 꺼낼 칸(기계 시간당 경비) */
export type CfField = "노무비" | "재료비" | "경비" | "계";
/** 입력 값 물음 한 줄 — 값은 열 때 · 저장 때 마스터(사용자 요소 `UE-…` 는 다이어그램 안 · `_data`)에서
* · 인력 · 재료 ref = 키 → 그 줄 단가(기존 계산과 같은 셈) · 재료 ref = 검색어 묶음 JSON 글도 됨(unit 을 주면
* 기존 로직 재료 줄과 같은 단가)
* · 유가전력 · 환율 = 그 줄 값 · 요율 = 값 묶음의 field(기본 rate_percent · %)
* · 기계 ref = EQ 키 + field(값 칸 — 가격 · 손료계수 …) 또는 `로직(GC…, 기계='…', 지역=지역)` + inputs → 한 시간 값
* · 소요량 · 계수 table = {key, row?} → 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「·아래」 「·위」) */
export interface CfQuery {
id: string;
group: CfGroup;
ref?: string;
table?: { key: string; row?: number };
unit?: string;
field?: string;
inputs?: Record<string, unknown>;
}
/** 입력 값 답 — 표는 `rows` · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */
export interface CfAnswer {
id: string;
ok: boolean;
value?: number | string | null;
rows?: (string | number | null)[][];
name?: string;
unit?: string;
reason?: string;
}
/** 서버 계산 결과 — 저장 답 */
export interface CfComputed {
/** 상자 id → 내보내는 알약 이름 → 값(표는 칸 덩어리) */
ports: Record<string, Record<string, unknown>>;
/** 출력 상자 id → 값 */
outputs: Record<string, number | string | boolean | null>;
cost: {
노무비: number;
재료비: number;
경비: number;
값들: Record<string, number | string | boolean | null>;
계: number;
};
/** 쉬운 말 */
errors: string[];
}
/** 목록 한 줄 — `sample` = `_sample` 견본(읽기만 · 저장하면 새 번호) */
export interface CfListItem {
key: string;
name: string;
resultUnit: string;
saved: string;
sample: boolean;
}
/** 저장 답 — `logic` 은 받는 칸에 서버 값이 들어간 다이어그램 */
export interface CfSaved {
key: string;
logic: CfLogic;
values: CfComputed;
}
/** 서버가 준 몸과 상태 번호 — 0 = 서버에 못 닿음 */
export class CfError extends Error {
constructor(
public status: number,
public detail: unknown,
) {
super(typeof detail === "string" ? detail : JSON.stringify(detail));
}
}
async function send<T>(path: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m02/df${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new CfError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
return data;
}
/** 내 다이어그램(저장 차례) + 견본 */
export async function cfList(): Promise<CfListItem[]> {
return (await send<{ logics: CfListItem[] }>("/list")).logics;
}
export async function cfGet(key: string): Promise<CfLogic> {
return (await send<{ logic: CfLogic }>(`/get?${new URLSearchParams({ key }).toString()}`)).logic;
}
/** 저장 — 서버가 입력 값을 마스터에서 다시 넣고 같은 엔진으로 다시 셈(브라우저 계산값은 버림) ·
* 견본을 저장하면 새 번호 */
export async function cfSave(logic: CfLogic): Promise<CfSaved> {
return send<CfSaved>("/save", { logic });
}
export async function cfDelete(key: string): Promise<void> {
await send<{ key: string }>("/delete", { key });
}
/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 ·
* userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */
export async function cfValues(
items: CfQuery[],
userElements: CfUserElement[] = [],
): Promise<CfAnswer[]> {
return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values;
}
/** 사용자 요소 목록 */
export async function cfUserElements(): Promise<CfUserElement[]> {
return (await send<{ elements: CfUserElement[] }>("/user-elements")).elements;
}
/** 사용자 요소 만들기(공용 목록 `_data`) — id(`UE-0001`)는 서버가 줌 · 마스터에 안 씀 */
export async function cfUserElementAdd(element: Omit<CfUserElement, "id">): Promise<CfUserElement> {
return (await send<{ element: CfUserElement }>("/user-elements", { op: "add", element })).element;
}
export async function cfUserElementDelete(id: string): Promise<void> {
await send<{ id: string }>("/user-elements", { op: "delete", id });
}
const PLAIN: Record<number, string> = {
0: "서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요",
400: "넣은 값의 모양이 맞지 않음",
403: "견본은 바꾸거나 지울 수 없음 — 저장하면 새 다이어그램으로 남음",
404: "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요",
503: "엑셀 계산을 서버에서 못 함 — 잠시 뒤 다시 해 주세요",
};
/** 실패 → 쉬운 말(키 · 날 글이 섞인 서버 글은 뭉뚱그림) */
export function cfPlain(e: unknown): string {
if (!(e instanceof CfError)) return e instanceof Error ? e.message : PLAIN[0];
const text = typeof e.detail === "string" ? e.detail : "";
if (e.status === 400 && text && !/[A-Z]{2}\d{6}|[{}]/.test(text)) return text;
return PLAIN[e.status] ?? "저장하지 못함 — 잠시 뒤 다시 해 주세요";
}
@@ -0,0 +1,276 @@
/* 단가산출 다이어그램 — 자유 배치 */
.m02-dfdg {
--cfdg-in: var(--color-accent);
--cfdg-out: #c2610c;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
font-size: var(--text-body-sm);
}
:root[data-theme="dark"] .m02-dfdg {
--cfdg-out: #e5a866;
--cfdg-lift: #fff;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .m02-dfdg {
--cfdg-out: #e5a866;
--cfdg-lift: #fff;
}
}
.m02-dfdg__bar,
.m02-dfdg__tools {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfdg__tools {
min-height: 1.75rem;
}
.m02-dfdg__title {
font-size: var(--text-body);
}
.m02-dfdg__gap {
flex: 1;
}
.m02-dfdg__zoom {
min-width: 3rem;
text-align: center;
font-variant-numeric: tabular-nums;
color: var(--color-text-secondary);
}
.m02-dfdg__note {
color: var(--color-danger);
}
.m02-dfdg__hint {
margin: 0;
color: var(--color-text-muted);
}
/* 판 — 화면 위쪽 큰 영역 */
.m02-dfdg__view {
position: relative;
height: max(360px, 55vh);
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background-color: var(--color-canvas);
background-image: radial-gradient(
color-mix(in srgb, var(--color-text-muted) 35%, transparent) 1px,
transparent 1px
);
background-size: 20px 20px;
cursor: grab;
touch-action: none;
}
.m02-dfdg__world {
position: absolute;
inset: 0 auto auto 0;
width: 1px;
height: 1px;
transform-origin: 0 0;
}
.m02-dfdg__lines {
position: absolute;
inset: 0 auto auto 0;
width: 1px;
height: 1px;
overflow: visible;
pointer-events: none;
}
/* 상자 — 종류별 기본 색 · 상자마다 바꾼 색(--cf-color) */
.m02-dfdg__box {
position: absolute;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 11rem;
max-width: 16rem;
padding: 0 0 var(--spacing-8);
border: 1px solid color-mix(in srgb, var(--cf-color) 55%, var(--color-border));
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: 0 1px 3px rgb(0 0 0 / 12%);
cursor: pointer;
}
.m02-dfdg__box:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.m02-dfdg__box.is-selected {
border: 2px solid var(--cf-color);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--cf-color) 22%, transparent);
}
.m02-dfdg__head {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 0 var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
background: color-mix(in srgb, var(--cf-color) 16%, var(--color-surface));
cursor: move;
}
.m02-dfdg__kind {
grid-column: 1;
/* 어두운 화면은 종류 색을 밝게 섞음 */
color: color-mix(in srgb, var(--cf-color) 55%, var(--cfdg-lift, var(--cf-color)));
font-size: var(--text-caption);
font-weight: 600;
}
.m02-dfdg__unit {
grid-column: 2;
grid-row: 1;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.m02-dfdg__name {
grid-column: 1 / -1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m02-dfdg__pills {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
padding: 0 var(--spacing-8);
}
.m02-dfdg__ins,
.m02-dfdg__outs {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-dfdg__outs {
align-items: flex-end;
}
/* 알약 — 받는 파랑 · 내보내는 주황 · 빈 자리 회색 */
.m02-dfdg__pill {
display: inline-flex;
align-items: baseline;
gap: var(--spacing-4);
max-width: 9rem;
padding: 0 var(--spacing-8);
border: 1px solid var(--cfdg-in);
border-radius: 999px;
background: color-mix(in srgb, var(--cfdg-in) 12%, var(--color-surface));
color: var(--cfdg-in);
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
white-space: nowrap;
}
.m02-dfdg__pill.is-out {
border-color: var(--cfdg-out);
background: color-mix(in srgb, var(--cfdg-out) 14%, var(--color-surface));
color: var(--cfdg-out);
cursor: grab;
touch-action: none;
}
.m02-dfdg__pill.is-empty {
border-style: dashed;
border-color: var(--color-text-muted);
background: transparent;
color: var(--color-text-muted);
}
.m02-dfdg__pill.is-chosen {
box-shadow: 0 0 0 2px currentColor;
}
.m02-dfdg.is-linking .m02-dfdg__pill.is-target {
box-shadow: 0 0 0 2px var(--cfdg-in);
}
.m02-dfdg__pill-name {
overflow: hidden;
text-overflow: ellipsis;
}
.m02-dfdg__pill-value {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.m02-dfdg__pill-value:empty {
display: none;
}
/* 선 — 굽은 선 */
.m02-dfdg__link {
pointer-events: auto;
cursor: pointer;
}
.m02-dfdg__hit {
fill: none;
stroke: transparent;
stroke-width: 12;
}
.m02-dfdg__wire {
fill: none;
stroke: var(--color-text-muted);
stroke-width: 1.5;
}
.m02-dfdg__link.is-strong .m02-dfdg__wire {
stroke: var(--color-accent);
stroke-width: 3;
}
.m02-dfdg__link.is-dim .m02-dfdg__wire {
opacity: 0.25;
}
.m02-dfdg__drag {
fill: none;
stroke: var(--cfdg-out);
stroke-width: 2;
stroke-dasharray: 5 4;
}
/* 함수 상자 흐린 「+」 받는 자리 */
.m02-dfdg__pill.is-plus {
justify-content: center;
min-width: 2rem;
border-style: dashed;
border-color: var(--color-text-muted);
background: transparent;
color: var(--color-text-muted);
opacity: 0.7;
}
.m02-dfdg__pill.is-plus:hover,
.m02-dfdg.is-linking .m02-dfdg__pill.is-plus.is-target {
opacity: 1;
border-color: var(--cfdg-in);
color: var(--cfdg-in);
}
@@ -0,0 +1,682 @@
/* =============================================================================
* M02_DF_Diagram.ts
* 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」)
* 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 ·
* 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) ·
* 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게.
* SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import {
portsIn,
portsOut,
type CfBox,
type CfKind,
type CfLogic,
type CfPill,
type CfPort,
} from "./M02_DF_Model";
import "./M02_DF_Diagram.css";
const TEXT = {
Title: ["다이어그램", "Diagram"],
K_input: ["입력", "Input"],
K_function: ["함수", "Function"],
K_output: ["출력", "Output"],
AddInput: ["+ 입력", "+ Input"],
AddFunc: ["+ 함수", "+ Function"],
AddOutput: ["+ 출력", "+ Output"],
ZoomIn: ["확대", "Zoom in"],
ZoomOut: ["축소", "Zoom out"],
Fit: ["한눈에", "Fit all"],
Arrange: ["줄 맞추기", "Line up"],
Cancel: ["취소", "Cancel"],
Connect: ["잇기", "Connect"],
Disconnect: ["끊기", "Disconnect"],
Remove: ["상자 지우기", "Remove box"],
AskRemove: [
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
"Remove box “{v}”? Its lines go too",
],
PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"],
Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"],
NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"],
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"],
NoMatch: [
"같은 이름 받는 알약 없음 — 알약에 바로 놓으세요",
"No pill with that name — drop on a pill",
],
Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"],
From: ["어디서", "From"],
To: ["어디로", "To"],
Value: ["지금 값", "Now"],
PlusIn: [
"받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김",
"Drop an output pill here to add an input",
],
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
Hint: [
"상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대",
"Drag head to move · drag space to pan",
],
} as const;
const tt = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */
export const KIND_COLOR: Record<CfKind, string> = {
input: "#2a6fb0",
function: "#7a4fb5",
output: "#c2610c",
};
export interface DiagramOn {
select(id: string | null): void;
move(boxId: string, x: number, y: number): void;
/** 잇기 — 막히면 까닭(쉬운 말) */
link(from: CfPort, to: CfPort): string | null | void;
unlink(linkId: string): void;
add(kind: CfKind, x: number, y: number): void;
remove(boxId: string): void;
}
export interface DiagramOpts {
/** 상자 id → 알약 이름 → 지금 값 */
values?: Record<string, Record<string, unknown>>;
/** 고른 상자 id · 선 id */
selected?: string | null;
}
/* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */
/** 알약 — Model 의 받는 · 내보내는 알약 */
export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } {
return { ins: portsIn(box), outs: portsOut(box) };
}
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
export function resolveDrop(
lg: CfLogic,
from: CfPort,
target: { box: string; name: string | null; dir: "in" | "out" | null } | null,
): { to: CfPort } | { why: string } {
if (!target) return { why: tt("NotIn") };
if (target.box === from.box) return { why: tt("SameBox") };
if (target.dir === "out") return { why: tt("NotIn") };
const box = lg.boxes.find((b) => b.id === target.box);
// 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」)
if (box?.kind === "function" && target.name === null)
return { to: { box: box.id, name: from.name } };
const ins = box ? pillsOf(box).ins : [];
if (!box || !ins.length) return { why: tt("NoIn") };
const name =
target.name ??
ins.find((p) => p.name === from.name)?.name ??
(ins.length === 1 ? ins[0].name : null);
if (!name) return { why: tt("NoMatch") };
if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") };
return { to: { box: box.id, name } };
}
/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */
export function arrange(
lg: CfLogic,
gap = { x: 260, y: 130 },
): Record<string, { x: number; y: number }> {
const depth = new Map<string, number>(lg.boxes.map((b) => [b.id, 0]));
for (let round = 0; round < lg.boxes.length; round++) {
let moved = false;
for (const l of lg.links) {
const d = (depth.get(l.from.box) ?? 0) + 1;
if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) {
depth.set(l.to.box, d);
moved = true;
}
}
if (!moved) break;
}
const rows = new Map<number, number>();
const out: Record<string, { x: number; y: number }> = {};
for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) {
const d = depth.get(b.id) ?? 0;
const r = rows.get(d) ?? 0;
rows.set(d, r + 1);
out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y };
}
return out;
}
/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */
export const FIT_MIN = 0.9;
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
export function fitView(
rects: { x: number; y: number; w: number; h: number }[],
view: { w: number; h: number },
pad = 40,
): { 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 place = (size: number, from: number, to: number): number =>
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
}
const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
/* ── 그리기 ─────────────────────────────────────────────────────── */
interface HostState {
selected: string | null;
linking: CfPort | null;
pill: { box: string; name: string; dir: "in" | "out" } | null;
note: string;
zoom: number;
panX: number;
panY: number;
fitted: boolean;
watch?: ResizeObserver;
}
const states = new WeakMap<HTMLElement, HostState>();
const SVG = "http://www.w3.org/2000/svg";
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
export const showValue = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "";
if (typeof v === "number")
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
if (Array.isArray(v)) {
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
}
return String(v).slice(0, 12);
};
export function renderDiagram(
host: HTMLElement,
lg: CfLogic,
on: DiagramOn,
opts: DiagramOpts = {},
): void {
const st: HostState = states.get(host) ?? {
selected: null,
linking: null,
pill: null,
note: "",
zoom: 1,
panX: 0,
panY: 0,
fitted: false,
};
if (opts.selected !== undefined) st.selected = opts.selected;
states.set(host, st);
const values = opts.values ?? {};
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected });
const select = (id: string | null): void => {
st.selected = id;
st.pill = null;
st.note = "";
on.select(id);
redraw();
};
const tryLink = (from: CfPort, target: Parameters<typeof resolveDrop>[2]): void => {
const got = resolveDrop(lg, from, target);
st.linking = null;
st.note = "why" in got ? got.why : on.link(from, got.to) || "";
redraw();
};
/* 판 · 세상(확대 · 옮김을 한 번에) */
const world = el("div", { className: "m02-dfdg__world" });
const svg = document.createElementNS(SVG, "svg");
svg.classList.add("m02-dfdg__lines");
world.append(svg);
const view = el("div", {
className: "m02-dfdg__view",
attrs: { "aria-label": tt("Title") },
children: [world],
});
const applyView = (): void => {
world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`;
zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`;
};
/** 화면 점 → 세상 자리 */
const toWorld = (cx: number, cy: number): { x: number; y: number } => {
const r = view.getBoundingClientRect();
return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom };
};
/* 상자 */
const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => {
const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name);
const chosen = st.pill?.box === b.id && st.pill.name === p.name && st.pill.dir === dir;
const btn = el("button", {
className: [
"m02-dfdg__pill",
`is-${dir}`,
wired ? "" : "is-empty",
chosen ? "is-chosen" : "",
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
].join(" "),
attrs: {
type: "button",
"data-box": b.id,
"data-name": p.name,
"data-dir": dir,
title: `${p.name} (${p.unit || "-"})`,
},
children: [
el("span", { className: "m02-dfdg__pill-name", text: wired ? p.name : `? ${p.name}` }),
el("span", { className: "m02-dfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }),
],
});
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir });
st.pill = chosen ? null : { box: b.id, name: p.name, dir };
st.linking = null;
st.note = "";
redraw();
});
if (dir === "out")
btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name }));
return btn;
};
/** 함수 상자 왼쪽 흐린 「+」 — 여기(또는 몸통)에 놓으면 받는 알약이 새로 생김 */
const plusSlot = (b: CfBox): HTMLElement => {
const slot = el("button", {
className: `m02-dfdg__pill is-in is-plus${st.linking && st.linking.box !== b.id ? " is-target" : ""}`,
text: "+",
attrs: {
type: "button",
"data-box": b.id,
"data-dir": "in",
"data-plus": "1",
title: tt("PlusIn"),
},
});
slot.addEventListener("click", (ev) => {
ev.stopPropagation();
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: "in" });
st.note = tt("PlusIn");
redraw();
});
return slot;
};
const boxEl = (b: CfBox): HTMLElement => {
const { ins, outs } = pillsOf(b);
const head = el("div", {
className: "m02-dfdg__head",
children: [
el("span", { className: "m02-dfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }),
el("strong", { className: "m02-dfdg__name", text: b.name }),
el("span", { className: "m02-dfdg__unit", text: b.unit || "-" }),
],
});
const node = el("div", {
className: `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}`,
attrs: {
"data-box": b.id,
tabindex: "0",
role: "button",
"aria-label": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`,
},
children: [
head,
el("div", {
className: "m02-dfdg__pills",
children: [
el("div", {
className: "m02-dfdg__ins",
children: [
...ins.map((p) => pillEl(b, p, "in")),
...(b.kind === "function" ? [plusSlot(b)] : []),
],
}),
el("div", {
className: "m02-dfdg__outs",
children: outs.map((p) => pillEl(b, p, "out")),
}),
],
}),
],
});
node.style.left = `${b.x}px`;
node.style.top = `${b.y}px`;
node.style.setProperty("--cf-color", b.color || KIND_COLOR[b.kind]);
node.addEventListener("click", (ev) => {
ev.stopPropagation();
if (st.linking) return tryLink(st.linking, { box: b.id, name: null, dir: null });
select(st.selected === b.id ? null : b.id);
});
node.addEventListener("keydown", (ev) => {
if (ev.target !== node) return;
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
node.click();
}
const step = ev.shiftKey ? 50 : 10;
const d = {
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
}[ev.key];
if (d && st.selected === b.id) {
ev.preventDefault();
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
}
});
head.addEventListener("pointerdown", (ev) => dragBox(ev, b, node));
return node;
};
world.append(...lg.boxes.map(boxEl));
/* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */
const zoomLabel = el("span", { className: "m02-dfdg__zoom" });
const setZoom = (z: number, cx?: number, cy?: number): void => {
const r = view.getBoundingClientRect();
const px = cx ?? r.left + r.width / 2;
const py = cy ?? r.top + r.height / 2;
const at = toWorld(px, py);
st.zoom = clampZoom(z);
st.panX = px - r.left - at.x * st.zoom;
st.panY = py - r.top - at.y * st.zoom;
applyView();
drawLines();
};
const fit = (): void => {
const rects = lg.boxes.map((b) => {
const n = world.querySelector<HTMLElement>(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`);
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 }));
applyView();
drawLines();
};
const freeSpot = (): { x: number; y: number } => {
const r = view.getBoundingClientRect();
const c = toWorld(r.left + r.width / 3, r.top + r.height / 3);
let y = c.y;
while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100;
return { x: Math.round(c.x), y: Math.round(y) };
};
/** 작은 단추 */
const sm = (
label: keyof typeof TEXT,
onClick: () => void,
variant: "ghost" | "filled" | "danger" = "ghost",
) => createButton({ label: tt(label), size: "sm", variant, onClick });
const addBtn = (kind: CfKind, label: keyof typeof TEXT): HTMLButtonElement =>
sm(label, () => {
const at = freeSpot();
on.add(kind, at.x, at.y);
});
const endName = (e: CfPort): string => `${boxName(e.box)} · ${e.name}`;
const selLink = lg.links.find((l) => l.id === st.selected) ?? null;
const selBox = lg.boxes.find((b) => b.id === st.selected) ?? null;
const askRemove = async (b: CfBox): Promise<void> => {
if (await showConfirmDialog(tt("AskRemove", { v: b.name }), tt("Remove"))) on.remove(b.id);
};
const tools: HTMLElement[] = [];
if (st.linking) {
tools.push(
el("span", { text: tt("PickTarget", { v: st.linking.name }) }),
sm("Cancel", () => ((st.linking = null), redraw())),
);
} else if (st.pill) {
const p = st.pill;
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.name;
const where = lg.links
.filter((l) => mine(p.dir === "in" ? l.to : l.from))
.map((l) => endName(p.dir === "in" ? l.from : l.to))
.join(", ");
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.name]) || "—"}`;
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
tools.push(el("span", { text: [p.name, now, dir].filter(Boolean).join(" · ") }));
if (p.dir === "out")
tools.push(
sm(
"Connect",
() => ((st.linking = { box: p.box, name: p.name }), (st.pill = null), redraw()),
"filled",
),
);
} else if (selLink) {
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
tools.push(
el("span", { text: line }),
sm("Disconnect", () => on.unlink(selLink.id), "danger"),
);
} else if (selBox) {
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
}
const bar = el("div", {
className: "m02-dfdg__bar",
children: [
el("strong", { className: "m02-dfdg__title", text: tt("Title") }),
addBtn("input", "AddInput"),
addBtn("function", "AddFunc"),
addBtn("output", "AddOutput"),
el("span", { className: "m02-dfdg__gap" }),
sm("ZoomOut", () => setZoom(st.zoom / 1.2)),
zoomLabel,
sm("ZoomIn", () => setZoom(st.zoom * 1.2)),
sm("Fit", fit),
sm("Arrange", () => {
for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y);
}),
],
});
const root = el("div", {
className: `m02-dfdg${st.linking ? " is-linking" : ""}`,
children: [
bar,
el("div", {
className: "m02-dfdg__tools",
children: [
...tools,
el("span", { className: "m02-dfdg__note", text: st.note }),
el("span", { className: "m02-dfdg__hint", text: tools.length ? "" : tt("Hint") }),
],
}),
lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }),
],
});
root.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && (st.linking || st.pill)) {
st.linking = null;
st.pill = null;
redraw();
}
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
if (selLink) on.unlink(selLink.id);
else if (selBox) void askRemove(selBox);
});
view.addEventListener("click", (ev) => {
if (ev.target === view || ev.target === world) select(null);
});
view.addEventListener(
"wheel",
(ev) => {
ev.preventDefault();
setZoom(st.zoom * (ev.deltaY < 0 ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY);
},
{ passive: false },
);
view.addEventListener("pointerdown", (ev) => {
if (ev.target !== view && ev.target !== world && ev.target !== svg) return;
dragPan(ev);
});
host.replaceChildren(root);
applyView();
/* 선 — 알약 자리를 재서 세상 좌표로 */
function drawLines(): void {
svg.replaceChildren();
const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => {
const pill = world.querySelector<HTMLElement>(
`.m02-dfdg__pill[data-box="${CSS.escape(end.box)}"][data-name="${CSS.escape(end.name)}"][data-dir="${dir}"]`,
);
if (!pill) return null;
const r = pill.getBoundingClientRect();
return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2);
};
for (const l of lg.links) {
const a = at(l.from, "out");
const b = at(l.to, "in");
if (!a || !b) continue;
const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
const strong =
st.selected === l.id ||
(selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id));
const g = document.createElementNS(SVG, "g");
g.setAttribute(
"class",
`m02-dfdg__link${strong ? " is-strong" : ""}${st.selected !== null && !strong ? " is-dim" : ""}`,
);
g.setAttribute("data-link", l.id);
g.setAttribute("data-ends", [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","));
for (const cls of ["m02-dfdg__hit", "m02-dfdg__wire"]) {
const p = document.createElementNS(SVG, "path");
p.setAttribute("d", d);
p.setAttribute("class", cls);
g.append(p);
}
g.addEventListener("click", (ev) => {
ev.stopPropagation();
select(st.selected === l.id ? null : l.id);
});
svg.append(g);
}
}
const firstFit = (): void => {
if (st.fitted || !lg.boxes.length || !view.clientWidth) return drawLines();
st.fitted = true;
fit();
};
st.watch?.disconnect();
st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(firstFit);
st.watch?.observe(view);
if (view.isConnected) firstFit();
/* 끌기 셋 — 상자 · 판 · 선 */
function follow(
ev: PointerEvent,
move: (m: PointerEvent) => void,
end: (u: PointerEvent, moved: boolean) => void,
): void {
if (ev.button !== 0) return;
const x0 = ev.clientX;
const y0 = ev.clientY;
let moved = false;
const mv = (m: PointerEvent): void => {
if (!moved && Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return;
moved = true;
move(m);
};
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", mv);
window.removeEventListener("pointerup", up);
if (moved) {
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
const swallow = (c: MouseEvent): void => c.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
}
end(u, moved);
};
window.addEventListener("pointermove", mv);
window.addEventListener("pointerup", up);
}
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
const start = toWorld(ev.clientX, ev.clientY);
let x = b.x;
let y = b.y;
follow(
ev,
(m) => {
const now = toWorld(m.clientX, m.clientY);
x = Math.round(b.x + now.x - start.x);
y = Math.round(b.y + now.y - start.y);
node.style.left = `${x}px`;
node.style.top = `${y}px`;
drawLines();
},
(_u, moved) => {
if (moved) on.move(b.id, x, y);
},
);
}
function dragPan(ev: PointerEvent): void {
const p0 = { x: st.panX, y: st.panY };
follow(
ev,
(m) => {
st.panX = p0.x + m.clientX - ev.clientX;
st.panY = p0.y + m.clientY - ev.clientY;
applyView();
},
() => undefined,
);
}
function dragLink(ev: PointerEvent, from: CfPort): void {
ev.stopPropagation();
const pill = ev.currentTarget as HTMLElement;
const r = pill.getBoundingClientRect();
const a = toWorld(r.right, r.top + r.height / 2);
let line: SVGPathElement | null = null;
follow(
ev,
(m) => {
const b = toWorld(m.clientX, m.clientY);
if (!line) {
line = document.createElementNS(SVG, "path");
line.setAttribute("class", "m02-dfdg__drag");
svg.append(line);
}
line.setAttribute("d", `M${a.x},${a.y} L${b.x},${b.y}`);
},
(u, moved) => {
if (!moved) return;
line?.remove();
const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null;
const p = hit?.closest<HTMLElement>(".m02-dfdg__pill");
const bx = hit?.closest<HTMLElement>(".m02-dfdg__box");
tryLink(
from,
p
? {
box: p.dataset.box ?? "",
name: p.dataset.name ?? null,
dir: (p.dataset.dir as "in" | "out") ?? null,
}
: bx
? { box: bx.dataset.box ?? "", name: null, dir: null }
: null,
);
},
);
}
}
@@ -0,0 +1,220 @@
/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */
.m02-df {
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-df:focus {
outline: none;
}
.m02-df__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
/* 위 띠 — 밀어도 위에 붙어 있음 */
.m02-df__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-df__title {
margin: 0;
font-size: var(--text-subheading);
}
.m02-df__trial {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-df__strip {
display: flex;
flex: 1 1 480px;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
}
.m02-df__name {
flex: 1 1 260px;
}
.m02-df__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
.m02-df__body {
padding: var(--spacing-16) var(--spacing-24) var(--spacing-24);
}
.m02-df__big {
min-height: 56px;
padding: 0 var(--spacing-24);
font-size: var(--text-body);
}
/* 시작 화면 */
.m02-df__start {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: var(--spacing-24);
}
.m02-df__start-col {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-df__start-col h3 {
margin: var(--spacing-8) 0 0;
}
/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */
.m02-df__layout {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.m02-df__part {
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.m02-df__part h3 {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m02-df__part-body {
padding: var(--spacing-12);
}
.m02-df__lower {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.m02-df__box .m02-df__part-body {
min-height: 50vh;
}
.m02-df__box-slots {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--spacing-12);
}
.m02-df__slot {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-df__slot h4 {
margin: 0;
}
@media (max-width: 1100px) {
.m02-df__lower,
.m02-df__start,
.m02-df__box-slots {
grid-template-columns: minmax(0, 1fr);
}
}
.m02-df__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-df__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-df__item:hover {
border-color: var(--color-accent);
}
.m02-df__item-name {
flex: 1;
min-width: 0;
}
.m02-df__item-unit {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
white-space: nowrap;
}
.m02-df__note {
margin: 0 0 var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-line;
}
.m02-df__note:empty {
display: none;
}
.m02-df__color {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
margin-bottom: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m02-df__color[hidden] {
display: none;
}
.m02-df__color-input {
width: 44px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
}
+579
View File
@@ -0,0 +1,579 @@
/* =============================================================================
* M02_DF_Entry.ts
* 단가산출 다이어그램(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 10 · 12 · 13 · 이 폴더째 지워도 됨
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게.
* 잇기: 열기(cfGet · 견본 sampleLoad) → 입력 값(cfValues) → renderDiagram · 고른 상자 판(종류별) · renderSummary
* → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장 cfSave(서버 원가와 화면 대조) · 지우기 · 실행 취소 · Ctrl+Z · Ctrl+S.
* 바깥 페이지 하나만 세로로 밀림(안쪽 스크롤 없음).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { CfListItem, CfQuery } from "./M02_DF_Api";
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfKind, CfLogic, CfPort } from "./M02_DF_Model";
import "./M02_DF_Entry.css";
/** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */
const loadMods = () =>
Promise.all([
import("./M02_DF_Model"),
import("./M02_DF_Eval"),
import("./M02_DF_Api"),
import("./M02_DF_Diagram"),
import("./M02_DF_FuncPanel"),
import("./M02_DF_OutputPanel"),
import("./M02_DF_Summary"),
import("./M02_DF_Sample"),
import("./M02_DF_InputPanel"),
]).then(([model, ev, api, diagram, func, output, summary, sample, input]) => ({
model,
ev,
api,
diagram,
func,
output,
summary,
sample,
input,
}));
type Mods = Awaited<ReturnType<typeof loadMods>>;
const TEXT = {
Open: ["단가산출 다이어그램(시험)", "Cost diagram (trial)"],
Title: ["단가산출 다이어그램", "Cost diagram"],
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
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"],
Undo: ["실행 취소", "Undo"],
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
Diagram: ["다이어그램", "Diagram"],
Box: ["고른 상자", "Chosen box"],
PickBox: ["다이어그램에서 상자를 고르면 여기에 그 상자 판이 보임", "Choose a box in the diagram"],
Summary: ["원가", "Cost"],
Color: ["상자 색", "Box colour"],
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
SaveAnyway: [
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
"There are issues — {v} · Save anyway?",
],
Saving: ["저장 중", "Saving"],
Saved: ["저장했음", "Saved"],
SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"],
Differs: [
"저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름",
"Recalculated on save: {s} — differs from the screen value {c}",
],
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
Deleted: ["지웠음", "Deleted"],
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
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, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
);
const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text });
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
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 : ["원/㎡"];
}
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
return lg.boxes.flatMap((b) => {
const inp = b.input;
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
const group = inp.group;
const els = inp.elements
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
.map((e) => {
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
if (field) q.field = field;
return { q, box: b.id, port: e.name };
});
const table = inp.table
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
: [];
return [...els, ...table];
});
}
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> {
const asks = queriesOf(lg);
if (!asks.length) return { values: {}, errors: [] };
const answers = await M.api.cfValues(
asks.map((a) => a.q),
lg.userElements as never,
);
const values: CfValues = {};
const errors: string[] = [];
for (const a of asks) {
const got = answers.find((x) => x.id === a.q.id);
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
}
return { values, errors };
}
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
export function createDataFlowButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openDataFlow });
}
function openDataFlow(): void {
/** 고치는 화면이 열려 있을 때만 */
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
let units: string[] | null = null;
const mods = loadMods();
const mayLeave = async (): Promise<boolean> =>
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
const body = el("main", { className: "m02-df__body" });
const strip = el("div", { className: "m02-df__strip" });
const close = createButton({
label: te("Close"),
variant: "ghost",
onClick: () =>
void (async () => {
if (await mayLeave()) paper ? void showStart() : shut();
})(),
});
const page = el("div", {
className: "m02-df",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") },
children: [
el("header", {
className: "m02-df__head",
children: [
el("h2", { className: "m02-df__title", text: te("Title") }),
el("span", { className: "m02-df__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.defaultPrevented) return;
if (ev.key === "Escape") {
void (async () => {
if (await mayLeave()) shut();
})();
return;
}
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
const key = ev.key.toLowerCase();
if (key === "z" && !ev.shiftKey) {
ev.preventDefault();
paper.undo();
} else if (key === "s") {
ev.preventDefault();
void paper.save();
}
});
const run = (job: (M: Mods) => Promise<void>): void => {
body.replaceChildren(muted(te("Loading")));
void mods.then(job).catch((e: unknown) => {
showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error");
void showStart();
});
};
/* ---------- 시작 화면 ---------- */
const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => {
const row = el("button", {
className: "m02-df__item",
attrs: { type: "button", title },
children: [
el("span", { className: "m02-df__item-name", text: name }),
el("span", { className: "m02-df__item-unit", text: unit }),
],
});
row.addEventListener("click", onClick);
return row;
};
const showStart = async (): Promise<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",
children: [
el("section", {
className: "m02-df__start-col",
children: [
bigButton(te("Start_New"), () =>
run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)),
),
el("h3", { text: te("Start_Mine") }),
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(
...(mine.length
? mine.map((x) =>
listRow(
x.name,
x.resultUnit,
() => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)),
x.saved,
),
)
: [muted(te("Start_MineNone"))]),
);
} catch (e) {
mineHost.replaceChildren(muted(M.api.cfPlain(e)));
}
};
const units_ = async (): Promise<string[]> => (units ??= await unitChoices());
/* ---------- 고치는 화면 ---------- */
const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise<void> => {
const choices = await units_();
let lg = first;
let savedLg: CfLogic | null = saved ? first : null;
let stack: CfLogic[] = [];
let selected: string | null = null;
let values: CfValues = {};
let valueSig = "";
let notes: string[] = [];
let result: CfEvalResult;
let busy = false;
const unsaved = createTag(te("Unsaved"), "warning");
const note = el("p", { className: "m02-df__note", attrs: { role: "status" } });
const diagramHost = el("div");
const pickHint = muted(te("PickBox"));
// 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나
const colorBox = el("input", {
className: "m02-df__color-input",
attrs: { type: "color", "aria-label": te("Color") },
});
colorBox.addEventListener("change", () => {
if (selected) edit(M.model.setColor(lg, selected, colorBox.value));
});
const colorRow = el("label", {
className: "m02-df__color",
children: [el("span", { text: te("Color") }), colorBox],
});
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김)
const funcHost = el("div");
const outHost = el("div");
const inHost = el("div");
const summaryHost = el("div");
const evalNow = (): CfEvalResult => {
try {
return M.ev.evalLogic(lg, values);
} catch (e) {
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
}
};
const edit = (next: CfLogic): void => {
if (next === lg) return;
stack = [...stack, lg];
lg = next;
redraw();
};
/** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */
const editFromDiagram = (next: CfLogic): void => {
edit(next);
setTimeout(redraw);
};
/** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */
const refreshValues = (): void => {
const sig = JSON.stringify(queriesOf(lg).map((a) => a.q));
if (sig === valueSig) return;
valueSig = sig;
void fetchValues(M, lg).then(
(got) => {
if (sig !== valueSig) return;
values = got.values;
notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : [];
redraw();
},
(e: unknown) => {
notes = [te("Values", { v: M.api.cfPlain(e) })];
redraw();
},
);
};
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
value: lg.name,
onInput: (v) => edit({ ...lg, name: v }),
});
name.root.classList.add("m02-df__name");
const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices];
const unit = createSelectField({
label: te("Unit"),
options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
value: lg.resultUnit,
onChange: (v) => edit({ ...lg, resultUnit: v }),
});
const diagramOn = {
select: (id: string | null): void => {
selected = id;
drawPanel();
},
move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)),
link: (from: CfPort, to: CfPort): string | null => {
const got = M.model.link(lg, from, to);
if (got.why) return got.why;
editFromDiagram(got.lg);
return null;
},
unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)),
add: (kind: CfKind, x: number, y: number): void => {
const got = M.model.addBox(lg, kind, x, y);
selected = got.box.id;
editFromDiagram(got.lg);
},
remove: (id: string): void => {
if (selected === id) selected = null;
editFromDiagram(M.model.removeBox(lg, id));
},
};
const drawPanel = (): void => {
const box = lg.boxes.find((b) => b.id === selected);
pickHint.hidden = !!box;
colorRow.hidden = !box;
if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color;
funcHost.hidden = box?.kind !== "function";
outHost.hidden = box?.kind !== "output";
inHost.hidden = box?.kind !== "input";
if (box?.kind === "input") M.input.renderInputPanel(inHost, lg, box.id, { change: edit });
if (box?.kind === "function") M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit });
if (box?.kind === "output")
M.output.renderOutputPanel(outHost, lg, box.id, { change: edit }, { result });
};
const redraw = (): void => {
result = evalNow();
refreshValues();
unsaved.hidden = lg === savedLg;
undo.disabled = !stack.length;
deleteButton.hidden = !lg.key;
M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected });
drawPanel();
// 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임
// ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠)
(M.summary.renderSummary as (...args: unknown[]) => void)(
summaryHost,
lg,
result,
{ change: edit },
{ values },
);
note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n");
};
const save = async (): Promise<void> => {
if (busy) return;
if (!lg.name.trim()) {
note.textContent = te("NoName");
return void name.input.focus();
}
let problems: string[] = [];
try {
problems = M.ev.checkLogic(lg, values);
} catch {
problems = [];
}
const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") });
if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return;
busy = true;
note.textContent = te("Saving");
const screen = result.cost.계;
try {
const got = await M.api.cfSave(lg);
lg = got.logic;
savedLg = lg;
stack = [];
redraw();
const server = got.values.cost.계;
if (Math.abs(server - screen) > 0.5)
notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })];
note.textContent = [...notes, ...result.errors].join("\n");
showToast(te("Saved"), "success");
} catch (e) {
note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) });
} finally {
busy = false;
}
};
const doUndo = (): void => {
const back = stack[stack.length - 1];
if (!back) return;
stack = stack.slice(0, -1);
lg = back;
name.input.value = lg.name;
unit.select.value = lg.resultUnit;
redraw();
};
const doDelete = async (): Promise<void> => {
if (!lg.key || busy) return;
if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return;
try {
await M.api.cfDelete(lg.key);
savedLg = lg;
showToast(te("Deleted"), "success");
void showStart();
} catch (e) {
note.textContent = M.api.cfPlain(e);
}
};
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo });
const deleteButton = createButton({
label: te("Delete"),
variant: "danger",
onClick: () => void doDelete(),
});
strip.replaceChildren(
name.root,
unit.root,
unsaved,
el("div", {
className: "m02-df__actions",
children: [
undo,
createButton({ label: te("Save"), onClick: () => void save() }),
deleteButton,
],
}),
);
body.replaceChildren(
note,
el("div", {
className: "m02-df__layout",
children: [
part(te("Diagram"), "m02-df__diagram", [diagramHost]),
el("div", {
className: "m02-df__lower",
children: [
part(te("Box"), "m02-df__box", [pickHint, colorRow, inHost, funcHost, outHost]),
part(te("Summary"), "m02-df__summary", [summaryHost]),
],
}),
],
}),
);
paper = { dirty: () => lg !== savedLg, save, undo: doUndo };
redraw();
if (!lg.name) name.input.focus();
};
void 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-df__part ${className}`,
children: [el("h3", { text: title }), el("div", { className: "m02-df__part-body", children })],
});
}
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "filled", onClick });
button.classList.add("m02-df__big");
return button;
}
+210
View File
@@ -0,0 +1,210 @@
/* =============================================================================
* M02_DF_Eval.ts
* 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈
* (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음
* 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게
* ========================================================================== */
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_DF_Model";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
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, Scalar>;
cost: {
노무비: number;
재료비: number;
경비: number;
/** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */
값들: Record<string, Scalar>;
계: number;
};
/** 쉬운 말 */
errors: string[];
}
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"],
NoInCell: [
"「{box}」 의 받는 알약 「{pill}」 을 칸에 놓지 않음",
"Pill 「{pill}」 of 「{box}」 is not placed in a cell",
],
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.오류;
};
/* ── 계산 ───────────────────────────────────────────────────────────────── */
/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */
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;
}
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */
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, 열: "DF", 서식: [{}], 시트: [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 }));
for (const i of b.func?.inputs ?? [])
if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port }));
}
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];
}
@@ -0,0 +1,180 @@
.m02-dfpanel {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m02-dfpanel-head,
.m02-dfpanel-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfpanel-head {
justify-content: space-between;
}
.m02-dfpanel-title {
font-size: var(--text-subheading);
}
.m02-dfpanel-unitbox {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
margin-left: auto;
}
.m02-dfpanel-unit {
width: 6em;
text-align: center;
}
.m02-dfpanel-cell {
padding: 2px var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
background: var(--color-surface-raised);
}
.m02-dfpanel-label {
font-weight: var(--font-weight-bold);
}
.m02-dfpanel-note {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-dfpanel .ui-btn,
.m02-dfpanel .ui-input,
.m02-dfpanel .ui-select {
min-height: 44px;
font-size: var(--text-body);
}
.m02-dfpanel-form {
align-items: flex-end;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
}
.m02-dfpanel-fieldbox {
display: inline-flex;
flex-direction: column;
gap: 2px;
}
.m02-dfpanel-field {
width: 9em;
}
.m02-dfpanel-field.is-num {
width: 6em;
}
.m02-dfpanel-why,
.m02-dfpanel-msg {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-warning);
background: var(--color-surface-raised);
font-weight: var(--font-weight-medium);
}
.m02-dfpanel-chip {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
min-height: 44px;
padding: 0 var(--spacing-12);
border: 2px solid;
border-radius: var(--radius-pills);
background: var(--color-surface);
color: inherit;
font: inherit;
font-weight: var(--font-weight-medium);
}
button.m02-dfpanel-chip {
cursor: pointer;
}
button.m02-dfpanel-chip:focus-visible {
outline: 2px solid var(--color-focus-ring);
}
.m02-dfpanel-chip.is-in {
border-color: #3b82f6;
}
.m02-dfpanel-chip.is-in:not(.is-placed) {
border-style: dashed;
}
.m02-dfpanel-chip.is-out {
border-color: #f59e0b;
}
.m02-dfpanel-chip.is-var {
border-color: #22a06b;
}
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
.m02-dfpanel-sheet {
position: relative;
height: max(640px, calc(100vh - 260px));
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
overflow: hidden;
}
/* 칸 위 표시 — 색 + 글자(받음 · 변수 · 출력) 둘 다 */
.m02-dfpanel .aislo-cell[data-cf-kind="in"] {
box-shadow: inset 0 0 0 2px #3b82f6;
}
.m02-dfpanel .aislo-cell[data-cf-kind="var"] {
box-shadow: inset 0 0 0 2px #22a06b;
}
.m02-dfpanel .aislo-cell[data-cf-kind="out"] {
box-shadow: inset 0 0 0 2px #f59e0b;
}
.m02-dfpanel .aislo-cell[data-cf-kind]::after {
content: attr(data-cf-name);
position: absolute;
top: 0;
right: 0;
padding: 0 4px;
border-radius: 0 0 0 6px;
background: var(--color-surface);
color: var(--color-text);
font-size: 10px;
line-height: 14px;
pointer-events: none;
}
.m02-dfpanel .aislo-cell[data-cf-lock]::before {
content: attr(data-cf-lock);
position: absolute;
bottom: 0;
left: 0;
padding: 0 4px;
background: var(--color-surface);
font-size: 10px;
line-height: 14px;
pointer-events: none;
}
.m02-dfpanel .aislo-cell[data-cf-err] {
outline: 2px dashed var(--color-danger);
outline-offset: -2px;
}
@@ -0,0 +1,700 @@
/* =============================================================================
* M02_DF_FuncPanel.ts
* 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) ·
* 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_DF_Model 타입 그대로
* 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type {
CalcValues,
SpreadsheetHandle,
Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet";
import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model";
import "./M02_DF_FuncPanel.css";
const TEXT = {
Unit: ["단위", "Unit"],
Cell: ["고른 칸", "Selected cell"],
Got: ["받은 알약", "Received pills"],
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
Placed: ["놓임", "placed"],
Free: ["아직 안 놓은 알약", "not placed yet"],
Busy: ["이미 쓰는 칸", "That cell is already in use"],
Locked: [
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
"This cell is filled by the wire — cannot edit",
],
Vars: ["변수", "Variables"],
MakeVar: ["변수 만들기", "Make variable"],
NoVars: ["아직 변수가 없음", "No variables yet"],
VarName: ["이름", "Name"],
VarType: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
VarUnit: ["단위", "Unit"],
VarMin: ["최소", "Min"],
VarMax: ["최대", "Max"],
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
VarExample: ["예시값", "Example value"],
VarCell: ["붙을 칸", "Attach to"],
Make: ["만들기", "Make"],
Cancel: ["취소", "Cancel"],
Remove: ["지움", "Remove"],
NeedName: ["이름을 적음", "Enter a name"],
SameName: ["같은 이름이 이미 있음", "That name already exists"],
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
OutBtn: ["출력 칸으로", "Make output cell"],
OutNow: ["출력", "Output"],
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
Frames: ["틀", "Templates"],
FrameMul: ["곱하기", "Multiply"],
FrameMore: ["할증", "Markup"],
FrameDiv: ["나누기", "Divide"],
FrameIf: ["만약", "If"],
FrameMin: ["최소", "Min"],
FrameMax: ["최대", "Max"],
FrameUp: ["올림", "Round up"],
FrameLook: ["표에서 찾기", "Look up in table"],
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
ReadOnly: ["읽기만", "Read only"],
Loading: ["시트를 불러오는 중", "Loading sheet"],
Problem: ["⚠ ", "⚠ "],
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
Err_DIV0: ["0으로 나눔", "divides by zero"],
Err_NAME: ["알 수 없는 이름", "unknown name"],
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
Err_NA: ["찾는 값이 없음", "value not found"],
Err_OTHER: ["수식이 틀림", "formula is wrong"],
} as const;
const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
const FRAMES: [keyof typeof TEXT, string][] = [
["FrameMul", "=B1*B2"],
["FrameMore", "=B1*(1+B2/100)"],
["FrameDiv", "=B1/B2"],
["FrameIf", '=IF(B3="급",10,0)'],
["FrameMin", "=MIN(B1,B2)"],
["FrameMax", "=MAX(B1,B2)"],
["FrameUp", "=ROUNDUP(B4,3)"],
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
];
const ERR_KEY: Record<string, keyof typeof TEXT> = {
"#DIV/0!": "Err_DIV0",
"#NAME?": "Err_NAME",
"#REF!": "Err_REF",
"#VALUE!": "Err_VALUE",
"#CYCLE!": "Err_CYCLE",
"#N/A": "Err_NA",
};
type Handlers = { change(lg: CfLogic): void };
interface VarForm {
name: string;
type: CfVariable["type"];
unit: string;
min: string;
max: string;
choices: string;
example: string;
why: string;
}
interface State {
boxId: string;
lg: CfLogic;
on: Handlers;
readOnly: boolean;
handle: SpreadsheetHandle | null;
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
snap: CalcValues;
/** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */
json: string;
cell: string;
form: VarForm | null;
parts: {
head: HTMLElement;
got: HTMLElement;
vars: HTMLElement;
bar: HTMLElement;
msg: HTMLElement;
sheet: HTMLElement;
};
observer: MutationObserver | null;
redraw(): void;
}
const states = new WeakMap<HTMLElement, State>();
let sheetModule: Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> | null = null;
/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */
const loadSheet = (): Promise<typeof import("../../A00_Common/spreadsheet/spreadsheet")> =>
(sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet"));
const boxOf = (st: State): CfBox | undefined => st.lg.boxes.find((b) => b.id === st.boxId);
const funcOf = (st: State): CfFunc | undefined => boxOf(st)?.func;
const bookOf = (func: CfFunc): Workbook => ({
종류: "통합문서",
판: 1,
열: "cf",
서식: [{}],
시트: [structuredClone(func.sheet)],
활성: func.sheet.id,
});
const sheetJson = (func: CfFunc): string => JSON.stringify(func.sheet);
/* ------------------------------ 바꾸기 ------------------------------ */
function patchBox(st: State, make: (b: CfBox) => CfBox): void {
const next: CfLogic = {
...st.lg,
boxes: st.lg.boxes.map((b) => (b.id === st.boxId ? make(b) : b)),
};
st.lg = next;
st.on.change(next);
}
const patchFunc = (st: State, make: (f: CfFunc) => CfFunc): void =>
patchBox(st, (b) => (b.func ? { ...b, func: make(b.func) } : b));
const usedCells = (f: CfFunc): Map<string, string> => {
const m = new Map<string, string>();
for (const i of f.inputs) m.set(i.cell, i.port);
for (const v of f.variables) if (v.cell) m.set(v.cell, v.name);
if (f.output.cell) m.set(f.output.cell, f.output.name);
return m;
};
function placePort(st: State, port: string, cell: string): void {
const f = funcOf(st);
if (!f || st.readOnly) return;
const other = [...f.variables.map((v) => v.cell), f.output.cell].includes(cell);
if (other) return void showToast(tf("Busy"), "error");
patchFunc(st, (x) => ({
...x,
inputs: [...x.inputs.filter((i) => i.port !== port && i.cell !== cell), { port, cell }],
}));
}
function makeVariable(st: State): void {
const form = st.form;
const f = funcOf(st);
if (!form || !f) return;
const name = form.name.trim();
const fail = (key: keyof typeof TEXT): void => {
form.why = tf(key);
drawVars(st);
};
if (!name) return fail("NeedName");
const taken = [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)];
const mine = f.variables.find((v) => v.name === name);
if (taken.includes(name) && !mine) return fail("SameName");
const busy = usedCells({ ...f, variables: f.variables.filter((v) => v.name !== name) });
if (busy.has(st.cell)) return fail("Busy");
let example: number | string | null = form.example.trim() === "" ? null : form.example.trim();
const v: CfVariable = {
name,
type: form.type,
unit: form.unit.trim() || "-",
example: null,
cell: st.cell,
};
if (form.type === "number") {
if (example !== null) {
const n = Number(String(example).replace(/,/g, ""));
if (!Number.isFinite(n)) return fail("NotNumber");
example = n;
}
const lo = form.min.trim() === "" ? null : Number(form.min);
const hi = form.max.trim() === "" ? null : Number(form.max);
if (lo !== null && hi !== null) {
if (!(lo < hi)) return fail("BadRange");
v.range = [lo, hi];
}
} else if (form.type === "choice") {
v.choices = form.choices
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}
v.example = example;
const cell = st.cell;
st.form = null;
patchFunc(st, (x) => {
const sheet = structuredClone(x.sheet);
if (example !== null) sheet.칸[cell] = { 값: example };
return {
...x,
sheet,
inputs: x.inputs.filter((i) => i.cell !== cell),
variables: [...x.variables.filter((o) => o.name !== name && o.cell !== cell), v],
};
});
}
/** 선으로 들어온 알약 — 받는 알약 이름과 보낸 쪽 단위 */
function incoming(st: State): { port: string; unit: string }[] {
const out = new Map<string, string>();
for (const l of st.lg.links) {
if (l.to.box !== st.boxId || out.has(l.to.name)) continue;
const src = st.lg.boxes.find((b) => b.id === l.from.box);
const unit =
src?.func && src.func.output.name === l.from.name
? src.func.output.unit
: (src?.input?.elements.find((e) => e.name === l.from.name)?.unit ??
src?.input?.variables.find((v) => v.name === l.from.name)?.unit ??
src?.unit ??
"-");
out.set(l.to.name, unit || "-");
}
// 선 없이 칸 없는 받는 알약만 있어도 보임(새 함수 상자가 먼저 만든 자리)
for (const i of funcOf(st)?.inputs ?? []) if (!out.has(i.port)) out.set(i.port, "-");
return [...out].map(([port, unit]) => ({ port, unit }));
}
function problems(st: State): string[] {
const f = funcOf(st);
if (!f) return [];
const out: string[] = [];
for (const [addr, v] of Object.entries(st.snap[f.sheet.id] ?? {})) {
if (typeof v === "object" && "오류" in v) {
out.push(`${addr} ${tf(ERR_KEY[v.오류] ?? "Err_OTHER")}`);
}
}
for (const p of incoming(st)) {
if (!f.inputs.some((i) => i.port === p.port && i.cell)) out.push(`${p.port}${tf("Unplaced")}`);
}
if (!f.output.cell) out.push(tf("OutNone"));
return out;
}
/* ------------------------------ 칸 위 표시 ------------------------------ */
function applyMarks(st: State): void {
const f = funcOf(st);
if (!f) return;
const marks = new Map<string, { kind: string; label: string }>();
for (const i of f.inputs.filter((x) => x.cell))
marks.set(i.cell, { kind: "in", label: `${tf("Got")} ${i.port}` });
for (const v of f.variables)
if (v.cell) marks.set(v.cell, { kind: "var", label: `${tf("Vars")} ${v.name}` });
if (f.output.cell)
marks.set(f.output.cell, { kind: "out", label: `${tf("OutNow")} ${f.output.name}` });
const locked = new Set(f.inputs.filter((i) => i.cell).map((i) => i.cell));
const list = problems(st);
const bad = new Set(list.map((p) => p.split(" ")[0]));
for (const c of st.parts.sheet.querySelectorAll<HTMLElement>(".aislo-cell")) {
const addr = c.dataset.addr ?? "";
for (const a of ["cfKind", "cfName", "cfLock", "cfErr"]) delete c.dataset[a];
const m = marks.get(addr);
if (m) {
c.dataset.cfKind = m.kind;
c.dataset.cfName = m.label;
}
if (locked.has(addr)) c.dataset.cfLock = "🔒";
if (bad.has(addr)) c.dataset.cfErr = "1";
}
st.parts.msg.textContent = list.length
? `${tf("Problem")}${list[0]}${list.length > 1 ? ` (+${list.length - 1})` : ""}`
: "";
st.parts.msg.hidden = !list.length;
}
/* ------------------------------ 위 줄들 ------------------------------ */
function drawHead(st: State): void {
const box = boxOf(st);
const unit = el("input", {
className: "ui-input m02-dfpanel-unit",
attrs: { type: "text", "aria-label": tf("Unit") },
});
unit.value = box?.unit ?? "-";
if (st.readOnly) unit.readOnly = true;
unit.addEventListener("change", () => {
const v = unit.value.trim() || "-";
unit.value = v;
patchBox(st, (b) => ({
...b,
unit: v,
func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func,
}));
});
st.parts.head.replaceChildren(
el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }),
el("span", { className: "m02-dfpanel-cell", text: `${tf("Cell")} ${st.cell}` }),
el("label", {
className: "m02-dfpanel-unitbox",
children: [el("span", { className: "m02-dfpanel-note", text: tf("Unit") }), unit],
}),
);
}
function drawGot(st: State): void {
const f = funcOf(st);
const items = incoming(st);
const chips = items.map(({ port, unit }) => {
const at = f?.inputs.find((i) => i.port === port)?.cell;
const b = el("button", {
className: `m02-dfpanel-chip is-in${at ? " is-placed" : ""}`,
text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`,
attrs: { type: "button", draggable: st.readOnly ? "false" : "true" },
});
b.addEventListener("click", () => placePort(st, port, st.cell));
b.addEventListener("dragstart", (e) => e.dataTransfer?.setData("text/plain", port));
return b;
});
st.parts.got.replaceChildren(
el("span", { className: "m02-dfpanel-label", text: tf("Got") }),
...(chips.length ? chips : [el("span", { className: "m02-dfpanel-note", text: tf("NoGot") })]),
...(chips.length && !st.readOnly
? [el("span", { className: "m02-dfpanel-note", text: tf("GotHint") })]
: []),
);
}
function formField(
st: State,
label: keyof typeof TEXT,
key: "name" | "unit" | "min" | "max" | "choices" | "example",
cls = "",
): HTMLElement {
const form = st.form!;
const input = el("input", {
className: `ui-input m02-dfpanel-field ${cls}`,
attrs: { type: "text", "aria-label": tf(label) },
});
input.value = form[key];
input.addEventListener("input", () => (form[key] = input.value));
input.addEventListener("keydown", (e) => e.key === "Enter" && makeVariable(st));
return el("label", {
className: "m02-dfpanel-fieldbox",
children: [el("span", { className: "m02-dfpanel-note", text: tf(label) }), input],
});
}
function drawVars(st: State): void {
const f = funcOf(st);
const row: HTMLElement[] = [el("span", { className: "m02-dfpanel-label", text: tf("Vars") })];
for (const v of f?.variables ?? []) {
const chip = el("span", {
className: "m02-dfpanel-chip is-var",
children: [`${v.name} (${v.unit}) · ${v.cell ?? ""}`],
});
if (!st.readOnly) {
chip.append(
createButton({
label: "×",
variant: "ghost",
size: "sm",
onClick: () =>
patchFunc(st, (x) => ({
...x,
variables: x.variables.filter((o) => o.name !== v.name),
})),
}),
);
chip.lastElementChild?.setAttribute("aria-label", `${v.name} ${tf("Remove")}`);
}
row.push(chip);
}
if (!f?.variables.length)
row.push(el("span", { className: "m02-dfpanel-note", text: tf("NoVars") }));
const parts: HTMLElement[] = [el("div", { className: "m02-dfpanel-row", children: row })];
if (!st.readOnly) {
const form = st.form;
if (!form) {
parts.push(
el("div", {
className: "m02-dfpanel-row",
children: [
createButton({
label: tf("MakeVar"),
variant: "ghost",
size: "sm",
onClick: () => {
st.form = {
name: "",
type: "number",
unit: "",
min: "",
max: "",
choices: "",
example: "",
why: "",
};
drawVars(st);
},
}),
],
}),
);
} else {
const type = el("select", {
className: "ui-select m02-dfpanel-field",
attrs: { "aria-label": tf("VarType") },
});
for (const [v, k] of [
["number", "Number"],
["choice", "Choice"],
["text", "Text"],
] as const) {
type.append(el("option", { attrs: { value: v }, text: tf(k) }));
}
type.value = form.type;
type.addEventListener("change", () => {
form.type = type.value as CfVariable["type"];
drawVars(st);
});
parts.push(
el("div", {
className: "m02-dfpanel-row m02-dfpanel-form",
children: [
formField(st, "VarName", "name"),
el("label", {
className: "m02-dfpanel-fieldbox",
children: [el("span", { className: "m02-dfpanel-note", text: tf("VarType") }), type],
}),
formField(st, "VarUnit", "unit"),
...(form.type === "number"
? [formField(st, "VarMin", "min", "is-num"), formField(st, "VarMax", "max", "is-num")]
: []),
...(form.type === "choice" ? [formField(st, "VarChoices", "choices")] : []),
formField(st, "VarExample", "example"),
el("span", { className: "m02-dfpanel-cell", text: `${tf("VarCell")} ${st.cell}` }),
createButton({
label: tf("Make"),
variant: "filled",
size: "sm",
onClick: () => makeVariable(st),
}),
createButton({
label: tf("Cancel"),
variant: "ghost",
size: "sm",
onClick: () => ((st.form = null), drawVars(st)),
}),
...(form.why
? [el("span", { className: "m02-dfpanel-why", text: `⚠ ${form.why}` })]
: []),
],
}),
);
}
}
st.parts.vars.replaceChildren(...parts);
}
function drawBar(st: State): void {
const f = funcOf(st);
if (st.readOnly) {
st.parts.bar.replaceChildren(
el("span", { className: "m02-dfpanel-note", text: tf("ReadOnly") }),
);
return;
}
const outNow = f?.output.cell ? `${tf("OutNow")} ${f.output.cell}` : tf("OutNone");
st.parts.bar.replaceChildren(
el("div", {
className: "m02-dfpanel-row",
children: [
createButton({
label: tf("OutBtn"),
variant: "ghost",
size: "sm",
onClick: () => setOutput(st, st.cell),
}),
el("span", { className: "m02-dfpanel-chip is-out", text: outNow }),
el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }),
],
}),
el("div", {
className: "m02-dfpanel-row",
children: [
el("span", { className: "m02-dfpanel-label", text: tf("Frames") }),
...FRAMES.map(([key, formula]) =>
createButton({
label: tf(key),
variant: "ghost",
size: "sm",
onClick: () => putFormula(st, formula),
}),
),
el("span", { className: "m02-dfpanel-note", text: tf("FrameHint") }),
],
}),
);
}
/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */
function setOutput(st: State, cell: string): void {
const f = funcOf(st);
if (!f) return;
if (f.inputs.some((i) => i.cell === cell) || f.variables.some((v) => v.cell === cell)) {
return void showToast(tf("Busy"), "error");
}
const row = cell.replace(/^[A-Z]+/, "");
const label = f.sheet.칸[`A${row}`];
const name = String(label?.값 ?? f.output.name ?? "").trim() || cell;
patchFunc(st, (x) => ({ ...x, output: { ...x.output, cell, name } }));
}
/* ------------------------------ 편집기 ------------------------------ */
/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */
function putFormula(st: State, formula: string): void {
const root = st.parts.sheet;
const bar = root.querySelector<HTMLTextAreaElement>(".ss-formula");
const addr = root.querySelector<HTMLInputElement>(".ss-addr");
if (!bar || !addr || st.readOnly) return;
const at = st.cell;
bar.focus();
bar.value = formula;
bar.dispatchEvent(new Event("input", { bubbles: true }));
bar.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }),
);
addr.value = at;
addr.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }),
);
}
/** 서식 번호는 이 판이 안 들고 있음 — 지워 둠 */
function plain(sheet: CfFunc["sheet"]): CfFunc["sheet"] {
for (const cell of Object.values(sheet.칸)) delete cell.서식;
return sheet;
}
/** 편집기가 문서를 냄 — 받은 알약이 놓인 칸을 직접 고쳤으면 되돌리고 한 줄 */
function onBook(st: State, book: Workbook): void {
const f = funcOf(st);
if (!f) return;
const sheet = plain(structuredClone(book.시트[0]));
const hit = f.inputs.find(
(i) => i.cell && JSON.stringify(f.sheet.칸[i.cell]) !== JSON.stringify(sheet.칸[i.cell]),
);
if (hit) {
showToast(tf("Locked"), "error");
st.json = sheetJson(f);
return st.handle?.setDoc(bookOf(f));
}
st.json = JSON.stringify(sheet);
patchFunc(st, (x) => ({ ...x, sheet }));
setTimeout(() => refreshSnap(st), 0);
}
/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */
function refreshSnap(st: State): void {
if (!st.handle) return;
st.snap = st.handle.recalc();
st.redraw();
}
async function mountSheet(st: State): Promise<void> {
const { createSpreadsheet } = await loadSheet();
const f = funcOf(st);
if (!st.parts.sheet.isConnected || st.handle || !f) return;
st.parts.sheet.replaceChildren();
st.json = sheetJson(f);
st.handle = createSpreadsheet(st.parts.sheet, bookOf(f), {
preview: true,
tabs: false,
readOnly: st.readOnly,
onChange: (b) => onBook(st, b),
onSelect: (s) => {
st.cell = s.칸;
drawHead(st);
if (st.form) drawVars(st);
},
});
st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st)));
st.observer.observe(st.parts.sheet, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-addr"],
});
st.parts.sheet.addEventListener("dragover", (e) => !st.readOnly && e.preventDefault());
st.parts.sheet.addEventListener("drop", (e) => {
e.preventDefault();
const port = e.dataTransfer?.getData("text/plain");
const cell = (e.target as Element | null)?.closest<HTMLElement>(".aislo-cell")?.dataset.addr;
if (port && cell) placePort(st, port, cell);
});
refreshSnap(st);
}
/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
export function renderFuncPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: Handlers,
opts: { 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;
const f = funcOf(old);
if (old.handle && f && sheetJson(f) !== old.json) {
old.json = sheetJson(f);
old.handle.setDoc(bookOf(f));
setTimeout(() => refreshSnap(old), 0);
return;
}
return old.redraw();
}
if (old) {
old.observer?.disconnect();
old.handle?.destroy();
}
const parts: State["parts"] = {
head: el("div", { className: "m02-dfpanel-head" }),
got: el("div", { className: "m02-dfpanel-row" }),
vars: el("div", { className: "m02-dfpanel-vars" }),
bar: el("div", { className: "m02-dfpanel-bar" }),
msg: el("p", { className: "m02-dfpanel-msg", attrs: { role: "status" } }),
sheet: el("div", { className: "m02-dfpanel-sheet", text: tf("Loading") }),
};
const st: State = {
boxId,
lg,
on,
readOnly,
handle: null,
snap: {},
json: "",
cell: "A1",
form: null,
parts,
observer: null,
redraw: () => {
drawHead(st);
drawGot(st);
drawVars(st);
drawBar(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);
parts.msg.hidden = true;
st.redraw();
void mountSheet(st);
}
@@ -0,0 +1,145 @@
.m02-dfip {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
color: var(--color-text);
font-size: var(--text-body-lg, 1.05rem);
}
.m02-dfip h4,
.m02-dfip h5 {
margin: 0;
color: var(--color-text-secondary);
}
.m02-dfip h4 {
font-size: 1.15em;
color: var(--color-text);
}
.m02-dfip__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-dfip__unit {
margin: 0;
font-weight: 600;
}
.m02-dfip__body,
.m02-dfip__chosen,
.m02-dfip__find,
.m02-dfip__stack,
.m02-dfip__list {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-dfip__list {
max-height: 28vh;
overflow: auto;
}
.m02-dfip__chips {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m02-dfip__chip,
.m02-dfip__row {
padding: var(--spacing-8) var(--spacing-12);
border: 2px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.m02-dfip__chip {
border-radius: var(--radius-pills);
padding: var(--spacing-4) var(--spacing-12);
}
.m02-dfip__chip.is-on {
border-color: var(--color-accent);
outline: 2px solid var(--color-accent);
}
.m02-dfip__chip:hover,
.m02-dfip__chip:focus-visible,
.m02-dfip__row:hover,
.m02-dfip__row:focus-visible {
border-color: var(--color-accent);
}
.m02-dfip__row {
display: flex;
flex: 1 1 auto;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
text-align: left;
}
.m02-dfip__rowbox {
display: flex;
gap: var(--spacing-8);
align-items: center;
}
.m02-dfip__bar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.m02-dfip__bar > :last-child {
flex: 1 1 180px;
}
.m02-dfip__item {
display: flex;
gap: var(--spacing-8);
align-items: center;
justify-content: space-between;
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.m02-dfip__name {
flex: 1 1 auto;
min-width: 0;
}
.m02-dfip__value {
color: var(--color-accent);
}
.m02-dfip__tag {
margin-left: var(--spacing-8);
padding: 0 var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
font-size: var(--text-caption);
}
.m02-dfip__new,
.m02-dfip__form {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-cards);
}
.m02-dfip__new > summary {
cursor: pointer;
font-weight: 600;
}
.m02-dfip__preview table {
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m02-dfip__preview th,
.m02-dfip__preview td {
padding: 2px 8px;
border: 1px solid var(--color-border);
white-space: nowrap;
text-align: center;
}
.m02-dfip__preview tr.is-hit td {
background: rgba(240, 190, 40, 0.35);
font-weight: 700;
}
.m02-dfip__preview {
max-height: 24vh;
overflow: auto;
}
@@ -0,0 +1,459 @@
/* =============================================================================
* M02_DF_InputPanel.ts
* 단가산출 다이어그램 — 입력 상자 판: 갈래(그룹 · 변수) 고르기 · M01 그룹 요소 고르기 · 새 요소(사용자 요소) ·
* 소요량 · 계수 표 고르기(Table.ts) · 변수 정의(Vars.ts) · 상자 단위
* M01 Api(`Api_Fetch`)만 가져다 씀 — 마스터에 쓰지 않음(새 요소는 서버 `/api/m02/df/user-elements` = 시험 폴더 `_data`)
* 글 [한, 영] 짝은 아래 TEXT · 용어는 정함(입력 · 함수 · 출력 · 다이어그램 · 알약 · 변수 · 단위)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import {
fetchFiles,
fetchRows,
fetchSubs,
type SubInfo,
} from "../../M01_MasterData/M01_MasterData_Api_Fetch";
import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Api";
import type { CfBox, CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model";
import {
GROUPS,
M01_OF,
MACHINE_SEP,
USER_PREFIX,
addElement,
elementOf,
machineFields,
queryOf,
reconcileUnit,
removeElement,
setKind,
valueOf,
type M01Row,
} from "./M02_DF_InputPanel_Build";
import { renderTablePick } from "./M02_DF_InputPanel_Table";
import { renderVariables } from "./M02_DF_InputPanel_Vars";
import "./M02_DF_InputPanel.css";
/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */
interface Answer {
id: string;
ok: boolean;
value?: number | string | null;
}
interface UserEl {
id: string;
group: string;
name: string;
spec: string;
unit: string;
value: number | null;
}
const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise<Answer[]>;
const apiUsers = cfUserElements as unknown as () => Promise<UserEl[]>;
const apiAdd = cfUserElementAdd as unknown as (e: Omit<UserEl, "id">) => Promise<UserEl>;
const TEXT = {
Kind: ["무엇을 넣을까요?", "What goes in?"],
Variable: ["변수", "Variable"],
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
유가전력: ["유가·전력", "Fuel · power"],
기계: ["기계", "Machine"],
소요량: ["소요량", "Quantity table"],
계수: ["계수", "Factor table"],
환율: ["환율", "Exchange rate"],
요율: ["요율", "Rate"],
Chosen: [
"넣은 요소 — 알약마다 이름 · 값 · 단위",
"Chosen elements — name · value · unit per pill",
],
NoneChosen: ["아직 고른 요소가 없음", "Nothing chosen yet"],
Remove: ["빼기", "Remove"],
Put: ["넣기", "Add"],
Search: ["이름으로 찾기", "Search by name"],
AllSub: ["전체", "All"],
SubLabel: ["구분", "Group"],
DetailLabel: ["상세구분", "Detail"],
Loading: ["찾는 중…", "Searching…"],
NoRow: ["찾는 요소 없음", "Nothing found"],
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
Mine: ["내가 만든 것", "Mine"],
NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"],
NewName: ["이름", "Name"],
NewSpec: ["규격", "Spec"],
NewUnit: ["단위", "Unit"],
NewValue: ["값(숫자)", "Value (number)"],
Make: ["만들어 넣기", "Create and add"],
NeedName: ["이름을 적어 주세요", "Enter a name"],
NeedValue: ["값은 숫자로", "Value must be a number"],
BoxUnit: ["상자 단위", "Box unit"],
Field: ["값 칸", "Value"],
NoValue: ["—", "—"],
Failed: ["불러오지 못함", "Could not load"],
Pills: ["알약 {n} 개", "{n} pills"],
NotInput: ["입력 상자를 고르면 이 판이 뜸", "Pick an input box"],
} as const;
type TKey = keyof typeof TEXT;
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");
/** 서버 실패 → 쉬운 말(Api 쉬운 말 길이 아직 없으면 그 글 그대로) */
const plain = (e: unknown): string => {
try {
return cfPlain(e);
} catch {
return e instanceof Error ? e.message : t("Failed");
}
};
const fileCache = new Map<string, string>();
/** 그룹의 M01 파일 이름 */
async function fileOf(group: CfGroup): Promise<string> {
const hit = fileCache.get(group);
if (hit) return hit;
const { group: g, part } = M01_OF[group];
const files = await fetchFiles(g);
const f = files.find((x) => !part || x.file.includes(part)) ?? files[0];
fileCache.set(group, f.file);
return f.file;
}
/** 입력 상자 판 — 고른 입력 상자의 갈래 · 요소 · 표 · 변수를 고침 · 바뀌면 on.change(새 CfLogic) */
export function renderInputPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
): void {
host.className = "m02-dfip";
const box = lg.boxes.find((b) => b.id === boxId);
if (!box || box.kind !== "input") {
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("NotInput") }));
return;
}
// 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감
const fixed = reconcileUnit(lg, boxId);
if (fixed !== lg) return on.change(fixed);
const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] };
const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력");
const body = el("div", { className: "m02-dfip__body" });
// ① 갈래 — 그룹 여덟 + 변수
const kinds = el("div", {
className: "m02-dfip__chips",
attrs: { role: "tablist", "aria-label": t("Kind") },
children: [...GROUPS, "변수" as const].map((k) => {
const b = el("button", {
className: `m02-dfip__chip${k === kind && (k !== "변수" ? input.mode === "group" : true) ? " is-on" : ""}`,
attrs: { type: "button", role: "tab" },
text: k === "변수" ? t("Variable") : t(k),
});
b.addEventListener("click", () => k !== kind && on.change(setKind(lg, boxId, k)));
return b;
}),
});
const unit = el("p", {
className: "m02-dfip__unit",
text: `${t("BoxUnit")}: ${box.unit || "-"}`,
});
host.replaceChildren(el("h4", { text: t("Kind") }), kinds, unit, body);
if (kind === "변수") renderVariables(body, lg, boxId, on);
else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on);
else renderGroup(body, lg, box, kind, on);
}
/** 그룹 요소 — 고른 것 목록 · 찾기 · 새 요소 */
function renderGroup(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const chosen = el("div", { className: "m02-dfip__chosen" });
const find = el("div", { className: "m02-dfip__find" });
host.replaceChildren(chosen, find, newForm(lg, box, group, on));
drawChosen(chosen, lg, box, group, on);
void drawFind(find, lg, box, group, on);
}
/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 Api 가 마스터에서 */
function drawChosen(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const els = box.input?.elements ?? [];
const values = new Map<string, HTMLElement>();
host.replaceChildren(
el("h5", { text: `${t("Chosen")} · ${t("Pills", { n: els.length })}` }),
...(els.length
? els.map((e) => {
const val = el("strong", { className: "m02-dfip__value", text: t("NoValue") });
values.set(e.ref, val);
return el("div", {
className: "m02-dfip__item",
children: [
el("span", { className: "m02-dfip__name", text: e.name }),
val,
el("span", { className: "m02-dfip__muted", text: e.unit || "-" }),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeElement(lg, box.id, e.ref)),
}),
],
});
})
: [el("p", { className: "m02-dfip__muted", text: t("NoneChosen") })]),
);
if (!els.length) return;
const items = els.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
void apiValues(items, lg.userElements).then(
(answers) =>
answers.forEach((a) => {
const node = values.get(a.id);
if (node && a.ok && a.value !== null && a.value !== undefined)
node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value);
}),
// Api 가 아직 값을 못 주면 M01 줄에서 바로 읽어 보임(값 칸만 — 요소는 그대로)
() =>
els.forEach(
(e) =>
void m01Value(group, e).then((v) => {
const node = values.get(e.ref);
if (node && v !== null) node.textContent = fmt(v);
}),
),
);
}
/** 요소 한 개의 값을 M01 줄에서 읽음 — 이름으로 찾아 키가 같은 줄 · 못 찾으면 null */
async function m01Value(group: CfGroup, e: CfElementRef): Promise<number | null> {
if (e.ref.startsWith(USER_PREFIX)) return null;
try {
const [key, field] = e.ref.split(MACHINE_SEP);
const page = await fetchRows(
await fileOf(group),
1,
30,
e.name.split(" · ")[0].split(" ")[0],
false,
"",
"",
);
const row = (page.rows as M01Row[]).find((r) => r["키"] === key);
return row ? valueOf(group, row, field || "가격") : null;
} catch {
return null;
}
}
/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */
async function drawFind(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): Promise<void> {
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-dfip__list" });
const count = el("span", { className: "m02-dfip__muted" });
const bar: HTMLElement[] = [];
let file = "";
let sub = "";
let detail = "";
let subs: SubInfo[] = [];
const all = { value: "", text: t("AllSub") };
const subBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: t("SubLabel"),
onChange: (v) => ((sub = v), (detail = ""), fillDetail(), void load()),
});
const detailBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: t("DetailLabel"),
onChange: (v) => ((detail = v), void load()),
});
const fillDetail = (): void => {
const d = subs.find((s) => s.name === sub)?.details ?? [];
detailBox.root.hidden = !d.length;
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], "");
};
fillDetail();
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
try {
const word = q.input.value.trim();
const [page, users] = await Promise.all([
fetchRows(file, 1, 40, word, false, sub, detail),
apiUsers().catch((): UserEl[] => []),
]);
if (mine !== seq) return;
const known = new Map<string, UserEl>();
for (const u of [...lg.userElements, ...users])
if (u.group === group) known.set(u.id, u as UserEl);
const mineRows: M01Row[] = [...known.values()]
.filter((u) => !word || `${u.name} ${u.spec}`.includes(word))
.map((u) => ({
키: u.id,
이름: u.name,
규격: u.spec,
단위: u.unit,
값: u.value,
__mine: true,
}));
const rows = [...mineRows, ...(page.rows as M01Row[])];
count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length });
list.replaceChildren(
...(rows.length
? rows.map((r) => rowOf(r))
: [el("p", { className: "m02-dfip__muted", text: t("NoRow") })]),
);
} catch (e) {
if (mine === seq)
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) }));
}
};
const rowOf = (r: M01Row): HTMLElement => {
const fields = group === "기계" ? machineFields(r) : [];
const field = createSelectField({
options: fields.map((f) => ({ value: f, text: f })),
value: fields.includes("가격") ? "가격" : (fields[0] ?? ""),
compact: true,
ariaLabel: t("Field"),
});
const pick = (): CfLogic =>
addElement(lg, box.id, elementOf(group, r, field.select.value || "가격"));
const v = valueOf(group, r, field.select.value || "가격");
const unitText = group === "기계" ? "" : String(r["단위"] ?? "");
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("strong", { text: String(r["이름"] ?? "") }),
el("span", { className: "m02-dfip__muted", text: ` ${r["규격"] ?? ""}` }),
...(r["__mine"] ? [el("span", { className: "m02-dfip__tag", text: t("Mine") })] : []),
],
}),
el("span", {
className: "m02-dfip__muted",
text: `${v === null ? "" : fmt(v)} ${unitText}`.trim(),
}),
],
});
b.addEventListener("click", () => on.change(pick()));
return el("div", {
className: "m02-dfip__rowbox",
children: [b, ...(fields.length ? [field.root] : [])],
});
};
try {
file = await fileOf(group);
subs = await fetchSubs({ file }).catch(() => []);
subBox.setOptions([all, ...subs.map((s) => ({ value: s.name, text: s.name }))], "");
subBox.root.hidden = !subs.length;
bar.push(subBox.root, detailBox.root);
} catch (e) {
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) }));
return;
}
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
host.replaceChildren(
el("div", { className: "m02-dfip__bar", children: [...bar, q.root] }),
count,
list,
);
void load();
void USER_PREFIX;
}
/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */
function newForm(
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): HTMLElement {
const name = createInputField({ type: "text", label: t("NewName"), compact: true });
const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true });
const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true });
const value = createInputField({ type: "text", label: t("NewValue"), compact: true });
const go = createButton({
label: t("Make"),
onClick: () => {
const n =
value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, ""));
if (!name.input.value.trim()) return name.setError(t("NeedName"));
if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue"));
name.setError(undefined);
value.setError(undefined);
apiAdd({
group,
name: name.input.value.trim(),
spec: spec.input.value.trim(),
unit: unit.input.value.trim(),
value: n,
}).then(
(made) => {
const el1 = elementOf(group, {
키: made.id,
이름: made.name,
규격: made.spec,
단위: made.unit,
값: made.value,
});
const withUser: CfLogic = {
...lg,
userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }],
};
on.change(addElement(withUser, box.id, el1));
},
(e) => showToast(plain(e), "error"),
);
},
});
const d = el("details", {
className: "m02-dfip__new",
children: [
el("summary", { text: t("NewHead") }),
name.root,
spec.root,
unit.root,
value.root,
go,
],
});
return d;
}
@@ -0,0 +1,294 @@
/* =============================================================================
* M02_DF_InputPanel_Build.ts
* 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic)
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts`
* ========================================================================== */
import type { CfBox, CfElementRef, CfGroup, CfInput, CfLogic, CfVariable } from "./M02_DF_Model";
/** 입력 상자가 고르는 그룹 — 화면 차례 */
export const GROUPS: CfGroup[] = [
"인력",
"재료",
"유가전력",
"기계",
"소요량",
"계수",
"환율",
"요율",
];
/** 그룹 → M01 저장 그룹 · 파일 이름에 든 낱말(재료 그룹은 파일이 둘) */
export const M01_OF: Record<CfGroup, { group: string; part: string }> = {
인력: { group: "인력", part: "" },
재료: { group: "재료", part: "자재품목" },
유가전력: { group: "재료", part: "유가전력" },
기계: { group: "기계", part: "" },
소요량: { group: "소요량", part: "" },
계수: { group: "계수", part: "" },
환율: { group: "환율", part: "" },
요율: { group: "요율", part: "" },
};
/** M01 줄 한 개 — 파일 그대로(한글 칸) */
export type M01Row = Record<string, unknown>;
/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */
export const USER_PREFIX = "UE-";
/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */
export const MACHINE_SEP = "#";
export const machineFields = (row: M01Row): string[] =>
row["값"] && typeof row["값"] === "object" ? Object.keys(row["값"] as object) : [];
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const dictUnit = (row: M01Row, field: string): string =>
row["단위"] && typeof row["단위"] === "object"
? text((row["단위"] as Record<string, unknown>)[field])
: "";
/** 재료 줄 값 열 다섯 가운데 낮은 값 — 없으면 null */
export function lowest(row: M01Row): number | null {
const found = ["물가자료", "유통물가", "물가정보", "거래가격"]
.map((k) => row[k])
.filter((v): v is number => typeof v === "number");
return found.length ? Math.min(...found) : null;
}
/** 줄 옆에 보일 값 — 모르면 null */
export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number | null {
if (group === "재료") return lowest(row);
if (group === "기계")
return typeof (row["값"] as Record<string, unknown> | undefined)?.[field] === "number"
? (row["값"] as Record<string, number>)[field]
: null;
if (group === "요율") {
const v = row["값"] as Record<string, unknown> | undefined;
return typeof v?.["rate_percent"] === "number" ? v["rate_percent"] : null;
}
return typeof row["값"] === "number" ? row["값"] : null;
}
/** M01 줄 → 입력 상자 요소 — 요율은 비율이 하나뿐인 줄만 「%」 */
export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef {
const key = text(row["키"]);
const spec = text(row["규격"]);
if (key.startsWith(USER_PREFIX))
return {
ref: key,
name: [text(row["이름"]), spec].filter(Boolean).join(" "),
spec,
unit: text(row["단위"]) || "-",
};
// 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름
const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec;
const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게)
if (group === "기계")
return {
ref: `${key}${MACHINE_SEP}${field}`,
name: `${named} · ${field}`,
spec,
unit: dictUnit(row, field) || "-",
};
if (group === "요율")
return {
ref: key,
name: text(row["이름"]),
spec: "",
unit: valueOf(group, row) === null ? "-" : "%",
};
return { ref: key, name: named, spec, unit: text(row["단위"]) || "-" };
}
/** 입력 상자가 내보내는 알약 이름 — 요소면 요소 이름 · 표면 「표」 · 변수면 변수 이름(정함) */
export function pillNames(box: CfBox): string[] {
const i = box.input;
if (!i) return [];
if (i.table) return ["표"];
return i.mode === "variable" ? i.variables.map((v) => v.name) : i.elements.map((e) => e.name);
}
/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */
export function queryOf(group: CfGroup, ref: string): Record<string, unknown> {
if (group === "기계" && !ref.startsWith(USER_PREFIX)) {
const [key, field] = ref.split(MACHINE_SEP);
return { group, ref: key, field: field || "가격" };
}
return { group, ref };
}
/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */
export function unitOfInput(input: CfInput): string {
if (input.table) return "-";
const units = (
input.mode === "variable"
? input.variables.map((v) => v.unit)
: input.elements.map((e) => e.unit)
).map((u) => u || "-");
return units.length && units.every((u) => u === units[0]) ? units[0] : "-";
}
/** 변수 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
export function checkVariable(v: CfVariable, others: CfVariable[]): string | null {
const name = v.name.trim();
if (!name) return "이름을 적어 주세요";
if (others.some((o) => o.name === name)) return "같은 이름의 변수가 이미 있음";
if (v.type === "choice" && (v.choices ?? []).length < 2) return "선택 목록은 둘 이상";
if (v.type === "number") {
if (v.range && !(v.range[0] < v.range[1])) return "범위는 아래가 위보다 작아야 함";
if (
typeof v.example === "number" &&
v.range &&
(v.example < v.range[0] || v.example > v.range[1])
)
return "예시값이 범위 밖";
if (v.example !== null && typeof v.example !== "number") return "예시값은 숫자";
}
if (
v.type === "choice" &&
v.example !== null &&
!(v.choices ?? []).map(String).includes(String(v.example))
)
return "예시값이 선택 목록에 없음";
return null;
}
/* ── 상자 고치기 — 새 CfLogic 을 돌려줌(받은 것은 안 바꿈) ── */
type Pills = (box: CfBox) => string[];
const defaultPills: Pills = pillNames;
const touch = (
lg: CfLogic,
id: string,
fn: (b: CfBox) => CfBox,
pills: Pills = defaultPills,
): CfLogic => {
const old = lg.boxes.find((b) => b.id === id);
if (!old) return lg;
const next = fn(old);
const boxes = lg.boxes.map((b) =>
b.id === id ? { ...next, unit: next.input ? unitOfInput(next.input) : next.unit } : b,
);
// 알약이 없어진 선은 지움
const keep = new Set(pills(next));
const links = lg.links.filter((l) => l.from.box !== id || keep.has(l.from.name));
return { ...lg, boxes, links };
};
const inputOf = (b: CfBox): CfInput => b.input ?? { mode: "group", elements: [], variables: [] };
/** 갈래 바꾸기 — 그룹이면 고른 것 비움 · 변수 ↔ 그룹은 서로의 내용을 비움 */
export function setKind(lg: CfLogic, id: string, kind: CfGroup | "변수", pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => {
const cur = inputOf(b);
if (kind === "변수")
return { ...b, input: { mode: "variable", elements: [], variables: cur.variables } };
const same = cur.mode === "group" && cur.group === kind;
return {
...b,
input: same ? cur : { mode: "group", group: kind, elements: [], variables: [] },
};
},
pills,
);
}
/** 요소 넣기 — 같은 요소는 한 번만 */
export function addElement(lg: CfLogic, id: string, el: CfElementRef, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => {
const cur = inputOf(b);
return cur.elements.some((e) => e.ref === el.ref || e.name === el.name)
? b
: { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } };
},
pills,
);
}
export function removeElement(lg: CfLogic, id: string, ref: string, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => ({
...b,
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.ref !== ref) },
}),
pills,
);
}
/** 표 고르기 — `row` 가 있으면 그 줄 하나 · 요소는 비움 */
export function setTable(
lg: CfLogic,
id: string,
table: { key: string; row?: number } | null,
name?: string,
pills?: Pills,
): CfLogic {
const next = touch(
lg,
id,
(b) => ({ ...b, input: { ...inputOf(b), elements: [], table: table ?? undefined } }),
pills,
);
return name
? {
...next,
boxes: next.boxes.map((b) =>
b.id === id && /^(입력|새)/.test(b.name) ? { ...b, name } : b,
),
}
: next;
}
/** 변수 넣기 · 고치기(이름이 같으면 바꿈) */
export function putVariable(
lg: CfLogic,
id: string,
v: CfVariable,
oldName?: string,
pills?: Pills,
): CfLogic {
return touch(
lg,
id,
(b) => {
const cur = inputOf(b);
const vars =
oldName === undefined
? [...cur.variables, v]
: cur.variables.map((x) => (x.name === oldName ? v : x));
return { ...b, input: { ...cur, mode: "variable", group: undefined, variables: vars } };
},
pills,
);
}
export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => ({
...b,
input: { ...inputOf(b), variables: inputOf(b).variables.filter((v) => v.name !== name) },
}),
pills,
);
}
/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */
export function reconcileUnit(lg: CfLogic, id: string): CfLogic {
const box = lg.boxes.find((b) => b.id === id);
if (!box?.input) return lg;
const want = unitOfInput(box.input);
return box.unit === want
? lg
: { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) };
}
@@ -0,0 +1,323 @@
/* =============================================================================
* M02_DF_InputPanel_Table.ts
* 입력 상자 판 — 소요량 · 계수 표 고르기(절 · 이름 찾기 → 미리 보기 → 표 통째 또는 줄 하나)
* `M02_DF_InputPanel.ts` 가 부름 · M01 Api 길(`/sections` · `/tables` · `/table`)만 읽음 · 글 [한, 영] 짝은 아래
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import type { CfGroup, CfLogic } from "./M02_DF_Model";
import { setTable } from "./M02_DF_InputPanel_Build";
const TEXT = {
Book: ["적용 품셈", "Standard"],
Search: ["절 번호나 이름 — 예: 13-4-1 메쌓기", "Section number or name"],
Loading: ["찾는 중…", "Searching…"],
NoSection: ["찾는 절 없음", "No section"],
SecLine: ["표 {t} 개", "{t} tables"],
Tables: ["이 절의 표 — 하나를 고름", "Pick a table"],
NoTable: ["이 절에는 이 그룹의 표가 없음", "No table of this group here"],
TableLine: [
"{b} · 조건 {c} · 결과 {v} · {n} 줄",
"{b} · {c} conditions · {v} results · {n} rows",
],
Chosen: ["고른 표", "Chosen table"],
Whole: [
"표 통째 — 함수 상자가 조건에 맞는 줄을 찾음",
"Whole table — a function box looks it up",
],
OneRow: ["줄 하나 고정", "Fix one row"],
RowNo: ["몇 번째 줄", "Row number"],
Preview: ["미리 보기", "Preview"],
Notes: ["주(註)", "Notes"],
Remove: ["표 빼기", "Remove table"],
Change: ["다른 표 고르기", "Pick another table"],
Failed: ["불러오지 못함", "Could not load"],
} as const;
type TKey = keyof typeof TEXT;
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,
);
async function get<T>(path: string, params: Record<string, string>): Promise<T> {
const res = await fetch(`${API_BASE_URL}/m01${path}?${new URLSearchParams(params)}`, {
credentials: "include",
});
const data = (await res.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!res.ok) throw new Error(String(data.detail ?? res.statusText));
return data;
}
interface SectionItem {
section: string;
title: string;
chapter: string;
tables: number;
}
interface TableHead {
file: string;
키: string;
이름: string;
기준: string;
조건: Record<string, string>;
값칸: Record<string, string>;
count: number;
}
interface RawTable {
키: string;
원문번호: string;
이름: string;
기준: string;
조건: Record<string, string>;
값칸: Record<string, string>;
줄: Record<string, unknown>[];
주?: string[];
}
const BOOKS = ["산림품셈", "건설품셈"];
const files = new Map<string, string>();
const tables = new Map<string, RawTable>();
/** 표 한 벌 — 키로 찾아 받음(받은 것은 기억) */
export async function loadTable(group: CfGroup, key: string): Promise<RawTable> {
const hit = tables.get(key);
if (hit) return hit;
let file = files.get(key);
if (!file) {
const found = (await get<{ tables: TableHead[] }>("/tables", { group, q: key })).tables.find(
(x) => x.키 === key,
);
if (!found) throw new Error(t("Failed"));
file = found.file;
}
const raw = (await get<{ table: RawTable }>("/table", { file, key })).table;
tables.set(key, raw);
return raw;
}
const cell = (v: unknown): string =>
Array.isArray(v)
? v.map((x) => x ?? "").join("~")
: v === null || v === undefined
? ""
: String(v);
/** 표 미리 보기 — 앞 8 줄 · `row`(0 부터) 줄은 칠함 */
function previewOf(tb: RawTable, row?: number): HTMLElement {
const cols = [...Object.keys(tb.조건), ...Object.keys(tb.값칸)];
const body = tb.줄.slice(0, Math.max(8, (row ?? 0) + 1));
return el("div", {
className: "m02-dfip__preview",
children: [
el("table", {
children: [
el("tr", { children: cols.map((c) => el("th", { text: c })) }),
...body.map((r, i) =>
el("tr", {
className: i === row ? "is-hit" : "",
children: cols.map((c) => el("td", { text: cell(r[c]) })),
}),
),
],
}),
...(tb.줄.length > body.length
? [el("p", { className: "m02-dfip__muted", text: `… ${tb.줄.length} 줄` })]
: []),
...(tb.주?.length
? [
el("details", {
children: [
el("summary", { text: t("Notes") }),
...tb.주.map((n) => el("p", { text: n })),
],
}),
]
: []),
],
});
}
/** 소요량 · 계수 표 고르기 화면 */
export function renderTablePick(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
on: { change(lg: CfLogic): void },
): void {
const box = lg.boxes.find((b) => b.id === boxId);
const cur = box?.input?.table;
if (cur) return void showChosen(host, lg, boxId, group, cur, on);
let book = BOOKS[0];
const bookBox = createSelectField({
label: t("Book"),
options: BOOKS.map((b) => ({ value: b, text: b })),
value: book,
compact: true,
onChange: (v) => ((book = v), load()),
});
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-dfip__list" });
const tableList = el("div", { className: "m02-dfip__list" });
let seq = 0;
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
try {
const r = await get<{ sections: SectionItem[] }>("/sections", {
book,
q: q.input.value.trim(),
limit: "60",
});
if (mine !== seq) return;
list.replaceChildren(
...(r.sections.length
? r.sections.map((s) => {
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
children: [
el("strong", { text: `${s.section} ${s.title}` }),
el("span", {
className: "m02-dfip__muted",
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
}),
],
});
b.addEventListener("click", () => void pickSection(s));
return b;
})
: [el("p", { className: "m02-dfip__muted", text: t("NoSection") })]),
);
} catch (e) {
if (mine === seq) showToast(e instanceof Error ? e.message : t("Failed"), "error");
}
};
const pickSection = async (s: SectionItem): Promise<void> => {
try {
const heads = (
await get<{ tables: TableHead[] }>("/tables", {
group,
section: s.section,
sub: book,
size: "100",
})
).tables;
tableList.replaceChildren(
el("h5", { text: t("Tables") }),
...(heads.length
? heads.map((h) => {
const b = el("button", {
className: "m02-dfip__row",
attrs: { type: "button" },
children: [
el("strong", { text: h.이름 }),
el("span", {
className: "m02-dfip__muted",
text: t("TableLine", {
b: h.기준 || "—",
c: Object.keys(h.조건).length,
v: Object.keys(h.값칸).length,
n: h.count,
}),
}),
],
});
b.addEventListener("click", () => {
files.set(h.키, h.file);
void loadTable(group, h.키).then(
(tb) => on.change(setTable(lg, boxId, { key: h.키 }, tb.이름)),
(e) => showToast(e instanceof Error ? e.message : t("Failed"), "error"),
);
});
return b;
})
: [el("p", { className: "m02-dfip__muted", text: t("NoTable") })]),
);
} catch (e) {
showToast(e instanceof Error ? e.message : t("Failed"), "error");
}
};
let timer = 0;
q.input.addEventListener(
"input",
() => (window.clearTimeout(timer), (timer = window.setTimeout(load, 250))),
);
host.replaceChildren(bookBox.root, q.root, list, tableList);
void load();
}
/** 고른 표 — 통째 · 줄 하나 · 미리 보기 */
function showChosen(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
cur: { key: string; row?: number },
on: { change(lg: CfLogic): void },
): void {
const body = el("div", { className: "m02-dfip__stack" });
host.replaceChildren(body);
void loadTable(group, cur.key).then(
(tb) => {
const whole = cur.row === undefined;
const modeWhole = createButton({
label: t("Whole"),
variant: whole ? "filled" : "ghost",
onClick: () => on.change(setTable(lg, boxId, { key: cur.key })),
});
const modeOne = createButton({
label: t("OneRow"),
variant: whole ? "ghost" : "filled",
onClick: () => on.change(setTable(lg, boxId, { key: cur.key, row: cur.row ?? 0 })),
});
const rowBox = createSelectField({
label: t("RowNo"),
options: tb.줄.map((r, i) => ({
value: String(i),
text: `${i + 1} · ${[...Object.keys(tb.조건), ...Object.keys(tb.값칸)]
.slice(0, 4)
.map((c) => cell(r[c]))
.join(" / ")}`,
})),
value: String(cur.row ?? 0),
compact: true,
onChange: (v) => on.change(setTable(lg, boxId, { key: cur.key, row: Number(v) })),
});
body.replaceChildren(
el("h5", { text: `${t("Chosen")} — ${tb.이름} · ${tb.원문번호} · ${tb.기준 || "—"}` }),
el("div", { className: "m02-dfip__chips", children: [modeWhole, modeOne] }),
...(whole ? [] : [rowBox.root]),
el("h5", { text: t("Preview") }),
previewOf(tb, cur.row),
el("div", {
className: "m02-dfip__chips",
children: [
createButton({
label: t("Change"),
variant: "ghost",
onClick: () => on.change(setTable(lg, boxId, null)),
}),
],
}),
);
},
(e) =>
body.replaceChildren(
el("p", {
className: "m02-dfip__muted",
text: e instanceof Error ? e.message : t("Failed"),
}),
),
);
}
@@ -0,0 +1,201 @@
/* =============================================================================
* M02_DF_InputPanel_Vars.ts
* 입력 상자 판 — 변수(그룹 밖 정보: 면적 · 길이 · 부피 …) 정의: 이름 · 갈래(숫자 / 선택 / 글) · 단위 · 범위 · 예시값
* 변수마다 출력 알약 하나 · `M02_DF_InputPanel.ts` 가 부름 · 글 [한, 영] 짝은 아래
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { CfLogic, CfVariable } from "./M02_DF_Model";
import { checkVariable, putVariable, removeVariable } from "./M02_DF_InputPanel_Build";
const TEXT = {
List: ["변수 — 알약마다 하나", "Variables — one pill each"],
None: ["아직 변수가 없음 — 아래에서 만듦", "No variable yet"],
Name: ["이름", "Name"],
Type: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
Unit: ["단위", "Unit"],
Min: ["범위 아래", "Range from"],
Max: ["범위 위", "Range to"],
Choices: ["선택 목록(쉼표로 나눔)", "Choices (comma separated)"],
Example: ["예시값", "Example"],
Add: ["변수 넣기", "Add variable"],
Save: ["고치기 끝", "Save change"],
Edit: ["고치기", "Edit"],
Remove: ["삭제", "Remove"],
Cancel: ["취소", "Cancel"],
Line: ["{n} · {u} · 예시 {e}", "{n} · {u} · e.g. {e}"],
Range: ["범위 {a}~{b}", "range {a}~{b}"],
} as const;
type TKey = keyof typeof TEXT;
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 numOf = (s: string): number | null =>
s.trim() !== "" && Number.isFinite(Number(s)) ? Number(s) : null;
/** 변수 한 줄 글 */
export function variableLine(v: CfVariable): string {
const base = t("Line", {
n: v.name,
u: v.unit || "-",
e: v.example === null ? "—" : String(v.example),
});
const range =
v.type === "number" && v.range ? ` · ${t("Range", { a: v.range[0], b: v.range[1] })}` : "";
const choices = v.type === "choice" ? ` · ${(v.choices ?? []).join(" / ")}` : "";
return `${base}${range}${choices}`;
}
export function renderVariables(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
): void {
const box = lg.boxes.find((b) => b.id === boxId);
const vars = box?.input?.variables ?? [];
let editing: CfVariable | null = null;
const form = (): HTMLElement => {
const cur = editing;
const name = createInputField({
type: "text",
label: t("Name"),
value: cur?.name ?? "",
placeholder: "예: 면적",
compact: true,
});
const type = createSelectField({
label: t("Type"),
options: [
{ value: "number", text: t("Number") },
{ value: "choice", text: t("Choice") },
{ value: "text", text: t("Text") },
],
value: cur?.type ?? "number",
compact: true,
onChange: () => paint(),
});
const unit = createInputField({
type: "text",
label: t("Unit"),
value: cur?.unit ?? "",
placeholder: "예: ㎡",
compact: true,
});
const lo = createInputField({
type: "text",
label: t("Min"),
value: cur?.range ? String(cur.range[0]) : "",
compact: true,
});
const hi = createInputField({
type: "text",
label: t("Max"),
value: cur?.range ? String(cur.range[1]) : "",
compact: true,
});
const choices = createInputField({
type: "text",
label: t("Choices"),
value: (cur?.choices ?? []).join(", "),
compact: true,
});
const example = createInputField({
type: "text",
label: t("Example"),
value: cur?.example === null || cur?.example === undefined ? "" : String(cur.example),
compact: true,
});
const extra = el("div", { className: "m02-dfip__stack" });
const paint = (): void => {
const ty = type.select.value;
extra.replaceChildren(
...(ty === "number" ? [lo.root, hi.root] : ty === "choice" ? [choices.root] : []),
);
};
paint();
const submit = (): void => {
const ty = type.select.value as CfVariable["type"];
const ex = example.input.value.trim();
const v: CfVariable = {
name: name.input.value.trim(),
type: ty,
unit: unit.input.value.trim(),
example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex,
...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null
? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] }
: {}),
...(ty === "choice"
? {
choices: choices.input.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
}
: {}),
};
const why = checkVariable(
v,
vars.filter((x) => x.name !== cur?.name),
);
name.setError(why ?? undefined);
if (why) return;
on.change(putVariable(lg, boxId, v, cur?.name));
};
const ok = createButton({ label: cur ? t("Save") : t("Add"), onClick: submit });
const cancel = cur
? createButton({
label: t("Cancel"),
variant: "ghost",
onClick: () => ((editing = null), draw()),
})
: null;
return el("div", {
className: "m02-dfip__form",
children: [
name.root,
type.root,
unit.root,
extra,
example.root,
el("div", { className: "m02-dfip__chips", children: cancel ? [ok, cancel] : [ok] }),
],
});
};
const draw = (): void => {
const list = vars.length
? vars.map((v) =>
el("div", {
className: "m02-dfip__item",
children: [
el("span", { text: variableLine(v) }),
createButton({
label: t("Edit"),
variant: "ghost",
size: "sm",
onClick: () => ((editing = v), draw()),
}),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeVariable(lg, boxId, v.name)),
}),
],
}),
)
: [el("p", { className: "m02-dfip__muted", text: t("None") })];
host.replaceChildren(el("h5", { text: t("List") }), ...list, form());
};
draw();
}
+329
View File
@@ -0,0 +1,329 @@
/* =============================================================================
* M02_DF_Model.ts
* 단가산출 다이어그램 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나)
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 그대로
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
export type CfKind = "input" | "function" | "output";
export type CfGroup = "인력" | "재료" | "유가전력" | "기계" | "소요량" | "계수" | "환율" | "요율";
export type CfCost = "노무비" | "재료비" | "경비" | "값";
/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id */
export interface CfElementRef {
ref: string;
name: string;
spec: string;
unit: string;
}
/** 변수(입력 정의) — 그룹 밖 정보(면적 · 길이 …) · 함수 상자의 받는 값 */
export interface CfVariable {
name: string;
type: "number" | "choice" | "text";
unit: string;
choices?: (string | number)[];
range?: [number, number];
example: number | string | null;
/** 함수 상자 안 변수의 칸(「B2」) — 입력 상자 변수는 없음 */
cell?: string;
}
export interface CfInput {
mode: "group" | "variable";
group?: CfGroup;
elements: CfElementRef[];
/** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */
table?: { key: string; row?: number };
variables: CfVariable[];
}
export interface CfFunc {
/** 우리 스프레드시트 시트 하나 */
sheet: Sheet;
/** 받는 알약 → 칸 */
inputs: { port: string; cell: string }[];
variables: CfVariable[];
/** 내보내는 알약 하나 */
output: { cell: string; name: string; unit: string };
}
export interface CfOutput {
cost: CfCost;
unit: string;
}
export interface CfBox {
id: string;
kind: CfKind;
name: string;
color: string;
/** 오른쪽 위 단위 — 기본 「-」 */
unit: string;
x: number;
y: number;
input?: CfInput;
func?: CfFunc;
output?: CfOutput;
}
/** 알약 한쪽 끝 */
export interface CfPort {
box: string;
name: string;
}
export interface CfLink {
id: string;
from: CfPort;
to: CfPort;
}
/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */
export interface CfUserElement {
id: string;
group: CfGroup;
name: string;
spec: string;
unit: string;
value: number | null;
}
export interface CfLogic {
key: string | null;
name: string;
resultUnit: string;
boxes: CfBox[];
links: CfLink[];
userElements: CfUserElement[];
}
/** 알약 하나의 모습 */
export interface CfPill {
name: string;
unit: string;
}
const TEXT = {
Input: ["입력", "Input"],
Func: ["함수", "Function"],
Output: ["출력", "Output"],
Result: ["결과", "Result"],
Value: ["값", "Value"],
NoPill: ["알약이 없음", "No such pill"],
FromOutput: ["출력 상자에서는 선이 나가지 않음", "Lines can't leave an output box"],
ToInput: ["입력 상자로는 선이 들어가지 않음", "Lines can't enter an input box"],
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
Taken: ["이미 선이 들어온 자리", "This spot already has a line"],
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 종류별 기본 색 */
export const BOX_COLOR: Record<CfKind, string> = {
input: "#2f80ed",
function: "#7b4fd6",
output: "#27ae60",
};
/** 알약 이름 규칙(브레인 정함) — 화면 말이 아니라 열쇠라 바꾸지 않음 */
export const OUTPUT_PORT = "값";
export const TABLE_PORT = "표";
const clone = <T>(v: T): T => structuredClone(v);
/** 다음 번호 — 있는 id(「b3」)보다 하나 큰 것 */
function nextId(prefix: string, ids: string[]): string {
const nums = ids
.filter((x) => x.startsWith(prefix))
.map((x) => Number(x.slice(prefix.length)))
.filter((n) => Number.isInteger(n));
return `${prefix}${Math.max(0, ...nums) + 1}`;
}
/** 같은 이름이면 「 2」 「 3」 */
function uniqueName(used: string[], base: string): string {
if (!used.includes(base)) return base;
for (let n = 2; ; n++) if (!used.includes(`${base} ${n}`)) return `${base} ${n}`;
}
const boxOf = (lg: CfLogic, id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id);
/** 상자 하나 고치기 — 나머지는 그대로(받은 것은 안 바꿈) */
export function updateBox(
lg: CfLogic,
id: string,
patch: Partial<Omit<CfBox, "id" | "kind">>,
): CfLogic {
return {
...lg,
boxes: lg.boxes.map((b) => (b.id === id ? { ...clone(b), ...clone(patch) } : b)),
};
}
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
export function newLogic(name = "", resultUnit = "원/㎡"): CfLogic {
return { key: null, name, resultUnit, boxes: [], links: [], userElements: [] };
}
/** 상자 더하기 — 종류별 기본 색 · 단위 「-」 · 함수는 빈 시트 하나(A1 결과 · B1 출력 칸) · 출력은 노무비 */
export function addBox(
lg: CfLogic,
kind: CfKind,
x: number,
y: number,
): { lg: CfLogic; box: CfBox } {
const id = nextId(
"b",
lg.boxes.map((b) => b.id),
);
const base = tl(kind === "input" ? "Input" : kind === "function" ? "Func" : "Output");
const name = uniqueName(
lg.boxes.map((b) => b.name),
base,
);
const box: CfBox = { id, kind, name, color: BOX_COLOR[kind], unit: "-", x, y };
if (kind === "input") box.input = { mode: "variable", elements: [], variables: [] };
if (kind === "function") {
// 출력 이름은 로직 안에서 겹치지 않게 「결과 1」 「결과 2」 … — 다른 함수 상자의 받는 알약과 섞이지 않음
const taken = lg.boxes.flatMap((b) => (b.func ? [b.func.output.name] : []));
let n = 1;
while (taken.includes(`${tl("Result")} ${n}`)) n++;
const out = `${tl("Result")} ${n}`;
box.func = {
sheet: { id: `s-${id}`, 이름: name, 칸: { A1: { 값: out } } },
inputs: [],
variables: [],
output: { cell: "B1", name: out, unit: "-" },
};
}
if (kind === "output") box.output = { cost: "노무비", unit: "-" };
return { lg: { ...lg, boxes: [...lg.boxes, box] }, box };
}
/** 상자 지우기 — 이어진 선도 지움 */
export function removeBox(lg: CfLogic, id: string): CfLogic {
// 선을 하나씩 끊어 받는 쪽 함수 상자의 칸 없는 알약도 정리
let next = lg;
for (const l of lg.links) if (l.from.box === id || l.to.box === id) next = unlink(next, l.id);
return { ...next, boxes: next.boxes.filter((b) => b.id !== id) };
}
export function moveBox(lg: CfLogic, id: string, x: number, y: number): CfLogic {
return updateBox(lg, id, { x, y });
}
export function setColor(lg: CfLogic, id: string, color: string): CfLogic {
return updateBox(lg, id, { color });
}
/** 단위 — 상자 오른쪽 위 = 그 상자 출력의 단위 · 함수 · 출력 상자는 안쪽 단위도 같이 */
export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic {
const box = boxOf(lg, id);
if (!box) return lg;
const u = unit.trim() || "-";
const patch: Partial<CfBox> = { unit: u };
if (box.func) patch.func = { ...box.func, output: { ...box.func.output, unit: u } };
if (box.output) patch.output = { ...box.output, unit: u };
return updateBox(lg, id, patch);
}
/* ── 알약 ───────────────────────────────────────────────────────────────── */
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name
* 함수 = func.output.name 하나 · 출력 = 없음 */
export function portsOut(box: CfBox): CfPill[] {
if (box.kind === "function" && box.func)
return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }];
if (box.kind !== "input" || !box.input) return [];
const inp = box.input;
return [
...(inp.mode === "group"
? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" }))
: []),
...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []),
...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })),
];
}
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */
export function portsIn(box: CfBox): CfPill[] {
if (box.kind === "function" && box.func)
return box.func.inputs.map((i) => ({ name: i.port, unit: "-" }));
if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }];
return [];
}
/* ── 선 ─────────────────────────────────────────────────────────────────── */
/** from 상자에서 선을 따라 to 상자에 닿나 */
export function reaches(lg: CfLogic, from: string, to: string): boolean {
const seen = new Set<string>();
const walk = (id: string): boolean => {
if (id === to) return true;
if (seen.has(id)) return false;
seen.add(id);
return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box));
};
return walk(from);
}
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음
* 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 ·
* 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */
export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } {
const a = boxOf(lg, from.box);
const b = boxOf(lg, to.box);
if (!a || !b) return { lg, why: tl("NoPill") };
if (a.kind === "output") return { lg, why: tl("FromOutput") };
if (b.kind === "input") return { lg, why: tl("ToInput") };
if (a.id === b.id) return { lg, why: tl("Self") };
if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") };
const has = portsIn(b).some((p) => p.name === to.name);
if (!has && b.kind !== "function") return { lg, why: tl("NoPill") };
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
return { lg, why: tl("Taken") };
let next = lg;
let port = to.name;
if (!has) {
const used = portsIn(b).map((p) => p.name);
const base = to.name.trim() || from.name;
port = used.includes(base) ? uniqueSuffix(used, base) : base;
const func = b.func!;
next = updateBox(lg, b.id, { func: { ...func, inputs: [...func.inputs, { port, cell: "" }] } });
}
const id = nextId(
"l",
next.links.map((l) => l.id),
);
return {
lg: {
...next,
links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }],
},
why: null,
};
}
/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */
function uniqueSuffix(used: string[], base: string): string {
for (let n = 2; ; n++) if (!used.includes(`${base} (${n})`)) return `${base} (${n})`;
}
/** 선 끊기 — 받는 쪽이 함수 상자이고 그 받는 알약이 칸에 안 놓였으면(cell 빈 글) 알약도 같이 지움 */
export function unlink(lg: CfLogic, id: string): CfLogic {
const gone = lg.links.find((l) => l.id === id);
const next = { ...lg, links: lg.links.filter((l) => l.id !== id) };
const box = gone && boxOf(next, gone.to.box);
if (!gone || !box?.func) return next;
const port = box.func.inputs.find((i) => i.port === gone.to.name);
const still = next.links.some((l) => l.to.box === box.id && l.to.name === gone.to.name);
if (!port || port.cell.trim() || still) return next;
const inputs = box.func.inputs.filter((i) => i !== port);
return updateBox(next, box.id, { func: { ...box.func, inputs } });
}
@@ -0,0 +1,85 @@
.m02-dfout__title {
color: var(--color-text-body);
font-size: var(--text-body);
}
.m02-dfout__body {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--spacing-16);
margin: var(--spacing-8) 0;
}
.m02-dfout__got {
min-width: 220px;
color: var(--color-text-body);
}
.m02-dfout__got dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-4) var(--spacing-8);
margin: var(--spacing-4) 0 0;
font-size: var(--text-body-sm);
}
.m02-dfout__got dt {
color: var(--color-text-secondary);
}
.m02-dfout__got dd {
margin: 0;
}
.m02-dfout__num {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m02-dfout__fields {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-8) var(--spacing-16);
}
.m02-dfout__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-dfout__input {
box-sizing: border-box;
width: 160px;
max-width: 100%;
height: var(--control-height-sm);
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m02-dfout__warn {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-body-sm);
}
.m02-dfout__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m02-dfout__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
@@ -0,0 +1,195 @@
/* =============================================================================
* M02_DF_OutputPanel.ts
* 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸
* 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSelectField, el } from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval";
import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_DF_Model";
import "./M02_DF_OutputPanel.css";
const TEXT = {
Title: ["출력", "Output"],
Name: ["이름", "Name"],
Cost: ["비용 종류", "Cost kind"],
Labor: ["노무비", "Labor cost"],
Material: ["재료비", "Material cost"],
Expense: ["경비", "Expense"],
Plain: ["값(비용 아님)", "Value (not a cost)"],
Unit: ["단위", "Unit"],
UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"],
Got: ["받은 값", "Received value"],
From: ["어디서", "From"],
Value: ["값", "Value"],
NoLink: [
"아직 이어진 선이 없어요 — 입력이나 함수 상자에서 이 상자로 선을 이어 주세요",
"Nothing is linked yet — draw a line from an input or function box",
],
Table: ["표", "Table"],
Calc: ["계산 중이 아니에요", "Not calculated"],
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
PlainHint: [
"원가 합계에 안 넣고 값으로만 보여요",
"Shown as a value, not added to the cost total",
],
} as const;
type TextKey = keyof typeof TEXT;
const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const COSTS: [CfCost, TextKey][] = [
["노무비", "Labor"],
["재료비", "Material"],
["경비", "Expense"],
["값", "Plain"],
];
/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */
export function showValue(v: CfValue | undefined, money = false): string {
if (v === undefined || v === null) return "—";
if (Array.isArray(v)) return to("Table");
if (typeof v === "number")
return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
return String(v);
}
const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({
...lg,
boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)),
});
/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */
export function renderOutputPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
opts: { result?: CfEvalResult } = {},
): void {
const box = lg.boxes.find((b) => b.id === boxId);
if (!box || box.kind !== "output") {
host.replaceChildren();
return;
}
const msg = el("p", { className: "m02-dfout__msg" });
const emit = (make: () => CfLogic): void => {
msg.textContent = "";
try {
on.change(make());
} catch {
msg.textContent = to("Cant");
}
};
const cost = box.output?.cost ?? "노무비";
/* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */
const link = lg.links.find((l) => l.to.box === box.id);
const from = link && lg.boxes.find((b) => b.id === link.from.box);
let result = opts.result;
if (!result && link) {
try {
result = evalLogic(lg);
} catch {
result = undefined;
}
}
let unit = "";
if (from && link) {
try {
unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit;
} catch {
unit = from.unit;
}
}
const got = el("div", { className: "m02-dfout__got" });
if (link && from) {
const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id];
got.replaceChildren(
el("strong", { text: to("Got") }),
el("dl", {
children: [
el("dt", { text: to("From") }),
el("dd", { text: `${from.name} · ${link.from.name}` }),
el("dt", { text: to("Value") }),
el("dd", {
className: "m02-dfout__num",
text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`,
}),
],
}),
);
} else {
got.replaceChildren(
el("strong", { text: to("Got") }),
el("p", { className: "m02-dfout__warn", text: to("NoLink") }),
);
}
/* 이름 · 비용 종류 · 단위 */
const nameBox = el("input", {
className: "m02-dfout__input",
attrs: { type: "text", "aria-label": to("Name") },
});
nameBox.value = box.name;
nameBox.addEventListener("change", () => {
const next = nameBox.value.trim();
if (!next || next === box.name) {
nameBox.value = box.name;
return;
}
emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next })));
});
const costField = createSelectField({
label: to("Cost"),
options: COSTS.map(([value, key]) => ({ value, text: to(key) })),
value: cost,
compact: true,
onChange: (v) =>
emit(() =>
withBox(lg, box.id, (b) => ({
...b,
output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost },
})),
),
});
const unitBox = el("input", {
className: "m02-dfout__input",
attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") },
});
unitBox.value = box.output?.unit ?? box.unit;
unitBox.addEventListener("change", () => {
const next = unitBox.value.trim() || "-";
emit(() =>
withBox(lg, box.id, (b) => ({
...b,
unit: next,
output: { cost: b.output?.cost ?? "노무비", unit: next },
})),
);
});
const field = (label: string, control: HTMLElement): HTMLElement =>
el("label", {
className: "m02-dfout__field",
children: [el("span", { text: label }), control],
});
host.replaceChildren(
el("strong", { className: "m02-dfout__title", text: `${to("Title")} · ${box.name}` }),
el("div", {
className: "m02-dfout__body",
children: [
got,
el("div", {
className: "m02-dfout__fields",
children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)],
}),
],
}),
...(cost === "값" ? [el("p", { className: "m02-dfout__muted", text: to("PlainHint") })] : []),
msg,
);
}
@@ -0,0 +1,664 @@
"""단가산출 다이어그램 시험 길(PLAN 35-7) — 목록 · 열기 · 저장 · 지우기 · 입력 값 · 사용자 요소.
GET /api/m02/df/list → {logics: [{key, name, resultUnit, saved, sample}]}
GET /api/m02/df/get?key=DF-0001 → {logic, sample}
POST /api/m02/df/save {logic} → {key, logic, values: {ports, outputs, cost, errors}}
POST /api/m02/df/delete {key}
POST /api/m02/df/values {items, userElements?} → {values: CfAnswer[]}
GET /api/m02/df/user-elements → {elements}
POST /api/m02/df/user-elements {op: add | delete, element? | id?}
모양 = sub7 Model `M02_DF_Model.ts`
(CfLogic = key · name · resultUnit · boxes · links · userElements).
알약 이름(브레인 정함): 입력 상자가 내보내는 알약 = 요소 name · 표 「표」 · 변수 name /
함수 상자 받는 알약 = inputs[].port · 내보내는 알약 = output.name / 출력 상자 받는 알약 = 「값」.
저장 = 입력 값(노임 · 재료 · 유가전력 · 기계 · 환율 · 요율 · 소요량 / 계수 표 · 사용자 요소)을
마스터(사용자 요소는 다이어그램 안 · `_data`)에서 다시 받아 → 함수 상자 시트들을 한 통합문서로
묶어(받는 칸에 값 · 함수 → 함수는 시트 사이 참조) 우리 스프레드시트 엔진(Node ·
`common_util_spreadsheet`)으로 셈 → 받는 칸에 값만 되돌려 넣어 `_data` 에 씀 · 브라우저 값은
안 받아 적음. 단가는 기존 `/calc` 와 같은 셈.
자리 = 이 폴더 `_data/`(git 밖) · 견본 `_sample/`(git 안 · 읽기만 · 저장하면 새 번호) ·
키 `DF-0001` · 사용자 요소 `UE-0001` · 마스터 파일은 안 씀. 권한 = 시스템 관리자(`main.py` 한 줄).
옛 시험판(SheetLogic) 서버를 옮겨 고쳐 씀 — 그 폴더를 import 하지 않음.
정함 = `docs/raw/M01_쉬운단가/원가다이어그램_정함.md`.
"""
from __future__ import annotations
import copy
import json
import os
import re
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from common_util import common_util_spreadsheet as spreadsheet
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.M01_MasterData_Router import _num
cm = store.cm
router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow (시험)"])
HERE = Path(__file__).resolve().parent
DATA = HERE / "_data" # 시험은 임시 폴더로 바꿈
SAMPLE = HERE / "_sample"
USERS = "_user_elements.json" # 사용자 요소 공용 목록 — `_data` 안
_KEY = re.compile(r"^DF-(\d{4,})$")
_UKEY = re.compile(r"^UE-(\d{4,})$")
_SAMPLE_KEY = re.compile(r"^[\w가-힣·() -]+$")
_CELL = re.compile(r"^([A-Z]+)(\d+)$")
RANGE_LOW, RANGE_HIGH = "·아래", "·위"
FIELDS = (*cm.mf.COST_ITEMS, "계")
GROUPS = ("인력", "재료", "유가전력", "기계", "소요량", "계수", "환율", "요율")
TABLE_PORT, OUTPUT_PORT = "표", "값"
COSTS = ("노무비", "재료비", "경비")
class LogicBody(BaseModel):
logic: dict[str, Any]
class KeyBody(BaseModel):
key: str
class ValuesBody(BaseModel):
items: list[dict[str, Any]]
userElements: list[dict[str, Any]] = []
class UserBody(BaseModel):
op: str
element: dict[str, Any] | None = None
id: str | None = None
# ── 자리 ──────────────────────────────────────────────────────────────
def _write(path: Path, data: Any) -> None:
DATA.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f".{path.name}.tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", newline="\n")
os.replace(tmp, path)
def _mine(key: str) -> Path:
if not _KEY.match(key or ""):
raise store.StoreError(400, f"다이어그램 번호 모양이 아님 「{key}」")
return DATA / f"{key}.json"
def _sample(key: str) -> Path | None:
if not key or _KEY.match(key) or not _SAMPLE_KEY.match(key):
return None
path = SAMPLE / f"{key}.json"
return path if path.is_file() else None
def _read(key: str) -> tuple[dict, bool]:
"""(다이어그램, 견본인가) — 내 것은 `_data` · 견본은 `_sample`."""
path = _sample(key)
if path is not None:
return json.loads(path.read_text(encoding="utf-8")), True
path = _mine(key)
if not path.is_file():
raise store.StoreError(404, f"없는 다이어그램 「{key}」")
return json.loads(path.read_text(encoding="utf-8")), False
def listing() -> dict:
out = []
for path, sample in [
*((p, False) for p in sorted(DATA.glob("DF-*.json"))),
*((p, True) for p in sorted(SAMPLE.glob("*.json"))),
]:
logic = json.loads(path.read_text(encoding="utf-8"))
out.append(
{
"key": path.stem,
"name": logic.get("name") or path.stem,
"resultUnit": logic.get("resultUnit") or "",
"saved": logic.get("saved") or "",
"sample": sample,
}
)
return {"logics": out}
def get(key: str) -> dict:
logic, sample = _read(key)
return {"logic": {**logic, "key": key}, "sample": sample}
def delete(key: str) -> dict:
if _sample(key) is not None:
raise store.StoreError(403, "견본은 지울 수 없음")
_read(key)
_mine(key).unlink()
return {"key": key}
# ── 사용자 요소(Model CfUserElement — id · group · name · spec · unit · value) ─────
def _users() -> list[dict]:
path = DATA / USERS
return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else []
def user_elements() -> dict:
return {"elements": _users()}
def user_edit(op: str, element: dict | None, uid: str | None) -> dict:
rows = _users()
if op == "delete":
if not any(r["id"] == uid for r in rows):
raise store.StoreError(404, f"없는 사용자 요소 「{uid}」")
_write(DATA / USERS, [r for r in rows if r["id"] != uid])
return {"id": uid}
if op != "add" or not isinstance(element, dict):
raise store.StoreError(400, "op 는 add(element) 또는 delete(id)")
name = str(element.get("name") or "").strip()
if not name:
raise store.StoreError(400, "이름을 넣어 주세요")
group = element.get("group") or "재료"
if group not in GROUPS:
raise store.StoreError(400, f"모르는 그룹 「{group}」")
value = element.get("value")
if value is not None and (isinstance(value, bool) or not isinstance(value, (int, float))):
raise store.StoreError(400, "값은 수 또는 빈 값")
used = [int(m.group(1)) for r in rows if (m := _UKEY.match(r["id"]))]
one = {
"id": f"UE-{max(used, default=0) + 1:04d}",
"group": group,
"name": name,
"spec": str(element.get("spec") or ""),
"unit": str(element.get("unit") or ""),
"value": value,
}
_write(DATA / USERS, [*rows, one])
return {"element": one}
# ── 입력 값 ────────────────────────────────────────────────────────────
def _row(line: dict, names) -> dict:
"""단가를 `/calc` 와 같은 셈으로 — 호표 한 줄(수량 1)짜리 저장 안 하는 견본 로직."""
return {
"키": "",
"원문번호": "",
"이름": "입력 값",
"결과단위": "원/식",
"출처": "자체",
"입력": [{"이름": n} for n in names],
"중간": [],
"호표": [line],
"덧줄": [],
"끝수": None,
}
def _money(got: dict, field: str | None) -> dict:
if not got["ok"]:
return got
if "result" in got:
return {"ok": True, "value": got["result"]}
pick = field or "계"
if pick not in FIELDS:
return {"ok": False, "reason": f"모르는 칸 「{pick}」"}
return {"ok": True, "value": got["sums"][pick]}
def _pick(key: Any) -> dict | None:
"""재료 검색어 묶음 `{구분, 상세구분, 검색어, 규격?}` — 묶음 · 그 JSON 글 · 아니면 None."""
if isinstance(key, dict):
return key
if isinstance(key, str) and key.lstrip().startswith("{"):
try:
got = json.loads(key)
except ValueError:
return None
return got if isinstance(got, dict) else None
return None
def _material(cond: dict, unit: str) -> dict:
"""검색어 묶음 재료 단가 — 기존 로직 재료 줄과 같은 길(검색어 · 자동값 차례 · 단위 환산)."""
line = {"종류": "재료", "요소": cond, "이름": cond.get("검색어") or "재료", "수량": "1"}
got = store.calc("", {}, _row({**line, "단위": unit, "비목": "재료비"}, ()), None)
if not got["ok"]:
return got
one = got["lines"][0]
if one["단가"] is None:
word = cond.get("검색어") or ""
empty = "값 없음" in str(one.get("까닭") or "")
why = (
"단가가 자료에 비어 있음 — 단가를 채우거나 다른 품목을 고름"
if empty
else "맞는 재료 없음"
)
return {"ok": False, "reason": f"「{word}」 {why}"}
picked = one.get("품목") or {}
return {"ok": True, "value": one["단가"], "name": picked.get("이름"), "unit": unit or None}
def _brief(key: str, want: str) -> tuple[dict | None, str | None]:
"""마스터 줄 하나 + 그룹 확인 — (줄, 까닭) · 유가전력은 재료 그룹 안 MO 키."""
try:
row = store.loaded()[1].get(key)
except cm.mf.FormulaError as e:
return None, str(e)
group = store.mk.GROUP_OF.get(store.mk.id_of(key))
if not (group == want or (want == "유가전력" and key.startswith("MO"))):
return None, f"「{key}」 는 {want} 아님"
return row, None
def _price(group: str, key: str, unit: str = "") -> dict:
"""인력 노임 · 재료 단가 — 준용 · 낮은 값 · 출처는 엔진 그대로."""
row, why = _brief(key, group)
if row is None:
return {"ok": False, "reason": why}
kind, cost = {"인력": ("인력", "노무비"), "재료": ("재료", "재료비")}[group]
line = {"종류": kind, "요소": key, "이름": row.get("이름") or key, "수량": "1"}
line = {**line, "단위": unit or row.get("단위") or "", "비목": cost}
got = store.calc("", {}, _row(line, ()), None)
if not got["ok"]:
return got
one = got["lines"][0]
out = {"ok": one["단가"] is not None, "value": one["단가"], "name": row.get("이름")}
return {**out, "unit": row.get("단위"), **({"reason": one["까닭"]} if one.get("까닭") else {})}
def _plain_value(group: str, key: str, field: str | None) -> dict:
"""유가전력 · 환율 · 요율 · 기계 값 칸 — 그 줄 값(묶음이면 field · 요율 기본 rate_percent)."""
row, why = _brief(key, group)
if row is None:
return {"ok": False, "reason": why}
value, unit = row.get("값"), row.get("단위")
if isinstance(value, dict):
pick = field or ("rate_percent" if group == "요율" else "")
if pick not in value:
return {
"ok": False,
"reason": f"「{row.get('이름')}」 에서 쓸 칸을 고름 — {', '.join(value)}",
}
value = value[pick]
unit = (
unit.get(pick) if isinstance(unit, dict) else ("%" if pick == "rate_percent" else unit)
)
if value is None:
return {"ok": False, "reason": f"「{row.get('이름')}」 값이 자료에 비어 있음"}
return {"ok": True, "value": value, "name": row.get("이름"), "unit": unit}
def _table(key: str, at: Any = None) -> dict:
"""소요량 · 계수 표 → 머리 줄 + 줄(조건 칸 → 값 칸 · 범위가 있는 칸은 「·아래」 「·위」 두 칸 ·
그 칸에 수 하나면 아래 · 위 같은 수) · at = 줄 차례(0부터)면 머리 줄 + 그 줄 하나."""
files = store.loaded()[0]
table = next(
(
t
for d in files.values()
if d.get("그룹") in cm.mf.TABLE_GROUPS
for t in d.get("표") or []
if str(t.get("키")) == key
),
None,
)
if table is None:
return {"ok": False, "reason": f"없는 표 「{key}」"}
conds, lines = table.get("조건") or {}, table.get("줄") or []
names = [*conds, *(table.get("값칸") or {})]
split = {
n for n in names if conds.get(n) == "범위" or any(isinstance(r.get(n), list) for r in lines)
}
head = [h for n in names for h in ((n + RANGE_LOW, n + RANGE_HIGH) if n in split else (n,))]
if at is not None:
if not isinstance(at, int) or not 0 <= at < len(lines):
return {"ok": False, "reason": f"「{table.get('이름')}」 에 {at} 번째 줄 없음"}
lines = [lines[at]]
rows = []
for r in lines:
line = []
for n in names:
v = r.get(n)
if n in split:
line += list(v) if isinstance(v, list) else [v, v]
else:
line.append(None if v == "" else v if not isinstance(v, dict) else str(v))
rows.append(line)
return {"ok": True, "rows": [head, *rows], "name": table.get("이름"), "unit": table.get("기준")}
def _user(uid: str, local: list[dict]) -> dict:
one = next((r for r in [*local, *_users()] if r.get("id") == uid), None)
if one is None:
return {"ok": False, "reason": f"없는 사용자 요소 「{uid}」"}
if one.get("value") is None:
return {"ok": False, "reason": f"「{one.get('name')}」 값이 비어 있음"}
return {"ok": True, "value": one["value"], "name": one.get("name"), "unit": one.get("unit")}
MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」
HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝)
_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용
def _machine_family() -> list[dict]:
"""기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직."""
files = store.loaded()[0]
return [
row
for name, data in files.items()
if data.get("그룹") == "로직" and "건설기계" in name
for row in data.get("줄") or []
if str(row.get("결과단위") or "").startswith("원")
and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or [])
]
def _machine_rank(row: dict) -> tuple:
"""짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키."""
names = [i.get("이름") for i in row.get("입력") or []]
codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계")
attach = any(n not in _MACHINE_PLAIN for n in names)
loss = "지역" not in names
return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키")))
def _machine_hourly(key: str, inputs: dict) -> dict:
"""기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기."""
row, why = _brief(key, "기계")
if row is None:
return {"ok": False, "reason": why}
code = str(row.get("원문번호") or "")
fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]]
if not fit:
return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"}
logic = min(fit, key=_machine_rank)
given = {}
for spec in logic.get("입력") or []:
name = spec.get("이름")
if name != "기계":
given[name] = inputs.get(name, (spec.get("고르기") or [None])[0])
args = "".join(f", {n}='{v}'" for n, v in given.items())
ref = f"로직({logic['키']}, 기계='{code}'{args})"
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(store.calc("", {}, _row(line, ()), None), "계")
return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got
def _codes(row: dict) -> list:
return next(
(i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), []
)
def one_value(item: dict, local: list[dict] | None = None) -> dict:
"""물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`.
기계 ref 「EQ키#칸」(입력 판 요소 ref)도 받음 · 칸 「시간당 경비」 = 기계경비 로직으로 셈.
"""
group, ref = item.get("group"), item.get("ref")
table, field = item.get("table") or {}, item.get("field")
if group == "기계" and isinstance(ref, str) and MACHINE_SEP in ref:
ref, _, field = ref.partition(MACHINE_SEP)
try:
if isinstance(ref, str) and _UKEY.match(ref):
got = _user(ref, local or [])
elif group in ("소요량", "계수"):
got = _table(str(table.get("key") or ref or ""), table.get("row"))
elif group == "재료" and _pick(ref) is not None:
got = _material(_pick(ref), str(item.get("unit") or ""))
elif group in ("인력", "재료"):
got = _price(group, str(ref or ""), str(item.get("unit") or ""))
elif group == "기계" and str(ref or "").startswith("로직("):
inputs = item.get("inputs") or {}
line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"}
got = _money(store.calc("", inputs, _row(line, inputs), None), field)
elif group == "기계" and field == HOURLY:
got = _machine_hourly(str(ref or ""), item.get("inputs") or {})
elif group in ("기계", "유가전력", "환율", "요율"):
got = _plain_value(group, str(ref or ""), field)
else:
got = {"ok": False, "reason": f"모르는 그룹 「{group}」"}
except store.StoreError as e:
got = {"ok": False, "reason": str(e.detail)}
return {"id": item.get("id") or "", **got}
def values(items: list[dict], local: list[dict]) -> dict:
# ponytail: 하나씩 셈(단가 하나 0.3초쯤) — 입력 값이 수십이면 마스터 사본 한 벌로 묶어 셈
return {"values": [one_value(i, local) for i in items]}
# ── 저장 · 계산 ────────────────────────────────────────────────────────
def _col(letters: str) -> int:
n = 0
for ch in letters:
n = n * 26 + ord(ch) - 64
return n
def _letters(n: int) -> str:
out = ""
while n:
n, rest = divmod(n - 1, 26)
out = chr(65 + rest) + out
return out
def _style(cells: dict, cell: str) -> dict:
return {"서식": cells[cell]["서식"]} if "서식" in (cells.get(cell) or {}) else {}
def _put(cells: dict, cell: str, value: Any) -> None:
"""받는 칸 하나 — 값 칸으로 덮어씀(수식이 있었어도) · 빈 값은 값만 뺌(서식은 둠)."""
keep = _style(cells, cell)
if value is None and not keep:
cells.pop(cell, None)
else:
cells[cell] = {**({"값": value} if value is not None else {}), **keep}
def _place(cells: dict, cell: str, value: Any) -> None:
"""값 하나 · 표 블록(cell 이 왼위) · 수식 `{"식": …}` 을 받는 칸에."""
m = _CELL.match(str(cell or "").upper())
if not m:
raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」")
col, row, at = _col(m.group(1)), int(m.group(2)), f"{m.group(1)}{m.group(2)}"
if isinstance(value, dict) and "식" in value:
cells[at] = {**value, **_style(cells, at)}
elif isinstance(value, list):
for dy, line in enumerate(value):
for dx, one in enumerate(line):
_put(cells, f"{_letters(col + dx)}{row + dy}", one)
else:
_put(cells, at, value)
def _plain(v: Any) -> Any:
"""엔진 계산값 한 칸 → 수 · 글 · 참거짓 · 빈 값(오류는 None)."""
if not isinstance(v, dict):
return v
if "수" in v:
return Decimal(str(v["수"]))
if "글" in v:
return v["글"]
if "참" in v:
return v["참"]
return None
def _source(links: list[dict], box: str, port: str) -> dict | None:
return next((ln["from"] for ln in links if ln.get("to") == {"box": box, "name": port}), None)
def _input_ports(box: dict, local: list[dict], errors: list[str]) -> dict[str, Any]:
"""입력 상자 알약 값 — 요소 name · 표 「표」 · 변수 name(예시값)."""
inp, out = box.get("input") or {}, {}
if inp.get("mode") == "group":
group = inp.get("group")
asks = [
(el.get("name"), {"ref": el.get("ref"), "unit": el.get("unit")})
for el in inp.get("elements") or []
]
if inp.get("table"):
asks.append((TABLE_PORT, {"table": inp["table"]}))
for name, ask in asks:
got = one_value({"group": group, **ask}, local)
out[name] = (got.get("rows") or got.get("value")) if got["ok"] else None
if not got["ok"]:
errors.append(f"「{box.get('name')} · {name}」 {got.get('reason')}")
for v in inp.get("variables") or []:
out[v.get("name")] = v.get("example")
return out
def compute(logic: dict, local: list[dict]) -> tuple[dict, dict]:
"""함수 상자 시트를 한 통합문서로 셈 → (값을 되돌려 넣은 다이어그램, 계산 결과)."""
boxes = {b.get("id"): b for b in logic.get("boxes") or []}
links = logic.get("links") or []
errors: list[str] = []
ports = {
i: _input_ports(b, local, errors) for i, b in boxes.items() if b.get("kind") == "input"
}
funcs = [b for b in boxes.values() if b.get("kind") == "function" and b.get("func")]
sheets, fills = [], []
for b in funcs: # 한 통합문서 — 시트 id · 이름 = 상자 id
f = b["func"]
sheet = {**copy.deepcopy(f.get("sheet") or {}), "id": b["id"], "이름": b["id"]}
cells = sheet.setdefault("칸", {})
for v in f.get("variables") or []:
if v.get("cell"):
_place(cells, v["cell"], v.get("example"))
for p in f.get("inputs") or []:
src = _source(links, b["id"], p.get("port"))
if src is None or src.get("box") not in boxes:
continue
up = boxes[src["box"]]
if up.get("kind") == "function":
at = ((up.get("func") or {}).get("output") or {}).get("cell") or ""
_place(cells, p["cell"], {"식": f"'{up['id']}'!{at}"})
fills.append((b["id"], p["cell"], ("ref", up["id"], at)))
else:
value = ports.get(up["id"], {}).get(src.get("name"))
_place(cells, p["cell"], value)
fills.append((b["id"], p["cell"], ("value", value, None)))
sheets.append(sheet)
top = [c.get("서식", 0) for s in sheets for c in s["칸"].values() if isinstance(c, dict)]
book = _num(
{"종류": "통합문서", "판": 1, "서식": [{}] * (int(max(top, default=0)) + 1), "시트": sheets}
)
try:
calc = spreadsheet.recalc_workbooks([book])[0] if sheets else {}
except spreadsheet.RecalcError as e:
raise store.StoreError(503, str(e)) from e
def cell_value(box_id: str, cell: str) -> Any:
hit = (calc.get(box_id) or {}).get(cell)
if hit is None:
sheet = next(s for s in book["시트"] if s["id"] == box_id)
return (sheet["칸"].get(cell) or {}).get("값")
if isinstance(hit, dict) and "오류" in hit:
errors.append(f"「{boxes[box_id].get('name')}」 {cell} 칸 계산 오류 {hit.get('오류')}")
return _plain(hit)
for b in funcs:
out = b["func"].get("output") or {}
ports[b["id"]] = {out.get("name"): cell_value(b["id"], out.get("cell") or "")}
outputs, cost = {}, {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}}
for b in boxes.values(): # 출력 상자 · 원가
if b.get("kind") != "output":
continue
src = _source(links, b["id"], OUTPUT_PORT)
value = ports.get(src["box"], {}).get(src.get("name")) if src else None
outputs[b["id"]] = value
kind = (b.get("output") or {}).get("cost")
if (
kind in COSTS
and isinstance(value, (int, float, Decimal))
and not isinstance(value, bool)
):
cost[kind] += Decimal(str(value))
elif kind == "값":
cost["값들"][b.get("name")] = value
cost["계"] = sum(cost[k] for k in COSTS)
done = copy.deepcopy(logic) # 받는 칸에 값만 되돌려 넣음 — 시트 사이 참조는 셈한 값으로
for b in done.get("boxes") or []:
if b.get("kind") != "function" or not b.get("func"):
continue
cells = b["func"].setdefault("sheet", {}).setdefault("칸", {})
for v in b["func"].get("variables") or []:
if v.get("cell"):
_place(cells, v["cell"], v.get("example"))
for box_id, cell, (how, a, at) in fills:
if box_id == b["id"]:
_place(cells, cell, cell_value(a, at) if how == "ref" else a)
return done, {"ports": ports, "outputs": outputs, "cost": cost, "errors": errors}
def save(logic: dict) -> dict:
"""저장 — 입력 값 다시 받기 → 함수 상자 시트를 Node 로 셈 → 값만 되돌려 넣어 `_data`."""
if not str(logic.get("name") or "").strip():
raise store.StoreError(400, "이름을 넣어 주세요")
if not isinstance(logic.get("boxes"), list) or not isinstance(logic.get("links"), list):
raise store.StoreError(400, "상자(boxes) · 선(links) 이 없음")
key = logic.get("key") or None
if key and _sample(key) is not None:
key = None # 견본은 그대로 두고 새 번호로
elif key:
_read(key) # 있는 것만 고침 — 없으면 404
done, result = compute(logic, logic.get("userElements") or [])
if not key:
used = [int(m.group(1)) for p in DATA.glob("DF-*.json") if (m := _KEY.match(p.stem))]
key = f"DF-{max(used, default=0) + 1:04d}"
done = {**done, "key": key, "saved": datetime.now().isoformat(timespec="seconds")}
_write(DATA / f"{key}.json", _num(done))
return {"key": key, "logic": done, "values": result}
# ── 길 ────────────────────────────────────────────────────────────────
def _call(fn, *args):
try:
return _num(fn(*args))
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.get("/list")
def get_list() -> dict:
return _call(listing)
@router.get("/get")
def get_one(key: str) -> dict:
return _call(get, key)
@router.post("/save")
def post_save(body: LogicBody) -> dict:
return _call(save, body.logic)
@router.post("/delete")
def post_delete(body: KeyBody) -> dict:
return _call(delete, body.key)
@router.post("/values")
def post_values(body: ValuesBody) -> dict:
return _call(values, body.items, body.userElements)
@router.get("/user-elements")
def get_user_elements() -> dict:
return _call(user_elements)
@router.post("/user-elements")
def post_user_elements(body: UserBody) -> dict:
return _call(user_edit, body.op, body.element, body.id)
@@ -0,0 +1,65 @@
/* =============================================================================
* M02_DF_Sample.ts
* 단가산출 다이어그램 견본 — 처음 열었을 때 바로 보고 따라 만들 견본 셋(`_sample/*.json` · 손으로 짬)
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 함수(표 찾기 · 높이 할증 · 직렬 · 병렬) → 출력 노무비
* ② 공구손료(비율) — 노무비 × 율 → 출력 재료비
* ③ 덤프트럭 시간당 작업량(값) — 변수 넷 → 함수 하나 → 출력 값
* 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호).
* 입력 값(노임 · 표)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음(정함 「계산」).
* 모양 = `M02_DF_Model` CfLogic · 알약 이름 규칙(입력 = 요소 이름 · 「표」 · 변수 이름 / 함수 = inputs[].port ·
* output.name / 출력 = 「값」) — 정함 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 11. 이 폴더째 지워도 됨.
* ========================================================================== */
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",
],
rate: ["노무비 × 공구손료율 → 재료비", "Labor cost × small-tools rate → material cost"],
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", "rate", "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;
}
@@ -0,0 +1,132 @@
.m02-dfsum__big {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-8);
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
}
.m02-dfsum__big strong {
color: var(--color-text-body);
font-size: 28px;
font-variant-numeric: tabular-nums;
}
.m02-dfsum__costs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4) var(--spacing-16);
margin: 0 0 var(--spacing-12);
font-size: var(--text-body-sm);
}
.m02-dfsum__costs dt {
display: inline;
color: var(--color-text-secondary);
}
.m02-dfsum__costs dd {
display: inline;
margin: 0 0 0 var(--spacing-4);
color: var(--color-text-body);
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.m02-dfsum__block {
margin: 0 0 var(--spacing-12);
color: var(--color-text-body);
}
.m02-dfsum__scroll {
max-width: 100%;
overflow-x: auto;
}
.m02-dfsum__table {
width: 100%;
min-width: 360px;
margin-top: var(--spacing-4);
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m02-dfsum__table th,
.m02-dfsum__table td {
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
}
.m02-dfsum__table th {
background: var(--color-surface);
color: var(--color-text-secondary);
font-weight: 600;
white-space: nowrap;
}
.m02-dfsum__table td.m02-dfsum__num {
text-align: right;
font-variant-numeric: tabular-nums;
}
.m02-dfsum__strip {
margin: 0 0 var(--spacing-12);
color: var(--color-text-body);
}
.m02-dfsum__fields {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-16);
margin: var(--spacing-8) 0;
}
.m02-dfsum__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-dfsum__input {
box-sizing: border-box;
width: 140px;
height: var(--control-height-sm);
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
font-size: var(--text-body-sm);
}
.m02-dfsum__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m02-dfsum__msg {
margin: var(--spacing-4) 0 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m02-dfsum__problems {
margin: 0 0 var(--spacing-12);
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-danger);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m02-dfsum__problems ul {
margin: var(--spacing-4) 0 0;
padding-left: var(--spacing-16);
}
@@ -0,0 +1,279 @@
/* =============================================================================
* M02_DF_Summary.ts
* 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄
* · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈)
* 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌.
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSelectField, el } from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { checkLogic, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
import type { CfBox, CfLogic, CfVariable } from "./M02_DF_Model";
import { showValue } from "./M02_DF_OutputPanel";
import "./M02_DF_Summary.css";
const TEXT = {
Per: ["{per} 당", "per {per}"],
Total: ["원가 합계", "Cost total"],
Won: ["원", "KRW"],
Labor: ["노무비", "Labor cost"],
Material: ["재료비", "Material cost"],
Expense: ["경비", "Expense"],
Lines: ["출력 상자별", "By output box"],
HName: ["이름", "Name"],
HCost: ["비용 종류", "Cost kind"],
HValue: ["값", "Value"],
HUnit: ["단위", "Unit"],
Plain: ["값(비용 아님)", "Values (not costs)"],
Sample: ["예시값", "Sample values"],
SampleHint: [
"여기서 바꾸면 변수 상자의 예시값도 바뀌어요",
"Changing here changes the variable box too",
],
Problems: ["고칠 것", "To fix"],
NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"],
NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
Pick: ["고르세요", "Choose"],
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
Plain3: ["값", "Value"],
} as const;
type TextKey = keyof typeof TEXT;
const ts = (key: TextKey, fill: Record<string, string> = {}): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(
/\{(\w+)\}/g,
(m, k: string) => fill[k] ?? m,
);
const COSTS = ["노무비", "재료비", "경비"] as const;
const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const;
const costText = (c: string): string => (c === "값" ? ts("Plain3") : c);
/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */
function emptyPlaces(lg: CfLogic): string[] {
const outs = lg.boxes.filter((b) => b.kind === "output");
if (!outs.length) return [ts("NoOutput")];
return outs
.filter((o) => !lg.links.some((l) => l.to.box === o.id))
.map((o) => ts("NoLink", { box: o.name }));
}
/** 변수 상자의 예시값 칸 하나 */
function sampleField(
v: CfVariable,
put: (value: number | string) => void,
msg: HTMLElement,
): HTMLElement {
const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name });
let control: HTMLElement;
if (v.type === "choice" && v.choices?.length) {
control = createSelectField({
options: [
{ value: "", text: ts("Pick") },
...v.choices.map((c) => ({ value: String(c), text: String(c) })),
],
value: v.example === null ? "" : String(v.example),
compact: true,
ariaLabel: v.name,
onChange: (s) => {
const hit = v.choices?.find((c) => String(c) === s);
if (hit !== undefined) put(hit);
},
}).root;
} else {
const box = el("input", {
className: "m02-dfsum__input",
attrs: {
type: "text",
inputmode: v.type === "number" ? "decimal" : "text",
"aria-label": v.name,
},
});
if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) });
box.value = v.example === null ? "" : String(v.example);
box.addEventListener("change", () => {
const raw = box.value.trim();
if (raw === "") return;
if (v.type !== "number") return put(raw);
const n = Number(raw);
if (Number.isNaN(n)) msg.textContent = ts("Bad");
else put(n);
});
control = box;
}
return el("label", { className: "m02-dfsum__field", children: [label, control] });
}
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */
export function renderSummary(
host: HTMLElement,
lg: CfLogic,
result: CfEvalResult,
on?: { change(lg: CfLogic): void },
opts?: { values?: CfValues },
): void {
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
const perUnit = lg.resultUnit.split("/").slice(1).join("/");
const perText = !perUnit
? ts("Total")
: ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` });
const { cost } = result;
const big = el("p", {
className: "m02-dfsum__big",
children: [
el("span", { text: money ? perText : ts("Total") }),
el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }),
],
});
const costs = el("dl", {
className: "m02-dfsum__costs",
children: COSTS.flatMap((c) => [
el("dt", { text: ts(COST_KEY[c]) }),
el("dd", { text: formatMoney(cost[c]) }),
]),
});
/* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */
const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output");
const costed = outs.filter((b) => b.output?.cost !== "값");
const plain = outs.filter((b) => b.output?.cost === "값");
const line = (b: CfBox, isCost: boolean): HTMLElement =>
el("tr", {
children: [
el("td", { text: b.name }),
...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []),
el("td", {
className: "m02-dfsum__num",
text: showValue(result.outputs[b.id], isCost),
}),
el("td", { text: b.output?.unit ?? b.unit }),
],
});
const table = (list: CfBox[], isCost: boolean, title: string): HTMLElement =>
el("div", {
className: "m02-dfsum__block",
children: [
el("strong", { text: title }),
el("div", {
className: "m02-dfsum__scroll",
children: [
el("table", {
className: "m02-dfsum__table",
children: [
el("thead", {
children: [
el("tr", {
children: [
el("th", { text: ts("HName") }),
...(isCost ? [el("th", { text: ts("HCost") })] : []),
el("th", { text: ts("HValue") }),
el("th", { text: ts("HUnit") }),
],
}),
],
}),
el("tbody", { children: list.map((b) => line(b, isCost)) }),
],
}),
],
}),
],
});
/* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */
let checked: string[] = [];
try {
checked = checkLogic(lg, opts?.values ?? {});
} catch {
checked = [];
}
const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])];
/* 예시값 — 변수 상자의 변수마다 */
const msg = el("p", { className: "m02-dfsum__msg" });
const fields = lg.boxes
.filter((b) => b.kind === "input" && b.input?.mode === "variable")
.flatMap((b) =>
(b.input?.variables ?? []).map((v, i) =>
sampleField(
v,
(value) => {
msg.textContent = "";
if (!on) return;
try {
on.change({
...lg,
boxes: lg.boxes.map((x) =>
x.id === b.id && x.input
? {
...x,
input: {
...x.input,
variables: x.input.variables.map((y, j) =>
j === i ? { ...y, example: value } : y,
),
},
}
: x,
),
});
} catch {
msg.textContent = ts("Cant");
}
},
msg,
),
),
);
const plainValues = Object.entries(cost.값들);
host.replaceChildren(
big,
costs,
...(costed.length ? [table(costed, true, ts("Lines"))] : []),
...(plain.length
? [table(plain, false, ts("Plain"))]
: plainValues.length
? [
el("div", {
className: "m02-dfsum__block",
children: [
el("strong", { text: ts("Plain") }),
...plainValues.map(([k, v]) =>
el("p", { className: "m02-dfsum__muted", text: `${k} · ${showValue(v)}` }),
),
],
}),
]
: []),
...(fields.length
? [
el("div", {
className: "m02-dfsum__strip",
children: [
el("strong", { text: ts("Sample") }),
el("div", { className: "m02-dfsum__fields", children: fields }),
el("p", { className: "m02-dfsum__muted", text: ts("SampleHint") }),
msg,
],
}),
]
: []),
...(problems.length
? [
el("div", {
className: "m02-dfsum__problems",
children: [
el("strong", { text: ts("Problems") }),
el("ul", { children: problems.map((t) => el("li", { text: t })) }),
],
}),
]
: []),
);
}
@@ -0,0 +1,785 @@
{
"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": []
}
@@ -0,0 +1,146 @@
{
"key": "SAMPLE-rate",
"name": "견본 · 공구손료(비율)",
"resultUnit": "원",
"boxes": [
{
"id": "in-var",
"kind": "input",
"name": "바탕 값",
"color": "#2f80ed",
"unit": "-",
"x": 40,
"y": 80,
"input": {
"mode": "variable",
"elements": [],
"variables": [
{
"name": "노무비",
"type": "number",
"unit": "원",
"example": 100000
},
{
"name": "공구손료율",
"type": "number",
"unit": "%",
"example": 3
}
]
}
},
{
"id": "fn-rate",
"kind": "function",
"name": "공구손료",
"color": "#7b4fd6",
"unit": "원",
"x": 340,
"y": 80,
"func": {
"sheet": {
"id": "cf-tool",
"이름": "공구손료",
"칸": {
"A1": {
"값": "이름"
},
"B1": {
"값": "값"
},
"C1": {
"값": "설명"
},
"A2": {
"값": "노무비"
},
"C2": {
"값": "받는 값 · 원"
},
"A3": {
"값": "공구손료율"
},
"C3": {
"값": "받는 값 · %"
},
"A4": {
"값": "공구손료"
},
"B4": {
"식": "B2*B3/100"
},
"C4": {
"값": "노무비 × 율 ÷ 100"
}
}
},
"inputs": [
{
"port": "노무비",
"cell": "B2"
},
{
"port": "공구손료율",
"cell": "B3"
}
],
"variables": [],
"output": {
"cell": "B4",
"name": "공구손료",
"unit": "원"
}
}
},
{
"id": "out-mat",
"kind": "output",
"name": "재료비",
"color": "#27ae60",
"unit": "원",
"x": 640,
"y": 80,
"output": {
"cost": "재료비",
"unit": "원"
}
}
],
"links": [
{
"id": "l1",
"from": {
"box": "in-var",
"name": "노무비"
},
"to": {
"box": "fn-rate",
"name": "노무비"
}
},
{
"id": "l2",
"from": {
"box": "in-var",
"name": "공구손료율"
},
"to": {
"box": "fn-rate",
"name": "공구손료율"
}
},
{
"id": "l3",
"from": {
"box": "fn-rate",
"name": "공구손료"
},
"to": {
"box": "out-mat",
"name": "값"
}
}
],
"userElements": []
}
@@ -0,0 +1,200 @@
{
"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": []
}