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:
@@ -0,0 +1 @@
|
||||
_data/
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
Reference in New Issue
Block a user