refactor(794-1b): M01 흐름도 패널 걷음 — 로직 상세 오른쪽 = 원문만 · [신규 로직] = 빈 시트 줄 · 흐름 서버 길 · DataFlow 화면 지움

요구 651 · 653 — 계산 공식 참고용 흐름도(① M01 로직 상세 오른쪽 접는 패널)를 걷음. ② M02 일위대가 다이어그램은 그대로.

지움(64 파일 · 18,666 줄 · 되돌릴 길 = git 이력)
- M01 6 파일 1,064 줄 — Store_Flow.py · UI_LogicLab_Flow.ts/.css · FlowRows.ts · FlowView.ts/.css
- M02_MasterTemplete/DataFlow 43 파일 12,799 줄 — 흐름도 화면 · 셈(Compute · Convert · Lookup · Model · Eval …)
- 시험 15 파일 4,803 줄 — test_m01_logic_flow{view,_engine,_project,_save} · test_m02_df_*(값 찾기 values_old 뺌) — 지운 흐름 코드만 지키던 것

남김(바닥) — DataFlow M02_DF_UnitPick(일위대가 셋) · M02_DF_Values(M02_Item_Prices) · M02_DF_Round · _standard 표 ·
  M02_DF_Router = GET /api/m02/df/standards 하나 · M02_DF_Api = cfStandards 하나 · CfRound 꼴은 Round 안으로

고침
- M01 Router — /save = Store_Make.save(개정 확인 · 자체 파일 새 줄 GX · added 그대로) · /calc /text /logic/auto = 옛 셈만 · /logic/flow 둘 지움
- master_formula — 흐름 훅 지움 · 줄에 「흐름」 이 남으면 「흐름도 로직은 걷음」 까닭 오류(저장된 흐름 로직 0 — 마스터 · storage 다시 확인)
- Sangeun_Calc._defaults = 옛 자동값 · M02_Logic_Lib_Read /logic/flow 지움 · main.py 주석
- 화면 — LogicLab(startFlow · scheduleFlow · 예시값 잇기 지움 · [신규 로직] 줄 = 입력 · 중간 · 호표 빈 목록 + 끝수 null) ·
  Side(원문 패널 하나) · Source(머리 칸 이름 m01lab-src__top/__title · DF_Panel.css 안 씀) · Detail · View(nowInputs 지움) · Sheet(흐름 갈래 지움) · 글
- 시험 — 지운 파일을 가리키던 목록 넷(spreadsheet_size · 208_1 · 588 · input_prefs) · 717_1 흐름 길 두 줄 · values_old 흐름 셈 부분 지움
- test_713_3_list 기대 — 로직 1,461 → 1,515 · 묶음 307 → 335 · 작업량 21 → 65 · 부르는 506 → 512(782-1 · 2 · 3 건설 기초 로직 54 더함 ·
  옛 키만 세면 307 · 21 · 506 그대로)
- 새 지킴 test_794_1b_flow_gone — 흐름 길 404 · 표준 표 남음 · 신규 로직 자체 GX · 흐름 남은 줄 까닭 오류

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 19:38:39 +09:00
co-authored by Claude Opus 5.5
parent 9456935d2d
commit 598b80eb26
91 changed files with 203 additions and 19307 deletions
+7 -182
View File
@@ -1,195 +1,20 @@
/* =============================================================================
* M02_DF_Api.ts
* 단가산출 흐름도 Api — 입력 값 한 번에 받기 · 자재 찾기 · 다른 로직 값 · 설계 때 정할 것 ·
* 단위 · 금액 표준 표(흐름 저장은 로직 줄 「흐름」 칸 · M01 `/save`). 모양 = sub7 Model · 아래는 Api 몫만.
* 서버 = `M02_DF_Router.py`(`/api/m02/df/*`) · 정함 `docs/raw/M01_쉬운단가/흐름도_정함.md`
* 단위 · 금액 표준 표 읽기 — 서버 `GET /api/m02/df/standards`(`M02_DF_Router.py`)
* 부르는 곳 = 단위 고르기(`M02_DF_UnitPick`) · 끝수 표준(`M02_DF_Round`)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { CfDesign, CfElementRef, 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?} → 표 블록(머리 줄 포함 · 조건 칸 → 값 칸 · 범위는 「·아래」 「·위」)
* · 재료 자리 slot = 요소 그대로(mode fixed · choice · rule · scope · default) + field · op(조건 찾기 규칙) ·
* value = 이어진 알약 값(조건 찾기) · pick = 고른 품목 키(선택) → 단가 · 여럿이면 candidates */
export interface CfQuery {
id: string;
group: CfGroup;
ref?: string;
table?: { key: string; row?: number };
unit?: string;
field?: string;
inputs?: Record<string, unknown>;
slot?: Partial<CfElementRef> & {
field?: "규격" | "이름" | "키" | "검색어";
op?: "포함" | "같음";
};
value?: unknown;
pick?: string;
/** 다른 로직 입력 상자 — 서버가 그 로직을 받은 설계 값으로 셈(/logic-value · 화면 queriesOf 가 만듦 · /values 로는 안 감) */
logic?: { key: string; designs: 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;
/** 빈 단가 · 맞는 재료 없음 — 옛 셈처럼 그 값만 비우고 셈은 계속(흐름은 「#N/A」) */
blank?: boolean;
/** 재료 조건 찾기가 여럿 맞을 때 후보 다섯 */
candidates?: CfMaterial[];
}
/** 자재 찾기 한 줄 */
export interface CfMaterial {
ref: string;
name: string;
spec: string;
unit: string;
price: number | null;
구분: string;
상세구분: string;
}
/** 단위 · 금액 표준 표 — 고친 것(`_data`)이 있으면 saved */
/** 단위 · 금액 표준 표 */
export interface CfStandards {
unit: { 이름: string; 줄: Record<string, unknown>[]; [k: string]: unknown };
money: { 이름: string; 줄: Record<string, unknown>[]; [k: string]: unknown };
saved: boolean;
}
/** 서버 계산 결과 — 저장 답(화면 Eval 과 같은 차례 · 같은 규칙 · arrived = 상자마다 받은 값 · breaks = 끊긴 곳) */
export interface CfComputed {
arrived?: Record<string, Record<string, unknown>>;
breaks?: { kind: string; box: string; port?: string; link?: string; text: string }[];
/** 상자 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[];
export async function cfStandards(): Promise<CfStandards> {
const response = await fetch(`${API_BASE_URL}/m02/df/standards`, { credentials: "include" });
if (!response.ok) throw new Error(`표준 표 읽기 실패 (${response.status})`);
return (await response.json()) as CfStandards;
}
/** 서버가 준 몸과 상태 번호 — 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, method?: string): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m02/df${path}`, {
method: 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;
}
/** 입력 값 한 번에 — 열 때 · 예시값을 바꿀 때 화면이 받아 시트에 넣음 ·
* userElements = 이 다이어그램 안 사용자 요소(ref 가 `UE-…` 일 때 먼저 봄) */
export async function cfValues(
items: CfQuery[],
userElements: CfUserElement[] = [],
): Promise<CfAnswer[]> {
return (await send<{ values: CfAnswer[] }>("/values", { items, userElements })).values;
}
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] ?? "저장하지 못함 — 잠시 뒤 다시 해 주세요";
}
/** 자재 찾기 — 띄어쓰기로 나눈 말을 모두 포함(이름 · 규격 · 키 · 원문번호) · 쪽 넘기기 */
export async function cfMaterials(q: {
q?: string;
sub?: string;
detail?: string;
page?: number;
size?: number;
}): Promise<{ total: number; page: number; size: number; items: CfMaterial[] }> {
const qs = new URLSearchParams(
Object.entries(q).flatMap(([k, v]) => (v === undefined || v === "" ? [] : [[k, String(v)]])),
);
return send(`/materials?${qs.toString()}`);
}
/** 다른 로직 값 — 저장 로직 · 견본을 받은 설계 값(없으면 예시값)으로 서버가 셈 · 출력 이름 → 값 */
export async function cfLogicValue(
key: string,
designs: Record<string, unknown> = {},
): Promise<{ outputs: Record<string, unknown>; cost: CfComputed["cost"]; errors: string[] }> {
return send("/logic-value", { key, designs });
}
/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true ·
* notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */
export async function cfLogicFlow(
file: string,
key: string,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, {
credentials: "include",
});
} catch {
throw new CfError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
return data as { flow: CfLogic; converted: boolean; notes: string[] };
}
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */
export async function cfDesignInputs(key: string): Promise<CfDesign[]> {
const qs = new URLSearchParams({ key }).toString();
return (await send<{ designs: CfDesign[] }>(`/design-inputs?${qs}`)).designs;
}
/** 단위 · 금액 표준 표 읽기 · 고치기(시스템 관리자) · 처음 값으로 */
export const cfStandards = (): Promise<CfStandards> => send("/standards");
export const cfStandardsSave = (body: Omit<CfStandards, "saved">): Promise<CfStandards> =>
send("/standards", body, "PUT");
export const cfStandardsReset = (): Promise<CfStandards> => send("/standards", undefined, "DELETE");
@@ -1,138 +0,0 @@
/* =============================================================================
* M02_DF_CellTable.ts
* 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기
* 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfBox, CfLogic } from "./M02_DF_Model";
import { portText } from "./M02_DF_Detail";
import "@ui/ui_template_badge.css";
import "@ui/ui_template_table.css";
const TEXT = {
Title: ["칸 연결 표", "Cell connections"],
Cell: ["칸", "Cell"],
Pill: ["연결 알약", "Pill"],
Kind: ["종류", "Kind"],
Line: ["이어진 선", "Line"],
In: ["입력", "Input"],
Var: ["변수", "Variable"],
Out: ["결과", "Result"],
None: ["아직 연결된 칸이 없음", "No cell is connected yet"],
Unplaced: ["칸 없음", "no cell"],
NoLine: ["-", "-"],
Release: ["풀기", "Release"],
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
} as const;
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
export type CellKind = "in" | "var" | "out";
export interface CellTableHandlers {
/** 표에서 칸을 바꿈 — key = 알약 이름 */
move(kind: CellKind, key: string, cell: string): void;
/** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */
release(kind: CellKind, key: string): void;
}
interface Row {
kind: CellKind;
key: string;
cell: string;
line: string;
}
function rowsOf(lg: CfLogic, box: CfBox): Row[] {
const f = box.func;
if (!f) return [];
const into = (port: string): string => {
const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port);
return l ? portText(lg, l.from) : tt("NoLine");
};
const out = (): string => {
const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name);
return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine");
};
return [
...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })),
...f.variables.map((v): Row => ({
kind: "var",
key: v.name,
cell: v.cell ?? "",
line: tt("NoLine"),
})),
{ kind: "out", key: f.output.name, cell: f.output.cell, line: out() },
];
}
/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */
export function renderCellTable(
host: HTMLElement,
lg: CfLogic,
box: CfBox,
readOnly: boolean,
handlers: CellTableHandlers,
): void {
host.classList.add("m02-dfcells");
const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key);
const head = el("tr", {
children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) =>
el("th", { text: t }),
),
});
const body = rows.map((r) => {
const input = el("input", {
className: "ui-input ui-input--sm m02-dfcells__cell",
attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") },
});
input.value = r.cell;
input.readOnly = readOnly;
input.addEventListener("change", () => {
const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim());
input.classList.toggle("is-bad", !m);
input.title = m ? "" : tt("BadCell");
if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`);
});
return el("tr", {
className: `m02-dfcells__row is-${r.kind}`,
children: [
el("td", { children: [input] }),
el("td", { text: r.key }),
el("td", {
children: [
el("span", {
className: `ui-badge ui-badge--pill ui-badge--${r.kind === "in" ? "info" : r.kind === "var" ? "warning" : "success"} m02-dfcells__kind`,
attrs: { "data-size": "20" },
text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"),
}),
],
}),
el("td", { text: r.line }),
el("td", {
children:
readOnly || (r.kind !== "var" && !r.cell)
? []
: [
createButton({
label: tt("Release"),
variant: "ghost",
size: "sm",
onClick: () => handlers.release(r.kind, r.key),
}),
],
}),
],
});
});
host.replaceChildren(
el("strong", { className: "m02-dfcells__title", text: tt("Title") }),
el("table", {
className: "ui-table ui-table--data m02-dfcells__table",
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
}),
);
}
@@ -1,624 +0,0 @@
"""단가산출 흐름도 서버 계산(PLAN 35-9 · 정함 4 · 5 · 6 · 7 · 12) — 저장 다시 셈 · 다른 로직 값.
모양 = sub7 Model `M02_DF_Model.ts`(CfLogic · CfLink.rule · CfElementRef.mode · CfInput.logic ·
CfRound). 화면 계산 `M02_DF_Eval.evalLogic` 과 같은 차례 · 같은 뜻으로 셈(짝 ① — 화면은 TS · 서버는
이 파일 · 함수 시트는 같은 엔진(Node · `common_util_spreadsheet`)):
· 상자 차례 = 선 · 선 규칙 조건 알약 · 재료 조건 찾기 알약에 기댐(Model `depends`) — 서로 안 기대는
함수 상자들은 한 번에 Node 로 셈(시트마다 통합문서 하나)
· 입력 상자 알약 = 요소(재료는 고정 · 선택 · 조건 찾기 — 알약 이름 = `elementPort`) · 표 · 변수 ·
다른 로직 출력 — 단가는 마스터에서 새로(`M02_DF_Values`) · 설계 값 values = {상자: {알약: 값}}
· 선 규칙 = `M02_DF_Rule.applyRule` 과 같은 뜻(값 기준) — 막히면(통과 안 됨 · 표에 없는 조건 ·
여럿 ·
오류) 받는 함수는 셈하지 않고 빈 값 · 끝수 = 엔진 ROUND · ROUNDUP · ROUNDDOWN 과 같음
· 다른 로직 = 저장 로직 · 견본을 받은 설계 값(선 · 검증 칸 · 없으면 그 로직 예시값)으로 셈 ·
서로 부르면 막음
마스터 파일은 읽기만.
"""
from __future__ import annotations
import copy
import re
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal
from typing import Any, Callable
from common_util import common_util_spreadsheet as spreadsheet
from M01_MasterData import (
M01_MasterData_Router as m01,
) # _num — M01 Router 가 이 파일을 부름(서로 부름)
from M01_MasterData import M01_MasterData_Store as store
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
from M02_MasterTemplete.DataFlow.M02_DF_Lookup import ( # noqa: F401 — 시험이 cp._lookup 을 부름
RANGE_RULE,
_cond_of,
_exact,
_is_err,
_lookup,
_num_of,
_same,
)
_CELL = re.compile(r"^([A-Z]+)(\d+)$")
TABLE_PORT, OUTPUT_PORT = "표", "값"
COSTS = ("노무비", "재료비", "경비")
GATE = {"=": "=", "≠": "<>", "≥": ">=", "≤": "<=", ">": ">", "<": "<"}
ROUND = {"반올림": "ROUND", "올림": "ROUNDUP", "버림": "ROUNDDOWN"}
HELP = "_df_" # 보조 시트 이름 머리(표 블록 · 끝수)
FAR = Decimal("1E+300") # 범위 칸 열린 끝 — 엑셀 비교가 늘 참이 되게
Loader = Callable[[str], dict]
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 _at(cell: str) -> tuple[int, int, str]:
m = _CELL.match(str(cell or "").strip().upper())
if not m:
raise store.StoreError(400, f"칸 주소 모양이 아님 「{cell}」")
return _col(m.group(1)), int(m.group(2)), f"{m.group(1)}{m.group(2)}"
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 isinstance(value, dict) and "식" in value:
cells[cell] = {"식": value["식"], **keep}
elif value is None or value == "":
cells.pop(cell, None) if not keep else cells.__setitem__(cell, keep)
else:
cells[cell] = {"값": value, **keep}
def _place(cells: dict, cell: str, value: Any) -> None:
"""받는 칸에 값 · 수식 · 표 블록(cell 이 왼위)."""
col, row, at = _at(cell)
if 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["수"]))
return v.get("글", v.get("참"))
def _literal(v: Any) -> str:
"""수식 안 값 글 — 수는 그대로 · 글은 따옴표 · 빈 값은 빈 글."""
if v is None:
return '""'
if isinstance(v, bool):
return "TRUE" if v else "FALSE"
if isinstance(v, (int, float, Decimal)):
return str(v)
return '"' + str(v).replace('"', '""') + '"'
# ── 입력 상자 ──────────────────────────────────────────────────────────
def _element_value(
group: str, el: dict, given: Any, ports: dict, errors: list, box: dict, ctx: dict
) -> Any:
"""요소 알약 값 — 재료는 방식 셋 · 그 밖은 그룹 값 · 설계 값이 이김(선택 자리 = 키 · 수)."""
mode = el.get("mode") or "fixed"
env = ctx.get("env") or {} # 로직 입력(자재지역 · 계약종별 …) — 옛 셈 재료 찾기가 읽음
if group == "재료" and mode == "choice":
if isinstance(given, (int, float, Decimal)) and not isinstance(given, bool):
return given
slot = {"mode": "choice", "default": el.get("default"), "inputs": env}
got = dv.slot_price(slot, pick=given or None)
elif group == "재료" and mode == "rule":
rule = el.get("rule") or {}
src = rule.get("from") or {}
slot = {
"mode": "rule",
"scope": el.get("scope"),
"field": rule.get("field"),
"op": rule.get("op"),
"ref": el.get("ref"),
"unit": el.get("unit"),
"inputs": env,
}
value = ports.get(src.get("box"), {}).get(src.get("name"))
if rule.get("field") == "검색어": # 틀의 자리마다 알약 값 → 채운 검색어 글(빈 자리면 None)
value, slot["unit"] = _fill_template(rule, ports), el.get("unit")
got = dv.slot_price(slot, value)
if not got["ok"] and not got.get("blank") and el.get("default"):
errors.append(
f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본 품목으로 셈"
)
got = dv.slot_price({"mode": "fixed", "ref": el["default"], "inputs": env})
elif group in dv.GROUP_RULE and mode == "rule": # 인력 · 기계 · 유가전력 조건 찾기
rule = el.get("rule") or {}
src = rule.get("from") or {}
slot = {"field": rule.get("field"), "op": rule.get("op"), "scope": el.get("scope"),
"ref": el.get("ref"), "unit": el.get("unit")} # fmt: skip
got = dv.group_find(group, slot, ports.get(src.get("box"), {}).get(src.get("name")))
if not got["ok"] and not got.get("blank") and el.get("default"):
errors.append(f"「{box.get('name')} · {el.get('name')}」 {got['reason']} — 기본으로 셈")
got = dv.one_value({"group": group, "ref": el["default"], "unit": el.get("unit")})
elif given is not None:
return given
else:
item = {"group": group, "ref": el.get("ref"), "unit": el.get("unit"), "inputs": env}
got = dv.one_value(item, ctx["local"])
if not got["ok"] and got.get("blank"): # 옛 셈처럼 그 값만 비움(알림) — 셈은 계속
ctx["notes"].append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}")
return NA_VALUE # 그 값을 쓰는 식은 빈 값(옛 엔진) — 비목 합은 0
if not got["ok"]:
errors.append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}")
return None
return got.get("value")
def _fill_template(rule: dict, ports: dict) -> str | None:
"""재료 검색어 틀의 자리에 slots 알약 값 — 옛 엔진 fill 그대로(빈 자리 = None)."""
env = {}
for x in rule.get("slots") or []:
src = x.get("from") or {}
env[x.get("name")] = ports.get(src.get("box"), {}).get(src.get("name"))
if any(v is None for v in env.values()):
return None
try:
return store.cm.mf.fill(str(rule.get("template") or ""), env)
except store.cm.mf.FormulaError:
return None
def _port(el: dict) -> str:
"""요소 알약 이름 — Model `elementPort`(고정 아니면 자리 이름 slot · 없으면 name)."""
slot = str(el.get("slot") or "").strip()
return slot if el.get("mode") not in (None, "fixed") and slot else el.get("name")
def _input_ports(lg: dict, box: dict, values: dict, ports: dict, errors: list, ctx: dict) -> dict:
inp, given, out = box.get("input") or {}, values.get(box["id"]) or {}, {}
mode = inp.get("mode")
if mode == "logic":
return _logic_ports(lg, box, given, ports, errors, ctx)
if mode == "group":
group = inp.get("group")
for el in inp.get("elements") or []:
out[_port(el)] = _element_value(
group, el, given.get(_port(el)), ports, errors, box, ctx
)
if inp.get("table"):
got = dv.one_value({"group": group, "table": inp["table"]}, ctx["local"])
out[TABLE_PORT] = got.get("rows") if got["ok"] else None
if not got["ok"]:
errors.append(f"「{box.get('name')} · 표」 {got.get('reason')}")
for v in inp.get("variables") or []:
out[v.get("name")] = given.get(v.get("name"), v.get("example"))
return out
def _logic_ports(lg: dict, box: dict, given: dict, ports: dict, errors: list, ctx: dict) -> dict:
"""다른 로직 — 받는 알약(설계 값) = 선 · 검증 칸 값 · 없으면 그 로직 예시값 → 그 로직 출력."""
ref = (box.get("input") or {}).get("logic") or {}
key = str(ref.get("key") or "")
if key in ctx["stack"]:
errors.append(
f"「{box.get('name')}」 로직이 서로를 부름 — {' → '.join([*ctx['stack'], key])}"
)
return {}
try:
other = ctx["load"](key)
except store.StoreError as e:
errors.append(f"「{box.get('name')}」 {e.detail}")
return {}
designs = {**given}
for link in lg.get("links") or []:
if link.get("to", {}).get("box") == box["id"]:
src = link.get("from") or {}
designs[link["to"].get("name")] = ports.get(src.get("box"), {}).get(src.get("name"))
if callable(
other.get("calc")
): # 옛 마스터 로직(흐름 없음) — 옛 셈 · 노무비 · 재료비 · 경비 · 계
result = other["calc"](designs)
errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]]
return result["outputs"]
stack = [*ctx["stack"], key]
result = run(other, _design_values(other, designs), ctx["load"], ctx["local"], stack)
errors += [f"「{box.get('name')}」 {x}" for x in result["errors"]]
names = {b["id"]: b.get("name") for b in other.get("boxes") or []}
return {names[i]: v for i, v in result["outputs"].items() if i in names}
def _design_values(lg: dict, designs: dict) -> dict:
"""다른 로직의 설계 값 {알약 이름: 값} → 그 로직의 values {상자 id: {알약: 값}}."""
out: dict[str, dict] = {}
for d in design_inputs(lg):
if d["port"] in designs and designs[d["port"]] is not None:
out.setdefault(d["box"], {})[d["port"]] = designs[d["port"]]
return out
# ── 설계 때 정할 것 ────────────────────────────────────────────────────
def design_inputs(lg: dict) -> list[dict]:
"""변수 · 재료 자리(선택) · 다른 로직의 받는 알약(선이 없을 때) — Model `CfDesign` 꼴."""
links = lg.get("links") or []
fed = {
(ln.get("to") or {}).get("box", "") + "\0" + (ln.get("to") or {}).get("name", "")
for ln in links
}
out = []
for box in lg.get("boxes") or []:
inp = box.get("input") or {}
if box.get("kind") != "input":
for v in (box.get("func") or {}).get("variables") or []:
out.append(
{
"box": box["id"],
"port": v.get("name"),
"kind": "변수",
"unit": v.get("unit") or "-",
"variable": v,
}
)
continue
for v in inp.get("variables") or []:
out.append(
{
"box": box["id"],
"port": v.get("name"),
"kind": "변수",
"unit": v.get("unit") or "-",
"variable": v,
}
)
for el in inp.get("elements") or [] if inp.get("group") == "재료" else []:
if el.get("mode") == "choice":
out.append(
{
"box": box["id"],
"port": _port(el),
"kind": "재료 자리",
"unit": el.get("unit") or "-",
"element": el,
}
)
for pill in (
((inp.get("logic") or {}).get("designs") or []) if inp.get("mode") == "logic" else []
):
if box["id"] + "\0" + pill.get("name", "") not in fed:
out.append(
{
"box": box["id"],
"port": pill.get("name"),
"kind": "다른 로직",
"unit": pill.get("unit") or "-",
}
)
return out
# ── 선 규칙(`M02_DF_Rule.applyRule` 와 같은 뜻 · 값 기준) ────────────────────
def _compare(a: Any, op: str, b: Any) -> bool:
if op in ("=", "≠"):
return _same(a, b) == (op == "=")
na, nb = _num_of(a if a is not None else 0), _num_of(b)
if na is None or nb is None:
sa, sb = str(a or "").lower(), str(b or "").lower()
na, nb = (sa, sb) if na is None and nb is None else (None, None)
if na is None:
return False
return {"≥": na >= nb, "≤": na <= nb, ">": na > nb, "<": na < nb}.get(op, False)
def _rounded(value: Any, r: dict) -> Any:
"""끝수 — 엔진 ROUND(반올림 · 0 에서 먼 쪽) · ROUNDUP · ROUNDDOWN · 자리는 음수도."""
n = _num_of(value)
if n is None:
return value
mode = {"반올림": ROUND_HALF_UP, "올림": ROUND_UP, "버림": ROUND_DOWN}.get(
r.get("method"), ROUND_HALF_UP
)
places = int(r.get("places") or 0)
return (
n.quantize(Decimal(1).scaleb(-places), rounding=mode)
if places >= 0
else ((n.scaleb(places).quantize(Decimal(1), rounding=mode)).scaleb(-places))
)
def apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]:
"""(값, 까닭) — 까닭 = 통과 안 됨 · 표에 없는 조건 · 표에 여럿 맞음 · 오류 · None."""
kind = rule.get("kind")
if kind == "lookup":
return _lookup(rule, value, conds)
if kind == "gate":
rows, join = _gate_conds(rule)
hits = [
_compare(_cond_of(conds, c.get("from") or {}), c.get("op"), c.get("value"))
for c in rows
]
ok = any(hits) if join == "또는" else all(hits)
return (value, None) if ok else (None, "통과 안 됨")
n = _num_of(value)
if n is None:
return (None, None) if value is None else (None, "오류")
if kind == "round":
return _rounded(n, rule), None
v = Decimal(str(rule.get("value")))
op = rule.get("op")
if op == "÷" and v == 0:
return None, "오류"
return {"×": n * v, "÷": n / v, "+%": n * (1 + v / 100), "-%": n * (1 - v / 100)}.get(
op, n
), None
def _sources(rule: dict | None) -> list[dict]:
if not rule:
return []
if rule.get("kind") == "lookup":
return [c.get("from") or {} for c in rule.get("conds") or []]
if rule.get("kind") == "gate":
return [c.get("from") or {} for c in _gate_conds(rule)[0]]
return []
def _gate_conds(rule: dict) -> tuple[list[dict], str]:
"""조건 통과 줄 · 잇는 말 — conds · join · 옛 한 줄 꼴은 줄 하나(Model gateConds)."""
rows = rule.get("conds") or [
{"from": rule.get("from") or {}, "op": rule.get("op"), "value": rule.get("value")}
]
return rows, rule.get("join") or "그리고"
# ── 계산(`M02_DF_Eval.evalLogic` 와 같은 차례) ─────────────────────────────
def _depends(lg: dict) -> list[tuple[str, str]]:
"""상자 사이 기댐 — 선 + 선 규칙 조건 알약 + 재료 조건 찾기 알약(Model `depends`)."""
out = []
for ln in lg.get("links") or []:
out.append((ln["from"].get("box"), ln["to"].get("box")))
out += [(p.get("box"), ln["to"].get("box")) for p in _sources(ln.get("rule"))]
for b in lg.get("boxes") or []:
for el in (b.get("input") or {}).get("elements") or []:
if el.get("mode") == "rule" and el.get("rule"):
rule = el["rule"]
srcs = [
rule.get("from") or {},
*[x.get("from") or {} for x in rule.get("slots") or []],
]
out += [(p.get("box"), b["id"]) for p in srcs]
return out
def _waves(lg: dict) -> tuple[list[list[dict]], list[dict]]:
"""차례 물결 — 한 물결 안 상자는 서로 안 기댐(함수 시트를 한 번에 셈) · 돌고 도는 것은 따로."""
deps, boxes = _depends(lg), lg.get("boxes") or []
left, waves = {b["id"] for b in boxes}, []
while left:
ready = [
b
for b in boxes
if b["id"] in left and not any(t == b["id"] and f != t and f in left for f, t in deps)
]
if not ready:
break
waves.append(ready)
left -= {b["id"] for b in ready}
return waves, [b for b in boxes if b["id"] in left]
def _sheet_of(box: dict, arrived: dict, given: dict) -> dict:
"""함수 상자 시트 — 받는 칸 · 변수 칸에 값(Eval `runFunc` 와 같음)."""
func = box["func"]
sheet = copy.deepcopy(func.get("sheet") or {})
cells = sheet.setdefault("칸", {})
for p in func.get("inputs") or []:
if str(p.get("cell") or "").strip():
port = p.get("port")
v = arrived[port] if arrived.get(port) is not None else given.get(port)
_place(cells, p["cell"], {"식": "NA()"} if _is_err(v) else v) # 오류 값 · 빈 단가
for v in func.get("variables") or []:
if v.get("cell"):
_place(cells, v["cell"], given.get(v.get("name"), v.get("example")))
return sheet
REST = "앞 조건이 안 맞아 쉬는 갈래"
NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음)
def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | None = None) -> dict:
"""셈 — {ports, outputs, cost, errors, breaks, filled}(filled = 받는 칸에 값만 되돌려 넣음)."""
ctx = {"load": load, "local": local, "stack": stack or [lg.get("key") or ""], "notes": [],
"env": _env(lg, values)} # fmt: skip
boxes = {b["id"]: b for b in lg.get("boxes") or []}
links = lg.get("links") or []
name = lambda i: boxes.get(i, {}).get("name") or i # noqa: E731
errors, breaks, ports, arrived, outputs = [], [], {}, {}, {}
blocked: set[str] = set()
resting: set[str] = set() # 조건이 안 맞아 쉬는 상자 — Eval 과 같은 뜻
late: dict[str, list[dict]] = {} # 함수로 가는 표 찾기 실패 — 결과가 오류일 때만 끊긴 곳
cost: dict[str, Any] = {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}}
def through(ln: dict) -> Any:
src = ln.get("from") or {}
v = ports.get(src.get("box"), {}).get(src.get("name"))
rule = ln.get("rule")
if (
v == NA_VALUE
and (boxes.get(src.get("box"), {}).get("input") or {}).get("mode") == "group"
):
to = ln["to"] # 빈 단가 — 결과가 오류일 때만 끊긴 곳(Eval NoPrice 와 같음)
text = f"「{name(to.get('box'))}」 — 단가 없는 품목을 씀(「{src.get('name')}」)"
brk = {"kind": "단가 없는 품목", "level": "문제", "box": to.get("box")}
brk |= {"port": to.get("name"), "link": ln.get("id"), "text": text}
late.setdefault(to.get("box"), []).append(brk)
if not rule:
return v
if v is None and rule.get("kind") in ("scale", "round"):
return None
conds = {}
for p in _sources(rule):
got_v = ports.get(p.get("box"), {}).get(p.get("name"))
conds[p.get("name")] = got_v
conds[f"{p.get('box')}|{p.get('name')}"] = got_v
got, why = apply_rule(rule, v, conds)
if why:
text = f"「{name(src.get('box'))}」 → 「{name(ln['to'].get('box'))}」 {why}"
kind = "오류" if why in ("표에 여럿 맞음", "오류") else why
to = ln["to"]
brk = {
"kind": kind,
# 무게 — 통과 안 됨 = 쉼 · 나머지 = 문제(Eval CfBreak.level 과 같음)
"level": "쉼" if kind == "통과 안 됨" else "문제",
"box": to.get("box"),
"port": to.get("name"),
"link": ln.get("id"),
"text": text,
}
if (
rule.get("kind") == "lookup"
and boxes.get(to.get("box"), {}).get("kind") == "function"
):
late.setdefault(to.get("box"), []).append(brk)
return NA_VALUE
blocked.add(to.get("box"))
if why == "통과 안 됨":
resting.add(to.get("box"))
breaks.append(brk)
if why != "통과 안 됨":
errors.append(text)
return None if why else got
def arrive(box: dict) -> dict:
"""받는 값 — 쉬는 갈래 빈 값은 「쉼」 줄만 · 선이 모두 쉬면 상자도 쉼(Eval 과 같음)."""
ins = [ln for ln in links if ln["to"].get("box") == box["id"]]
got, rest = {}, 0
for ln in ins:
src, to = ln.get("from") or {}, ln["to"]
if (
src.get("box") in resting
and ports.get(src.get("box"), {}).get(src.get("name")) is None
):
rest += 1
got[to.get("name")] = None
breaks.append(
{
"kind": "통과 안 됨",
"level": "쉼",
"box": to.get("box"),
"port": to.get("name"),
"link": ln.get("id"),
"text": f"「{name(src.get('box'))}」 → 「{name(to.get('box'))}」 — {REST}",
}
)
continue
got[to.get("name")] = through(ln)
if ins and rest == len(ins):
resting.add(box["id"])
blocked.add(box["id"])
arrived[box["id"]] = got
return got
waves, loop = _waves(lg)
errors += [f"「{b.get('name')}」 상자가 서로를 기다림(돌고 도는 선)" for b in loop]
sheets: dict[str, dict] = {}
for wave in waves:
funcs = []
for b in wave:
given = values.get(b["id"]) or {}
if b.get("kind") == "input" and (b.get("input") or {}).get("mode") == "logic":
ports[b["id"]] = _logic_ports(lg, b, {**given, **arrive(b)}, ports, errors, ctx)
elif b.get("kind") == "input":
ports[b["id"]] = _input_ports(lg, b, values, ports, errors, ctx)
elif b.get("kind") == "function" and b.get("func"):
sheet = _sheet_of(b, arrive(b), given)
sheets[b["id"]] = sheet
if b["id"] in blocked:
ports[b["id"]] = {b["func"]["output"].get("name"): None}
else:
funcs.append(b)
elif b.get("kind") == "output":
outputs[b["id"]] = arrive(b).get(OUTPUT_PORT)
if funcs: # 이 물결의 함수 시트를 한 번에(시트마다 통합문서 하나 — Eval 과 같음)
books = [
m01._num(
{"종류": "통합문서", "판": 1, "서식": [{}] * 64, "시트": [sheets[f["id"]]]}
)
for f in funcs
]
try:
calcs = spreadsheet.recalc_workbooks(books)
except spreadsheet.RecalcError as e:
raise store.StoreError(503, str(e)) from e
for f, book, calc in zip(funcs, books, calcs):
sheet = book["시트"][0]
for cell, v in (calc.get(sheet.get("id")) or {}).items():
if "오류" in v and (sheet.get("칸") or {}).get(cell, {}).get("식") != "NA()":
errors.append(f"「{f.get('name')}」 {cell} 칸 계산 오류 {v['오류']}")
out = f["func"].get("output") or {}
at = _at(out.get("cell") or "A1")[2]
hit = (calc.get(sheet.get("id")) or {}).get(at)
value = (
hit["오류"] # 오류 값을 넘김(Eval fromCalc 와 같음 · 받는 칸은 그 글/NA())
if isinstance(hit, dict) and "오류" in hit
else _plain(hit)
if hit is not None
else ((sheet.get("칸") or {}).get(at) or {}).get("값")
)
if out.get("round") and _num_of(value) is not None:
value = _rounded(value, out["round"])
ports[f["id"]] = {out.get("name"): value}
if value is None or (isinstance(hit, dict) and "오류" in hit):
for brk in late.get(f["id"], []):
breaks.append(brk)
errors.append(brk["text"])
for b in boxes.values(): # 출력 상자 · 원가(Eval 과 같음)
if b.get("kind") != "output":
continue
value = outputs.get(b["id"])
value = None if isinstance(value, list) else value
if (b.get("output") or {}).get("round") and _num_of(value) is not None:
value = _rounded(value, b["output"]["round"])
outputs[b["id"]] = value
kind = (b.get("output") or {}).get("cost") or "값"
if kind == "값":
cost["값들"][b.get("name")] = value
elif _num_of(value) is not None:
cost[kind] += _num_of(value)
elif value is not None:
errors.append(f"「{b.get('name')}」 출력 값이 수가 아님")
cost["계"] = sum(cost[k] for k in COSTS)
filled = copy.deepcopy(lg) # 받는 칸 · 변수 칸에 값만 되돌려 넣음
for b in filled.get("boxes") or []:
if b["id"] in sheets:
b["func"]["sheet"] = sheets[b["id"]]
return {"ports": ports, "arrived": arrived, "outputs": outputs, "cost": cost, "errors": errors,
"breaks": breaks, "notes": ctx["notes"], "filled": filled} # fmt: skip
def _env(lg: dict, values: dict) -> dict:
"""입력 상자 변수 값(설계 값 · 없으면 예시값) — 재료 찾기가 읽는 로직 입력."""
return {
v.get("name"): (values.get(b["id"]) or {}).get(v.get("name"), v.get("example"))
for b in lg.get("boxes") or []
if b.get("kind") == "input"
for v in (b.get("input") or {}).get("variables") or []
if v.get("name")
}
@@ -1,431 +0,0 @@
"""M02 단가산출 흐름도 — 옛 로직 줄 → 흐름도(CfLogic) 변환 (PLAN 36-2 · 흐름도_적용_정함 2 · 3).
옛 식은 지금 서버 파서(`master_formula.parse` · `store.cm.mf`)로 나무를 읽어 함수 상자 시트로
· 이름마다 받는 알약 → 칸(B2 · B3 …) · 식은 칸 주소 · 함수 상자 하나 = 결과 하나.
입력 → 입력 상자 「설계 값」(변수 · 고르기 = choice · 범위)
중간 · 호표 수량 × 단가 · 덧줄 · 결과 → 함수 상자 · 비목 합 → 출력 상자(비목 셋 · 결과 = 값)
호표 단가 · 식 속 요소 키 → 그룹 입력 상자(인력 · 재료 · 기계 · 유가전력 · 환율 · 요율) 요소 알약
· 키에 {입력} 이 끼면 조건 찾기(field 「키」 · 같음 · from = 입력 알약 · 자리 여럿 = 글 잇는 함수)
찾기(표, 조건=값).칸 → 표 입력 상자 「표」 알약 + 선 규칙 「표에서 찾기」(lookup)
로직(키, 넘길 값=…) → 다른 로직 입력 상자(받는 알약 = 넘길 값 · 내보내는 알약 = 비목 · 결과)
만약 → IF · 그리고 / 또는 → AND / OR · 최소 / 최대 → MIN / MAX
· 올림 / 버림 / 반올림 → ROUNDUP / ROUNDDOWN / ROUND
자동 배치 — 입력 왼쪽 → 함수 가운데(기댐 깊이마다 한 칸) → 출력 오른쪽 · 칸마다 위에서 아래로
부르는 꼴 — convert(row, ctx={key, file, master}) → {flow, notes} · row = 옛 로직 줄 · 파일 안 씀.
"""
from __future__ import annotations
import json
import re
from decimal import Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as store
mf = store.cm.mf
COSTS = ("노무비", "재료비", "경비")
OUTPUT_PORT = "값"
TABLE_PORT = "표"
COLORS = {"input": "#2f80ed", "function": "#7b4fd6", "output": "#27ae60"}
GROUP_OF = {"LB": "인력", "MT": "재료", "MP": "재료", "MO": "유가전력", "EQ": "기계"}
GROUP_OF |= {"FX": "환율", "RT": "요율"}
FUNCS = {"만약": "IF", "최소": "MIN", "최대": "MAX", "올림": "ROUNDUP", "버림": "ROUNDDOWN"}
FUNCS["반올림"] = "ROUND"
OPS = {"==": "=", "!=": "<>"}
JOINS = {"그리고": "AND", "또는": "OR"}
_HOLE = re.compile(r"\{([^{}]+)\}")
class Unsupported(Exception):
"""옮기지 못하는 꼴 — 쉬운 말 까닭."""
def plain(v: Any) -> Any:
"""JSON 값 — Decimal → int · float."""
if isinstance(v, Decimal):
return int(v) if v == v.to_integral_value() else float(v)
if isinstance(v, (list, tuple)):
return [plain(x) for x in v]
if isinstance(v, dict):
return {k: plain(x) for k, x in v.items()}
return v
def literal(v: Any) -> str:
if isinstance(v, str):
return '"' + v.replace('"', '""') + '"'
if isinstance(v, Decimal):
return format(v.normalize(), "f")
return str(plain(v))
class _Flow:
"""흐름 한 벌 — 상자 · 선 · 못 옮긴 곳."""
def __init__(self, row: dict, master: Any):
self.row = row
self.master = master
self.specs = {s["이름"]: s for s in row.get("입력") or []}
self.boxes: dict[str, dict] = {}
self.links: list[dict] = []
self.notes: list[str] = []
self.named: dict[str, dict] = {} # 중간 · 덧줄 이름 → 내보내는 알약
self.cache: dict[str, dict] = {} # 같은 요소 · 표 · 로직 · 값 → 알약(하나만)
# ── 상자 · 선 ─────────────────────────────────────────────────────
def box(self, kind: str, name: str, **body: Any) -> dict:
bid = f"{kind[:2]}-{len(self.boxes) + 1}"
self.boxes[bid] = {"id": bid, "kind": kind, "name": name, "color": COLORS[kind]}
self.boxes[bid] |= {"unit": "-", "x": 0, "y": 0, **body}
return self.boxes[bid]
def link(self, src: dict, to: dict, rule: dict | None = None) -> None:
one = {"id": f"l{len(self.links) + 1}", "from": dict(src), "to": dict(to)}
self.links.append(one | ({"rule": rule} if rule else {}))
def design(self) -> None:
"""설계 값 상자 — 예시값 = 줄의 시험입력 · 없으면 왼쪽 시험 계산 기본값(master_auto)."""
given = store.mau.values(self.master, self.row)
given.update(self.row.get("시험입력") or {})
for (
k,
v,
) in given.items(): # 글로 적힌 수 → 수(고르기 값은 그대로 · 옛 엔진 _inputs 와 같음)
spec = self.specs.get(k) or {}
if isinstance(v, str) and "고르기" not in spec:
try:
given[k] = Decimal(v.strip())
except ArithmeticError:
pass
variables = []
for s in self.row.get("입력") or []:
pick = s.get("고르기")
one = {"name": s["이름"], "type": "choice" if pick else "number"}
one |= {"unit": str(s.get("단위") or "-"), "example": plain(given.get(s["이름"]))}
if pick:
one["choices"] = plain(pick)
if "범위" in s:
one["range"] = plain(s["범위"])
variables.append(one)
box = {"id": "in-design", "kind": "input", "name": "설계 값", "color": COLORS["input"]}
inp = {"mode": "variable", "elements": [], "variables": variables}
self.boxes["in-design"] = box | {"unit": "-", "x": 0, "y": 0, "input": inp}
# ── 바깥 값 알약 ──────────────────────────────────────────────────
def element(
self, group: str, ref: str, unit: str, label: str, rule: dict | None = None, **more: Any
) -> dict:
"""그룹 입력 상자의 요소 알약 — 그룹마다 상자 하나 · 같은 ref · 단위 · 조건이면 알약 하나
· rule = 조건 찾기(CfMaterialRule · 알약 값은 Model depends 로 · 선 없음) · more = scope."""
key = f"el|{group}|{ref}|{unit}|{json.dumps(rule, ensure_ascii=False, sort_keys=True)}"
if key in self.cache:
return self.cache[key]
gkey = f"group|{group}"
if gkey not in self.cache:
inp = {"mode": "group", "group": group, "elements": [], "variables": []}
self.cache[gkey] = self.box("input", group, input=inp)
els = self.cache[gkey]["input"]["elements"]
name = label
while any(e["name"] == name or e.get("slot") == name for e in els):
name += "·"
one = {"ref": ref, "name": name, "spec": "", "unit": unit or "-", "mode": "fixed"}
if rule:
one |= {"mode": "rule", "rule": rule, "slot": name, **more}
els.append(one)
self.cache[key] = {"box": self.cache[gkey]["id"], "name": name}
return self.cache[key]
def priced(self, group: str, ref: Any, unit: str, label: str, field: str | None = None):
"""요소 → 식 마디 「@port」 — 키에 {입력} 이 끼면 조건 찾기(field 키 · 같음 · from 알약)
· 자리 표시가 여럿이면 함수 상자가 글을 이어 만든 알약 하나로(정함 · 조사 ③)."""
if isinstance(ref, dict):
texts = {k: v for k, v in ref.items() if isinstance(v, str) and _HOLE.search(v)}
else:
texts = {None: ref} if _HOLE.search(ref) else {}
if not texts:
text = ref if isinstance(ref, str) else json.dumps(plain(ref), ensure_ascii=False)
return ("@port", self.element(group, self._key(text, field), unit, label))
if len(texts) > 1:
raise Unsupported(f"자리 표시가 여러 칸에 낀 요소 「{ref}」")
((at, text),) = texts.items()
if at == "검색어": # 재료 검색어 틀 — 자리마다 알약 · 서버는 옛 검색어 찾기(정함 · sub4)
names = list(dict.fromkeys(_HOLE.findall(text)))
slots = [{"name": n, "from": self.hole(n)} for n in names]
rule = {"field": "검색어", "op": "같음", "from": slots[0]["from"]}
rule |= {"template": text, "slots": slots}
scope = {k: str(v) for k, v in ref.items() if k in ("구분", "상세구분") and v}
tpl = json.dumps(plain(ref), ensure_ascii=False)
return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule, scope=scope))
# 찾는 값 = 원문번호(「EQ:{기계}」 → 기계 · 「MO:경유:{지역}」 → "경유:"&지역)
want = text.partition(":")[2] if at is None and ":" in text else text
if _HOLE.fullmatch(want):
src = self.hole(want[1:-1])
else:
src = self.func(f"{label} 찾는 말", ("@text", want))
tpl = text if at is None else json.dumps(plain(ref), ensure_ascii=False)
tpl += f"#{field}" if field else ""
rule = {"field": "키", "op": "같음", "from": src}
return ("@port", self.element(group, tpl, unit, f"{label} 자리", rule))
def hole(self, name: str) -> dict:
"""자리 표시 {이름} 의 값 알약 — 입력 · 중간."""
if name in self.specs:
return {"box": "in-design", "name": name}
if name in self.named:
return self.named[name]
raise Unsupported(f"자리 표시 {{{name}}} 의 값이 없음")
def _key(self, ref: str, field: str | None) -> str:
"""「ID:원문번호」 → 키 · 기계 칸은 「EQ키#칸」(입력 판 꼴)."""
if ":" in ref and not ref.lstrip().startswith("{"):
ref = str(self.master.get(ref).get("키"))
if field and ref[:2] != "EQ":
raise Unsupported(f"「{ref}」 의 칸 「{field}」 읽기")
return f"{ref}#{field}" if field else ref
def table(self, key: str) -> dict:
if f"tb|{key}" not in self.cache:
group = "소요량" if key[:2] in ("QF", "QC") else "계수"
inp = {"mode": "group", "group": group, "elements": [], "variables": []}
box = self.box("input", f"표 {key}", input=inp | {"table": {"key": key}})
self.cache[f"tb|{key}"] = {"box": box["id"], "name": TABLE_PORT}
return self.cache[f"tb|{key}"]
def logic(self, node: tuple) -> str:
"""로직(키, 넘길 값) → 다른 로직 입력 상자 id(같은 부르기는 하나)."""
key = "lg|" + repr(node)
if key not in self.cache:
head = node[1]
if "{" in head:
raise Unsupported(f"입력 따라 바뀌는 로직 「{head}」")
ref = str(self.master.logic(head).get("키"))
outs = [{"name": n, "unit": "원"} for n in (*COSTS, "결과")]
designs = [{"name": k, "unit": "-"} for k, _ in node[2]]
logic = {"key": ref, "outputs": outs, "designs": designs}
box = self.box("input", f"로직 {ref}", input={"mode": "logic", "elements": []})
box["input"] |= {"variables": [], "logic": logic}
for k, v in node[2]:
self.link(self.value(v), {"box": box["id"], "name": k})
self.cache[key] = {"box": box["id"]}
return self.cache[key]["box"]
def value(self, node: tuple) -> dict:
"""조건 · 넘길 값 → 내보내는 알약(입력 · 중간 · 아니면 함수 상자 · 같은 식은 하나)."""
if node[0] == "name" and node[1] in self.specs:
return {"box": "in-design", "name": node[1]}
if node[0] == "name" and node[1] in self.named:
return self.named[node[1]]
key = "val|" + repr(node)
if key not in self.cache:
label = literal(node[1]).strip('"') if node[0] in ("num", "str") else "값"
self.cache[key] = self.func(label, node)
return self.cache[key]
# ── 함수 상자 ─────────────────────────────────────────────────────
def func(self, name: str, node: tuple, scope: dict | None = None) -> dict:
"""식 하나 → 함수 상자(결과 하나) · 받는 알약마다 B2 · B3 … · 결과 칸은 그 아래."""
bid = f"fn-{len(self.boxes) + 1}"
self.boxes[bid] = {} # 자리 먼저 — 안에서 만드는 상자와 id 가 겹치지 않게
inputs: list[dict] = []
seen: dict[str, str] = {}
def take(src: dict, label: str, rule: dict | None = None) -> str:
k = json.dumps([src, rule], ensure_ascii=False, sort_keys=True, default=str)
if k not in seen:
port = label
while any(i["port"] == port for i in inputs):
port += "·"
seen[k] = f"B{len(inputs) + 2}"
inputs.append({"port": port, "cell": seen[k]})
self.link(src, {"box": bid, "name": port}, rule)
return seen[k]
formula = self.excel(node, take, scope or {})
out = f"B{len(inputs) + 2}"
cells: dict[str, Any] = {"A1": {"값": "알약"}, "B1": {"값": "값"}}
for i in inputs:
cells["A" + i["cell"][1:]] = {"값": i["port"]}
cells["A" + out[1:]] = {"값": name}
cells[out] = {"식": formula}
sheet = {"id": f"s-{bid}", "이름": name[:31] or bid, "칸": cells}
func = {"sheet": sheet, "inputs": inputs, "variables": []}
func["output"] = {"cell": out, "name": name, "unit": "-"}
body = {"id": bid, "kind": "function", "name": name, "color": COLORS["function"]}
self.boxes[bid] = body | {"unit": "-", "x": 0, "y": 0, "func": func}
return {"box": bid, "name": name}
def excel(self, node: tuple, take, scope: dict) -> str: # noqa: C901 — 마디 갈래 그대로
kind = node[0]
if kind in ("num", "str"):
return literal(node[1])
if kind == "neg":
return f"-({self.excel(node[1], take, scope)})"
if kind == "bin":
a, b = (self.excel(x, take, scope) for x in node[2:4])
if node[1] in JOINS:
return f"{JOINS[node[1]]}({a},{b})"
return f"({a}{OPS.get(node[1], node[1])}{b})"
if kind == "call":
items = [self.excel(x, take, scope) for x in node[2]]
if node[1] in ("올림", "버림", "반올림") and len(items) == 1:
items.append("0")
return f"{FUNCS[node[1]]}({','.join(items)})"
if kind == "name":
return self.name(node[1], take, scope)
if kind == "ref":
group = GROUP_OF.get(node[1][:2], "기계")
return self.excel(self.priced(group, node[1], "", node[1]), take, scope)
if kind == "field":
return self.field(node, take, scope)
if kind == "logic":
return take({"box": self.logic(node), "name": "결과"}, f"{node[1]} 결과")
if kind == "@port":
return take(node[1], node[1]["name"])
if kind == "@sum0": # 비목 합 — 빈 줄(빈 단가 · 못 셈 덧줄)은 0(옛 엔진 sums += 0)
cells = [f"IFERROR({take(p, p['name'])},0)" for p in node[1]]
return f"SUM({','.join(cells)})" if cells else "0"
if kind == "@line": # 호표 줄 수량 × 단가 — 수량 0 이면 0(옛 엔진 · 빈 단가여도)
q, p = self.excel(node[1], take, scope), self.excel(node[2], take, scope)
return f"IF({q}=0,0,{q}*{p})"
if kind == "@sum":
cells = [take(p, p["name"]) for p in node[1]]
return f"SUM({','.join(cells)})" if cells else "0"
if kind == "@text": # 자리 표시 글 「레미콘 {a}-{b}」 → "레미콘 "&B2&"-"&B3
bits = _HOLE.split(node[1])
out = [literal(b) if i % 2 == 0 else take(self.hole(b), b) for i, b in enumerate(bits)]
return "&".join(x for x in out if x != '""') or '""'
raise Unsupported(f"모르는 식 마디 「{kind}」")
def name(self, name: str, take, scope: dict) -> str:
if name in scope: # 덧줄의 비목 합(빈 줄 = 0) · 줄 금액(빈 줄이면 빈 값 — 옛 env 「줄」)
return self.excel(("@sum0" if name in COSTS else "@sum", scope[name]), take, scope)
if name in self.named:
return take(self.named[name], name)
if name in self.specs:
return take({"box": "in-design", "name": name}, name)
raise Unsupported(f"모르는 이름 「{name}」")
def field(self, node: tuple, take, scope: dict) -> str:
base, col = node[1], node[2]
if base[0] == "find":
if "{" in base[1]:
raise Unsupported(f"입력 따라 바뀌는 표 「{base[1]}」")
key = str(self.master.get(base[1]).get("키"))
conds = [{"col": k, "from": self.value(v)} for k, v in base[2]]
return take(self.table(key), col, {"kind": "lookup", "conds": conds, "col": col})
if base[0] == "ref":
group = GROUP_OF.get(base[1][:2], "기계")
node2 = self.priced(group, base[1], "", f"{base[1]} {col}", col)
return self.excel(node2, take, scope)
if base[0] == "logic" and col == "계": # 다른 로직 출력은 비목 셋 — 계 = 그 합
bid = self.logic(base)
return self.excel(("@sum", [{"box": bid, "name": k} for k in COSTS]), take, scope)
if base[0] == "logic":
return take({"box": self.logic(base), "name": col}, f"{base[1]} {col}")
if base[0] == "name" and base[1] == "줄" and f"줄.{col}" in scope:
return self.name(f"줄.{col}", take, scope)
raise Unsupported(f"칸 읽기 「{col}」")
# ── 한 로직 ──────────────────────────────────────────────────────────
def convert(row: dict, ctx: dict | None = None) -> dict:
"""옛 로직 줄 → {flow: CfLogic, notes: 못 옮긴 곳} · ctx = {key, file, master}(M01 저장소
/api/m01/logic/flow) · master 가 없으면 저장소가 읽어 둔 것."""
master = (ctx or {}).get("master") or store.loaded()[1]
f = _Flow(row, master)
f.design()
try:
_body(f, row)
except Unsupported as e:
f.notes.append(f"못 옮김 — {e}")
except mf.FormulaError as e:
f.notes.append(f"못 옮김 — {e}")
f.boxes = {k: b for k, b in f.boxes.items() if b} # 못 옮겨 비운 자리
_layout(f)
flow = {"key": row.get("키"), "name": row.get("이름") or ""}
flow |= {"resultUnit": row.get("결과단위") or "", "boxes": list(f.boxes.values())}
flow |= {"links": f.links, "userElements": []}
flow["totalRound"] = None # 옛 계는 끝수 없음(줄.끝수 묶음은 출력 상자 끝수로 실음)
return {"flow": flow, "notes": list(dict.fromkeys(f.notes))}
def _output(f: _Flow, name: str, cost: str, src: dict, rule: dict | None, on: str) -> None:
out = {"cost": cost, "unit": "원" if cost != "값" else "-"}
if rule and rule.get("대상") == on:
out["round"] = {"places": int(rule.get("자리") or 0), "method": str(rule.get("방법"))}
box = f.box("output", name, output=out)
f.link(src, {"box": box["id"], "name": OUTPUT_PORT})
def _body(f: _Flow, row: dict) -> None:
rule = row.get("끝수") if isinstance(row.get("끝수"), dict) else None
for step in row.get("중간") or []:
f.named[step["이름"]] = f.func(step["이름"], mf.parse(step["식"]))
if "결과" in row:
_output(f, "결과", "값", f.func("결과", mf.parse(row["결과"]["식"])), rule, "결과")
return
parts: dict[str, list[dict]] = {c: [] for c in COSTS}
lines: dict[str, list[dict]] = {}
for i, item in enumerate(row.get("호표") or []):
name = str(item.get("이름") or f"줄 {i + 1}")
qty = mf.parse(str(item["수량"]))
if item.get("종류") == "로직":
call = mf.parse(str(item["요소"]))
if call[0] != "logic":
raise Unsupported(f"「{name}」 단가가 로직 부르기 꼴 아님")
bid = f.logic(call)
whole = ("@sum", [{"box": bid, "name": k} for k in COSTS])
for c in [item["비목"]] if item.get("비목") else COSTS:
price = whole if item.get("비목") else ("@port", {"box": bid, "name": c})
part = f.func(f"{name} {c}", ("bin", "*", qty, price))
parts[c].append(part)
lines.setdefault(f"줄.{name}", []).append(part)
continue
unit = str(item.get("단위") or "")
ref = item["요소"] # 그룹 = 키 앞 두 글자(「MO:경유:{지역}」 = 유가전력) · 없으면 줄 종류
group = GROUP_OF.get(ref[:2], str(item.get("종류"))) if isinstance(ref, str) else "재료"
price = f.priced(group, ref, unit, f"{name} 단가")
part = f.func(f"{name} 금액", ("@line", qty, price))
parts[item["비목"]].append(part)
lines.setdefault(f"줄.{name}", []).append(part)
for extra in row.get("덧줄") or []:
scope = {c: list(parts[c]) for c in COSTS} | lines
part = f.func(extra["이름"], mf.parse(extra["식"]), scope)
f.named[extra["이름"]] = part
parts[extra["비목"]].append(part)
for c in COSTS:
if parts[c]:
_output(f, c, c, f.func(f"{c} 합", ("@sum0", parts[c])), rule, "계")
# ── 자동 배치 ────────────────────────────────────────────────────────
def _layout(f: _Flow) -> None:
"""입력 왼쪽 → 함수 가운데(기댐 깊이마다 한 칸) → 출력 오른쪽 · 칸마다 위→아래(겹침 없음)."""
feeds: dict[str, set[str]] = {}
for ln in f.links:
srcs = [ln["from"], *[c["from"] for c in (ln.get("rule") or {}).get("conds", [])]]
feeds.setdefault(ln["to"]["box"], set()).update(s["box"] for s in srcs)
for bid, b in f.boxes.items(): # 조건 찾기 요소 — 값을 주는 알약 상자 뒤
for e in (b.get("input") or {}).get("elements") or []:
rule = e.get("rule") or {}
srcs = [rule.get("from"), *[x["from"] for x in rule.get("slots") or []]]
feeds.setdefault(bid, set()).update(p["box"] for p in srcs if p)
depth: dict[str, int] = {}
def level(bid: str, path: frozenset = frozenset()) -> int:
if bid not in depth:
ins = [x for x in feeds.get(bid, ()) if x not in path and x in f.boxes]
depth[bid] = 1 + max((level(x, path | {bid}) for x in ins), default=-1)
return depth[bid]
for bid in f.boxes:
level(bid)
last = max(depth.values(), default=0) + 1
rows: dict[int, int] = {}
for bid, box in f.boxes.items():
col = last if box["kind"] == "output" else depth[bid]
box["x"], box["y"] = 40 + col * 300, 40 + rows.get(col, 0) * 170
rows[col] = rows.get(col, 0) + 1
@@ -1,140 +0,0 @@
/* =============================================================================
* M02_DF_Detail.ts
* 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 끊김 글 · 흐름 줄 · DOM 없음
* 셈은 모델 것 그대로(Eval 의 evalLogic · breaks · Model 의 traceBack · traceForward · Rule 의 ruleLabel)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
breaks,
evalLogic,
type CfBreak,
type CfEvalOptions,
type CfEvalResult,
type CfValue,
type CfValues,
} from "./M02_DF_Eval";
import {
portDir,
portsOut,
traceBack,
traceForward,
type CfBox,
type CfGateOp,
type CfLink,
type CfLogic,
type CfPort,
type CfRule,
type CfStep,
} from "./M02_DF_Model";
import { ruleLabel } from "./M02_DF_Rule";
const TEXT = {
Table: ["표 {n}줄", "table, {n} rows"],
Empty: ["값 없음", "no value"],
NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"],
} as const;
export const td = (key: keyof typeof TEXT, fill: Record<string, string | number> = {}): string =>
Object.entries(fill).reduce(
(s, [k, v]) => s.replace(`{${k}}`, String(v)),
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
);
export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"];
export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"];
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
export interface DetailOptions {
values?: CfValues;
logics?: CfEvalOptions["logics"];
}
export const boxOf = (lg: CfLogic, id: string): CfBox | undefined =>
lg.boxes.find((b) => b.id === id);
export const portText = (lg: CfLogic, p: CfPort): string =>
`${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`;
/** 계산 한 번 — 한창 만드는 중 막히면 null */
export function evalSafe(lg: CfLogic, o: DetailOptions = {}): CfEvalResult | null {
try {
return evalLogic(lg, o.values, { logics: o.logics });
} catch {
return null;
}
}
export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined =>
ev?.ports[p.box]?.[p.name];
/** 숫자 글 — 천 단위 쉼표 · 군더더기 소수는 뗌 */
export const commas = (n: number): string =>
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 화면에 보일 값 글 — 표는 줄 수 */
export function showValue(v: CfValue | undefined): string {
if (v === undefined || v === null) return td("Empty");
if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) });
return typeof v === "number" ? commas(v) : String(v);
}
export function unitOf(lg: CfLogic, p: CfPort): string {
const box = boxOf(lg, p.box);
return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-";
}
/** 알약이 나가는(out) 쪽인가 */
export const isOut = (box: CfBox, name: string): boolean => portDir(box, name) === "out";
/** 선 위 규칙 글 */
export const labelOf = (rule: CfRule | undefined): string =>
rule ? ruleLabel(rule) : td("NoRule");
/** 지나가기 전 값 → 지난 뒤 값 — 지난 뒤 값은 셈이 받는 알약에 실제로 들여보낸 것 · 못 들어갔으면 끊긴 까닭 */
export function preview(link: CfLink, ev: CfEvalResult | null): { before: string; after: string } {
const before = showValue(valueOf(ev, link.from));
if (!link.rule) return { before, after: before };
const arrived = ev?.arrived?.[link.to.box]?.[link.to.name];
if (arrived !== undefined && arrived !== null) return { before, after: showValue(arrived) };
const why = ev?.breaks?.find((b) => b.link === link.id)?.text;
return { before, after: why ?? td("Empty") };
}
/** 이 선에서 알릴 것 — 이 선이 막힌 까닭 · 같은 알약에서 나간 조건 선의 겹침 · 안 맞음 */
export function linkNotes(link: CfLink, ev: CfEvalResult | null): string[] {
return (ev?.breaks ?? [])
.filter(
(b) =>
b.link === link.id ||
((b.kind === "조건 겹침" || b.kind === "조건 안 맞음") &&
b.box === link.from.box &&
b.port === link.from.name),
)
.map((b) => b.text);
}
/** 온 길(dir "in") · 가는 길(dir "out") — 모델 것에서 자기 자신 줄은 뺌 */
export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] {
const steps = dir === "in" ? traceBack(lg, p) : traceForward(lg, p);
return steps.filter((s) => !(s.box === p.box && s.port === p.name));
}
/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */
export function breakLines(
lg: CfLogic,
boxId: string,
name?: string,
o: DetailOptions = {},
): string[] {
let list: CfBreak[] = [];
try {
list = breaks(lg, o.values, { logics: o.logics });
} catch {
// 만드는 중 셈이 막히면 끊긴 곳 없음으로 둠
}
return list
.filter((b) => b.box === boxId && (!name || !b.port || b.port === name))
.map((b) => b.text);
}
@@ -1,499 +0,0 @@
/* 단가산출 다이어그램 — 자유 배치 */
.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__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: default;
touch-action: none;
/* 왼쪽 끌기로 글자가 잡히지 않게 */
user-select: none;
-webkit-user-select: none;
}
.m02-dfdg__view.is-panning {
cursor: grabbing;
}
.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);
}
/* ── 흐름도 더함: 선 규칙 이름표 · 흐름 길 · 끊긴 곳 · 알약 고름 ── */
.m02-dfdg {
--dfdg-back: var(--color-accent);
--dfdg-fwd: #c2410c;
}
:root[data-theme="dark"] .m02-dfdg {
--dfdg-fwd: #f0a35e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .m02-dfdg {
--dfdg-fwd: #f0a35e;
}
}
.m02-dfdg__rule rect {
fill: var(--color-surface);
stroke: var(--color-border);
}
.m02-dfdg__rule text {
fill: var(--color-text-secondary);
font-size: 11px;
}
.m02-dfdg__link.is-strong .m02-dfdg__rule rect {
stroke: var(--color-accent);
}
/* 흐름 길 — 색 + 굵기 + 점선(색만으로 뜻을 안 전함) */
.m02-dfdg__link.is-back .m02-dfdg__wire {
stroke: var(--dfdg-back);
stroke-width: 3.5;
}
.m02-dfdg__link.is-fwd .m02-dfdg__wire {
stroke: var(--dfdg-fwd);
stroke-width: 3.5;
stroke-dasharray: 8 5;
}
.m02-dfdg__link.is-dim .m02-dfdg__wire,
.m02-dfdg__link.is-dim .m02-dfdg__rule {
opacity: 0.25;
}
.m02-dfdg__box.is-back {
outline: 3px solid var(--dfdg-back);
outline-offset: 2px;
}
.m02-dfdg__box.is-fwd {
outline: 3px dashed var(--dfdg-fwd);
outline-offset: 2px;
}
.m02-dfdg__box.is-dim {
opacity: 0.45;
}
.m02-dfdg__pill.is-back {
box-shadow: 0 0 0 2px var(--dfdg-back);
}
.m02-dfdg__pill.is-fwd {
outline: 2px dashed var(--dfdg-fwd);
outline-offset: 1px;
}
.m02-dfdg__legend {
padding: 0 var(--spacing-8);
border-radius: 999px;
font-size: var(--text-caption);
}
.m02-dfdg__legend.is-back {
border: 2px solid var(--dfdg-back);
color: var(--dfdg-back);
}
.m02-dfdg__legend.is-fwd {
border: 2px dashed var(--dfdg-fwd);
color: var(--dfdg-fwd);
}
/* 고른 알약 테두리 */
.m02-dfdg__pill.is-chosen {
box-shadow: 0 0 0 2px var(--color-text);
}
/* 끊긴 곳 */
.m02-dfdg__warn {
position: absolute;
top: -9px;
right: -9px;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--color-danger);
color: #fff;
font: inherit;
font-size: 12px;
font-weight: 700;
line-height: 18px;
cursor: pointer;
}
.m02-dfdg__pill.is-broken {
border-color: var(--color-danger);
}
.m02-dfdg__pill-warn {
color: var(--color-danger);
font-weight: 700;
}
.m02-dfdg__linkwarn circle {
fill: var(--color-danger);
}
.m02-dfdg__linkwarn text {
fill: #fff;
font-size: 11px;
font-weight: 700;
}
/* 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 + 화살 */
.m02-dfdg__dep {
pointer-events: auto;
cursor: pointer;
}
.m02-dfdg__depwire {
fill: none;
stroke: color-mix(in srgb, var(--color-text-muted) 70%, transparent);
stroke-width: 1.5;
stroke-dasharray: 3 4;
}
.m02-dfdg__arrow {
fill: var(--color-text-muted);
}
.m02-dfdg__deptext rect {
fill: var(--color-canvas);
stroke: none;
opacity: 0.85;
}
.m02-dfdg__deptext text {
fill: var(--color-text-muted);
font-size: 10px;
}
.m02-dfdg__dep.is-strong .m02-dfdg__depwire {
stroke: var(--color-accent);
stroke-width: 2.5;
}
.m02-dfdg__dep.is-back .m02-dfdg__depwire {
stroke: var(--dfdg-back);
stroke-width: 2.5;
}
.m02-dfdg__dep.is-fwd .m02-dfdg__depwire {
stroke: var(--dfdg-fwd);
stroke-width: 2.5;
}
.m02-dfdg__dep.is-dim {
opacity: 0.25;
}
/* 쉼 — 조건이 안 맞아 이번엔 안 지나감(문제 아님) */
.m02-dfdg__link.is-rest .m02-dfdg__wire {
stroke: var(--color-text-muted);
stroke-width: 1.5;
stroke-dasharray: 2 5;
}
.m02-dfdg__restmark rect {
fill: var(--color-surface);
stroke: var(--color-text-muted);
stroke-dasharray: 2 2;
}
.m02-dfdg__restmark text {
fill: var(--color-text-muted);
font-size: 10px;
}
.m02-dfdg__box.is-rest {
opacity: 0.5;
}
/* 빈 판 안내 — 가운데 세 줄 */
.m02-dfdg__empty {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
padding: var(--spacing-16) var(--spacing-24);
list-style: none;
border: 1px dashed var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text-secondary);
display: flex;
flex-direction: column;
gap: var(--spacing-8);
pointer-events: none;
}
/* 쉼 — 받는 알약만(상자 흐림은 들어오는 선이 모두 쉼일 때) */
.m02-dfdg__pill.is-in.is-rest {
border-style: dashed;
border-color: var(--color-text-muted);
color: var(--color-text-muted);
opacity: 0.7;
}
@@ -1,697 +0,0 @@
/* =============================================================================
* M02_DF_Diagram.ts
* 단가산출 흐름도 — 다이어그램(흐름도 정함 7 · 8 · 13 · 1판 「상자 · 선 약속」)
* 자유 배치 상자(입력 · 함수 · 출력) · 굽은 선 + 가운데 선 규칙 이름표 · 흐름 길(온 길 · 가는 길) 칠하기 ·
* 끊긴 곳 표시 · 알약 · 선 · 상자 고르기. 마우스 — 판 옮기기 = 가운데 단추 끌기(또는 Space + 왼쪽 끌기) ·
* 고르기 = 왼쪽 누름 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 휠 = 확대 · 글자 선택 막음.
* 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`.
* ========================================================================== */
import { wheelZoomsIn } from "../../A00_Common/touch_bridge";
import { guardMenuAfterDrag, isAuxPanButton } from "../../A00_Common/input_prefs";
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model";
import {
arrange,
clampZoom,
fitView,
KIND_COLOR,
depEnd,
depInTrace,
depPath,
follow,
depLines,
isRest,
restParts,
linkLabel,
midOf,
pillInfo,
svgAdd as add,
svgLabel as label,
pillsOf,
resolveDrop,
showValue,
traceOf,
tt,
type DfPillSel,
type DiagramOn,
type DiagramOpts,
type TEXT,
} from "./M02_DF_Diagram_Parts";
import "./M02_DF_Diagram.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_toolbar.css";
export * from "./M02_DF_Diagram_Parts";
interface HostState {
selected: string | null;
linking: CfPort | null;
pill: DfPillSel | null;
note: string;
zoom: number;
panX: number;
panY: number;
fitted: boolean;
fitKey?: number;
watch?: ResizeObserver;
}
const states = new WeakMap<HTMLElement, HostState>();
const SVG = "http://www.w3.org/2000/svg";
/** Space 를 누르고 있음 — 터치패드용 판 옮기기 */
let spaceDown = false;
if (typeof window !== "undefined") {
window.addEventListener("keydown", (e) => {
if (e.code === "Space" && !(e.target as HTMLElement)?.closest?.("input, textarea, select"))
spaceDown = true;
});
window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false));
window.addEventListener("blur", () => (spaceDown = false));
}
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;
if (opts.fitKey !== undefined && opts.fitKey !== st.fitKey)
[st.fitKey, st.fitted] = [opts.fitKey, false];
const ro = !!opts.readOnly; // 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음(고르기 · 흐름 따라가기는 됨)
if (opts.selectedPill !== undefined) st.pill = opts.selectedPill;
states.set(host, st);
const values = opts.values ?? {};
const labels = opts.labels ?? {};
const allBreaks = opts.breaks ?? [];
/** 「!」 는 문제에만 · 쉼(조건이 안 맞아 이번엔 안 지나감)은 회색 점선 선 + 받는 상자 흐리게 */
const breaks = allBreaks.filter((k) => !isRest(k));
const rests = allBreaks.filter(isRest);
const {
links: restLinks,
pills: restPills,
boxes: restBoxes,
} = restParts(lg, rests, 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, selectedPill: st.pill });
const select = (id: string | null, pill?: DfPillSel, note = ""): void => {
st.selected = pill ? null : id;
st.pill = pill ?? null;
st.note = note;
on.select(id, pill);
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 markOf = (pick: Parameters<typeof traceOf>[1]): string => {
const m = traceOf(opts.trace, pick);
return m ? ` is-${m}` : "";
};
const warn = (why: string, onClick: () => void): HTMLElement => {
const b = el("button", {
className: "m02-dfdg__warn",
text: "!",
attrs: { type: "button", title: why, "aria-label": `${tt("Break")} · ${why}` },
});
b.addEventListener("pointerdown", (ev) => ev.stopPropagation());
b.addEventListener("click", (ev) => {
ev.stopPropagation();
onClick();
});
return b;
};
/* 판 · 세상(확대 · 옮김을 한 번에) */
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 zoomLabel = el("span", { className: "m02-dfdg__zoom" });
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.port === p.name && st.pill.dir === dir;
const broken = breaks.find((k) => !k.link && k.box === b.id && k.port === p.name);
const sel: DfPillSel = { box: b.id, port: p.name, dir };
const btn = el("button", {
className:
[
"m02-dfdg__pill",
`is-${dir}`,
wired ? "" : "is-empty",
chosen ? "is-chosen" : "",
broken ? "is-broken" : "",
dir === "in" && restPills.has(`${b.id}|${p.name}`) ? "is-rest" : "",
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)),
attrs: {
type: "button",
"data-box": b.id,
"data-name": p.name,
"data-dir": dir,
title: `${p.name} (${p.unit || "-"})`,
},
children: [
...(broken ? [el("span", { className: "m02-dfdg__pill-warn", text: "!" })] : []),
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.linking = null;
if (chosen) select(null);
else select(b.id, sel, broken?.text ?? "");
});
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 kind = tt(`K_${b.kind}` as keyof typeof TEXT);
const broken = breaks.filter((k) => !k.link && !k.port && k.box === b.id);
const head = el("div", {
className: "m02-dfdg__head",
children: [
el("span", { className: "m02-dfdg__kind", text: kind }),
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" : ""}` +
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))) +
(restBoxes.has(b.id) ? " is-rest" : ""),
attrs: {
"data-box": b.id,
tabindex: "0",
role: "button",
"aria-label": `${kind} · ${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" && !ro ? [plusSlot(b)] : []),
],
}),
el("div", {
className: "m02-dfdg__outs",
children: outs.map((p) => pillEl(b, p, "out")),
}),
],
}),
...(broken.length
? [
warn(broken.map((k) => k.text).join(" · "), () =>
select(b.id, undefined, broken.map((k) => k.text).join(" · ")),
),
]
: []),
],
});
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.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 && !ro) {
ev.preventDefault();
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
}
});
head.addEventListener("pointerdown", (ev) => {
if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node);
});
return node;
};
world.append(...lg.boxes.map(boxEl));
/* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */
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();
};
/** 맞추기 — 저절로(처음 · 다시 맞춤)는 fitMin · 「한눈에」 단추는 fitAllMin(전체가 보이게) */
const fit = (button = false): 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 },
40,
button ? (opts.fitAllMin ?? opts.fitMin) : opts.fitMin,
),
);
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;
tools.push(el("span", { text: pillInfo(lg, p, values) }));
if (p.dir === "out" && !ro)
tools.push(
sm(
"Connect",
() => ((st.linking = { box: p.box, name: p.port }), (st.pill = null), redraw()),
"filled",
),
);
} else if (selLink) {
const rule = labels[selLink.id] ?? linkLabel(selLink);
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
tools.push(
el("span", { text: rule ? `${line} · ${rule}` : line }),
...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]),
);
} else if (selBox && !ro) {
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
}
const legend =
opts.trace && (opts.trace.back || opts.trace.forward)
? [
el("span", { className: "m02-dfdg__legend is-back", text: tt("Back") }),
el("span", { className: "m02-dfdg__legend is-fwd", text: tt("Forward") }),
]
: [];
const bar = el("div", {
className: "ui-toolbar m02-dfdg__bar",
children: [
el("strong", { className: "m02-dfdg__title", text: tt("Title") }),
...(ro
? []
: [
addBtn("input", "AddInput"),
addBtn("function", "AddFunc"),
addBtn("output", "AddOutput"),
]),
el("span", { className: "m02-dfdg__gap" }),
...legend,
sm("ZoomOut", () => setZoom(st.zoom / 1.2)),
zoomLabel,
sm("ZoomIn", () => setZoom(st.zoom * 1.2)),
sm("Fit", () => fit(true)),
...(ro
? []
: [
sm("Arrange", () =>
Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)),
),
]),
],
});
const root = el("div", {
className: `m02-dfdg${st.linking ? " is-linking" : ""}`,
children: [
bar,
el("div", {
className: "ui-toolbar m02-dfdg__tools",
children: [
...tools,
el("span", { className: "ui-text ui-text--error", text: st.note }),
el("span", {
className: "ui-text ui-text--muted",
text: tools.length || st.note ? "" : tt("Hint"),
}),
],
}),
view,
],
});
root.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && (st.linking || st.pill)) {
st.linking = null;
st.pill = null;
redraw();
}
if (ro || 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 * (wheelZoomsIn(ev) ? 1.1 : 1 / 1.1), ev.clientX, ev.clientY); // ☰ [설정] 방향(332)
},
{ passive: false },
);
// 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함(마우스)
view.addEventListener(
"pointerdown",
(ev) => {
// 손가락 빈 곳 끌기도 판 옮기기(기기대응 3-3)
const touchBlank = ev.pointerType === "touch" && (ev.target === view || ev.target === world);
if (isAuxPanButton(ev.button) || (ev.button === 0 && spaceDown) || touchBlank) {
guardMenuAfterDrag(ev);
ev.preventDefault();
ev.stopPropagation();
dragPan(ev);
}
},
{ capture: true },
);
view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault());
view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault());
if (!lg.boxes.length)
view.append(
el("ol", {
className: "m02-dfdg__empty",
children: (["Empty1", "Empty2", "Empty3"] as const).map((k) => el("li", { text: tt(k) })),
}),
);
host.replaceChildren(root);
applyView();
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 · 쉼 · 기댐 선 */
function drawLines(): void {
svg.replaceChildren();
const at = (end: CfPort, dir: "in" | "out", side: "near" | "far" = "near") => {
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();
const right = (dir === "out") === (side === "near");
return toWorld(right ? r.right : r.left, r.top + r.height / 2);
};
const defs = add(svg, "defs", {});
const arrow = add(defs, "marker", {
id: "m02-dfdg-arrow",
viewBox: "0 0 8 8",
refX: "7",
refY: "4",
markerWidth: "7",
markerHeight: "7",
orient: "auto",
});
add(arrow, "path", { d: "M0,0 L8,4 L0,8 z", class: "m02-dfdg__arrow" });
const dot = add(defs, "marker", {
id: "m02-dfdg-dot",
viewBox: "0 0 8 8",
refX: "4",
refY: "4",
markerWidth: "6",
markerHeight: "6",
});
add(dot, "circle", { cx: "4", cy: "4", r: "3", class: "m02-dfdg__arrow" });
const mids = new Map<string, { x: number; y: number; w: number }>();
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 traced = markOf((s) => s.some((x) => x.link === l.id));
const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음
const dim = !mark && st.selected !== null && !strong ? " is-dim" : "";
const rest = restLinks.has(l.id) ? " is-rest" : "";
const g = add(svg, "g", {
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}${rest}`,
"data-link": l.id,
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
});
add(g, "path", { d, class: "m02-dfdg__hit" });
add(g, "path", { d, class: "m02-dfdg__wire" });
const mid = midOf(a, b);
const rule = labels[l.id] ?? linkLabel(l);
mids.set(l.id, { ...mid, w: rule ? label(g, mid.x, mid.y, rule, "m02-dfdg__rule") : 0 });
if (rest) label(g, mid.x, mid.y + (rule ? 18 : 0), tt("Rest"), "m02-dfdg__restmark");
const broken = breaks.filter((k) => k.link === l.id);
const why = [...broken, ...rests.filter((k) => k.link === l.id)]
.map((k) => k.text)
.join(" · ");
if (broken.length) {
const cy = (mid.y - (rule ? 18 : 0)).toFixed(1);
const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why });
add(badge, "circle", { cx: mid.x.toFixed(1), cy, r: "8" });
const t = add(badge, "text", {
x: mid.x.toFixed(1),
y: cy,
"dominant-baseline": "central",
"text-anchor": "middle",
});
t.textContent = "!";
add(badge, "title", {}).textContent = why;
}
g.addEventListener("click", (ev) => {
ev.stopPropagation();
select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why);
});
}
// 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳 · 흐린 점선 · 화살
for (const dep of depLines(lg)) {
// 시작 = 조건 알약 오른쪽 · 끝 = 그 규칙 선의 이름표 가장자리 또는 받는 재료 알약 오른쪽
const a = at(dep.from, "out");
const tag = "link" in dep.to ? mids.get(dep.to.link) : null;
const pill = "box" in dep.to ? at({ box: dep.to.box, name: dep.to.port }, "out") : null;
const end = tag ? depEnd(a, tag) : pill && { ...pill, side: "right" as const };
if (!a || !end) continue;
const b = { x: end.x, y: end.y };
const d = depPath(a, b, end.side);
const where = "link" in dep.to ? dep.to.link : dep.to.box;
const steps = opts.trace;
const traced =
steps?.back && depInTrace(dep, steps.back)
? " is-back"
: steps?.forward && depInTrace(dep, steps.forward)
? " is-fwd"
: steps && (steps.back?.length || steps.forward?.length)
? " is-dim"
: "";
const g = add(svg, "g", {
class: `m02-dfdg__dep${traced}${st.selected === where ? " is-strong" : ""}`,
"data-dep": dep.id,
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
});
add(g, "path", { d, class: "m02-dfdg__hit" });
add(g, "path", {
d,
class: "m02-dfdg__depwire",
"marker-start": "url(#m02-dfdg-dot)",
"marker-end": "url(#m02-dfdg-arrow)",
});
const m = midOf(a, b);
label(g, m.x, m.y, dep.text, "m02-dfdg__deptext");
g.addEventListener("click", (ev) => {
ev.stopPropagation();
select(where);
});
}
}
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 dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
ev.preventDefault(); // 끌면서 글자가 잡히지 않게
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 };
view.classList.add("is-panning");
follow(
ev,
(m) => {
st.panX = p0.x + m.clientX - ev.clientX;
st.panY = p0.y + m.clientY - ev.clientY;
applyView();
},
() => view.classList.remove("is-panning"),
);
}
function dragLink(ev: PointerEvent, from: CfPort): void {
if (ev.button !== 0 || spaceDown || ro) return;
ev.stopPropagation();
ev.preventDefault();
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,
);
},
);
}
}
@@ -1,424 +0,0 @@
/* =============================================================================
* M02_DF_Diagram_Parts.ts
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
portsIn,
portsOut,
type CfBox,
type CfKind,
type CfLogic,
type CfPill,
type CfPort,
type CfLink,
type CfStep,
elementPort,
ruleSources,
} from "./M02_DF_Model";
import type { CfBreak } from "./M02_DF_Eval";
import { ruleLabel } from "./M02_DF_Rule";
export 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: [
"상자 머리를 끌어 옮김 · 가운데 버튼(또는 Space + 끌기)로 판 옮김 · 휠로 확대",
"Drag a head to move · middle button (or Space + drag) to pan · wheel to zoom",
],
Break: ["끊긴 곳", "Break"],
Back: ["온 길", "Came from"],
DepLookup: ["표 조건", "table key"],
DepGate: ["조건", "condition"],
DepMaterial: ["{f}에 {o}", "{f} {o}"],
Rest: ["쉼", "Resting"],
Empty1: ["① 「+ 입력」 으로 상자를 놓음", "① Place a box with “+ Input”"],
Empty2: ["② 알약을 끌어 다른 상자에 놓으면 선", "② Drag a pill onto another box to draw a line"],
Empty3: ["③ 선을 누르면 규칙", "③ Tap a line to set its rule"],
Forward: ["가는 길", "Goes to"],
} as const;
export 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 DfPillSel {
box: string;
port: string;
dir: "in" | "out";
}
export interface DiagramOn {
/** 상자 · 선 id · 없음 — 알약을 고르면 상자 id 와 알약 */
select(id: string | null, pill?: DfPillSel): 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;
/** 고른 알약 */
selectedPill?: DfPillSel | null;
/** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */
labels?: Record<string, string>;
/** 흐름 길 — Model traceBack · traceForward 걸음 */
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
/** 끊긴 곳 — Eval breaks */
breaks?: CfBreak[];
/** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */
readOnly?: boolean;
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
fitKey?: number;
/** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */
fitMin?: number;
/** 「한눈에」 단추로 맞출 때 가장 작은 확대 — 없으면 fitMin */
fitAllMin?: number;
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
/** 알약 — 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;
export const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
export function fitView(
rects: { x: number; y: number; w: number; h: number }[],
view: { w: number; h: number },
pad = 40,
min = FIT_MIN,
): { zoom: number; panX: number; panY: number } {
if (!rects.length) return { zoom: 1, panX: 0, panY: 0 };
const x0 = Math.min(...rects.map((r) => r.x)) - pad;
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
const zoom = clampZoom(Math.max(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) };
}
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 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 type TraceMark = "back" | "fwd" | "dim" | "";
export function traceOf(
trace: DiagramOpts["trace"],
pick: (steps: CfStep[]) => boolean,
): TraceMark {
if (!trace || (!trace.back?.length && !trace.forward?.length)) return "";
if (trace.back && pick(trace.back)) return "back";
if (trace.forward && pick(trace.forward)) return "fwd";
return "dim";
}
/** 선 위 규칙 글 — Rule 의 ruleLabel · 규칙 없으면 「」 */
export const linkLabel = (link: CfLink): string => (link.rule ? ruleLabel(link.rule) : "");
/** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 t = 0.5 가 두 끝의 가운데 */
export const midOf = (
a: { x: number; y: number },
b: { x: number; y: number },
): { x: number; y: number } => ({
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
});
/* ── 기댐 선 · 쉼 ──────────────────────────────────────────────────── */
/** 기댐 선 — 규칙이 기대는 알약 → 그 규칙이 있는 곳(선이면 그 선 이름표 · 재료 조건 찾기면 그 재료 알약) */
export interface DepLine {
id: string;
from: CfPort;
to: { link: string } | { box: string; port: string };
text: string;
}
export function depLines(lg: CfLogic): DepLine[] {
const out: DepLine[] = [];
for (const l of lg.links)
ruleSources(l.rule).forEach((from, i) =>
out.push({
id: `dep:${l.id}:${i}`,
from,
to: { link: l.id },
text: tt(l.rule?.kind === "lookup" ? "DepLookup" : "DepGate"),
}),
);
for (const b of lg.boxes)
for (const e of b.input?.elements ?? [])
if (e.mode === "rule" && e.rule)
out.push({
id: `dep:${b.id}:${elementPort(e)}`,
from: e.rule.from,
to: { box: b.id, port: elementPort(e) },
text: tt("DepMaterial", { f: e.rule.field, o: e.rule.op }),
});
return out;
}
/** 기댐 선이 흐름 길에 드나 — 기대는 알약과 닿는 곳(선 · 상자)이 같은 길에 있을 때 */
export function depInTrace(dep: DepLine, steps: CfStep[]): boolean {
const src = steps.some((s) => s.box === dep.from.box && s.port === dep.from.name);
const dst =
"link" in dep.to
? steps.some((s) => s.link === (dep.to as { link: string }).link)
: steps.some((s) => s.box === (dep.to as { box: string }).box);
return src && dst;
}
type Pt = { x: number; y: number };
/** 이름표에 닿는 끝 — 시작이 왼쪽이면 이름표 왼쪽 가장자리 · 오른쪽이면 오른쪽 가장자리(이름표가 없으면 선 가운데) */
export function depEnd(a: Pt | null, tag: Pt & { w: number }): Pt & { side: "left" | "right" } {
const fromLeft = !a || a.x <= tag.x;
return {
x: tag.x + (fromLeft ? -1 : 1) * (tag.w / 2 + 2),
y: tag.y,
side: fromLeft ? "left" : "right",
};
}
/** 기댐 선 길 — 알약 오른쪽에서 나가 끝에 닿음 · side = 끝으로 들어가는 쪽(재료 알약은 오른쪽에서) */
export function depPath(a: Pt, b: Pt, side: "left" | "right"): string {
const dx = Math.max(40, Math.abs(b.x - a.x) / 2);
const bx = side === "left" ? b.x - dx : b.x + dx;
return `M${a.x},${a.y} C${a.x + dx},${a.y} ${bx},${b.y} ${b.x},${b.y}`;
}
/** 끊긴 곳 갈래 — 쉼(조건이 안 맞아 이번엔 안 지나감 · 문제 아님) · 문제 — Eval 의 level 그대로 */
export const isRest = (k: CfBreak): boolean => k.level === "쉼";
/** 쉼 모양 자리 — 선 · 받는 알약(「상자|알약」) · 흐릴 상자
* 상자 흐림은 들어오는 선이 모두 쉼일 때 · 또는 상자에 걸린 쉼이 있고 그 상자 내보내는 값이 모두 빈 값일 때만 */
export function restParts(
lg: CfLogic,
rests: CfBreak[],
values: Record<string, Record<string, unknown>>,
): { links: Set<string>; pills: Set<string>; boxes: Set<string> } {
const links = new Set(rests.map((k) => k.link).filter((x): x is string => !!x));
const pills = new Set(
lg.links.filter((l) => links.has(l.id)).map((l) => `${l.to.box}|${l.to.name}`),
);
const boxes = new Set<string>();
const empty = (v: unknown): boolean => v === null || v === undefined || v === "";
for (const b of lg.boxes) {
const incoming = lg.links.filter((l) => l.to.box === b.id);
const allRest = incoming.length > 0 && incoming.every((l) => links.has(l.id));
// 내보내는 알약 값만(들어온 값은 빼고) · 쉼이 그 상자에 걸렸나(Eval 쉼 줄은 link 가 늘 있음)
const outs = portsOut(b).map((p) => values[b.id]?.[p.name]);
const hit = rests.some((k) => k.box === b.id);
if (allRest || (hit && outs.every(empty))) boxes.add(b.id);
}
return { links, pills, boxes };
}
/** 고른 알약 한 줄 — 이름 · 지금 값 · 어디서(받는) / 어디로(내보내는) */
export function pillInfo(
lg: CfLogic,
p: DfPillSel,
values: Record<string, Record<string, unknown>>,
): string {
const name = (e: CfPort): string =>
`${lg.boxes.find((b) => b.id === e.box)?.name ?? ""} · ${e.name}`;
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port;
const where = lg.links
.filter((l) => mine(p.dir === "in" ? l.to : l.from))
.map((l) => name(p.dir === "in" ? l.from : l.to))
.join(", ");
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`;
return [p.port, now, where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : ""]
.filter(Boolean)
.join(" · ");
}
/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */
/** 누른 뒤 5px 넘게 움직이면 끌기 — 끈 뒤 따라오는 click 한 번만 삼킴 */
export function follow(
ev: PointerEvent,
move: (m: PointerEvent) => void,
end: (u: PointerEvent, moved: boolean) => void,
): void {
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);
}
/* ── 선 그림 도우미(SVG) ────────────────────────────────────────────── */
const SVG_NS = "http://www.w3.org/2000/svg";
/** SVG 마디 하나 붙이기 */
export function svgAdd<K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attrs: Record<string, string>,
): SVGElementTagNameMap[K] {
const n = document.createElementNS(SVG_NS, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
parent.append(n);
return n;
}
/** 가운데 이름표 — 둥근 바탕 + 글 · 돌려주는 값 = 이름표 폭 */
export function svgLabel(parent: Element, x: number, y: number, text: string, cls: string): number {
const tag = svgAdd(parent, "g", { class: cls });
const box = svgAdd(tag, "rect", { rx: "4", ry: "4" });
const t = svgAdd(tag, "text", {
x: x.toFixed(1),
y: y.toFixed(1),
"dominant-baseline": "central",
"text-anchor": "middle",
});
t.textContent = text;
const w = (t.getComputedTextLength?.() || text.length * 7) + 10;
Object.entries({ x: x - w / 2, y: y - 9, width: w, height: 18 }).forEach(([k, v]) =>
box.setAttribute(k, v.toFixed(1)),
);
return w;
}
@@ -1,281 +0,0 @@
/* =============================================================================
* M02_DF_Entry_Detail.ts
* 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름)
* · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움.
* 경고 = 끊긴 곳 가운데 고른 것 몫 · 다른 로직 입력이 부르는 로직 읽기(겹쳐 부름) · 입력 값 물음 · 결과 단위 후보.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { CfAnswer, CfQuery } from "./M02_DF_Api";
import { fillTemplate } from "./M02_DF_InputPanel_Build";
import type { CfBreak, CfValues } from "./M02_DF_Eval";
import type { CfElementRef, CfLogic, CfPort } from "./M02_DF_Model";
import "@ui/ui_template_text.css";
const TEXT = {
Detail: ["상세 내용", "Details"],
Settings: ["설정", "Settings"],
Body: ["본문", "Content"],
Warn: ["경고", "Warnings"],
WarnNone: ["경고 없음", "No warnings"],
PickSome: [
"다이어그램에서 상자 · 알약 · 선을 고르면 여기에 그 상세 내용이 보임",
"Choose a box, pill or line in the diagram",
],
Line: ["선", "Line"],
In: ["받는 알약", "Incoming pill"],
Out: ["내보내는 알약", "Outgoing pill"],
} as const;
const tt = (key: keyof typeof TEXT): string =>
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string;
/** 고른 것 — 상자 · 선 · 알약 */
export type DfPick =
| { kind: "box"; id: string }
| { kind: "link"; id: string }
| { kind: "pill"; box: string; port: string; dir: "in" | "out" }
| null;
export interface DetailParts {
root: HTMLElement;
title: HTMLElement;
pill: HTMLElement;
hint: HTMLElement;
settings: HTMLElement;
body: HTMLElement;
warn: HTMLElement;
}
const row = (label: string, host: HTMLElement): HTMLElement =>
el("div", {
className: "m02-df__detail-row",
children: [el("h4", { text: label }), host],
});
/** 상세 내용 칸 — 머리 + 설정 · 본문 · 경고 */
export function detailFrame(): DetailParts {
const title = el("strong", { className: "m02-df__detail-title" });
const pill = el("span", { className: "m02-df__detail-pill" });
const hint = el("p", { className: "m02-df__muted", text: tt("PickSome") });
const settings = el("div", { className: "m02-df__detail-settings" });
const body = el("div", { className: "m02-df__detail-body" });
const warn = el("ul", { className: "m02-df__detail-warn" });
const root = el("section", {
className: "m02-df__part m02-df__detail",
children: [
el("div", {
className: "m02-df__detail-head",
children: [
el("h3", { text: tt("Detail") }),
el("div", { className: "m02-df__detail-name", children: [title, pill] }),
],
}),
el("div", {
className: "m02-df__part-body",
children: [
hint,
row(tt("Settings"), settings),
row(tt("Body"), body),
row(tt("Warn"), warn),
],
}),
],
});
return { root, title, pill, hint, settings, body, warn };
}
const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
/** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */
export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void {
d.hint.hidden = !!pick;
for (const r of [d.settings, d.body, d.warn]) (r.parentElement as HTMLElement).hidden = !pick;
const link = pick?.kind === "link" ? lg.links.find((l) => l.id === pick.id) : undefined;
d.title.textContent =
pick?.kind === "box"
? boxName(lg, pick.id)
: pick?.kind === "pill"
? boxName(lg, pick.box)
: link
? `${tt("Line")} · ${boxName(lg, link.from.box)} → ${boxName(lg, link.to.box)}`
: "";
d.pill.textContent =
pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.port}` : "";
d.pill.hidden = pick?.kind !== "pill";
}
/** 경고 — 끊긴 곳(Eval breaks) 가운데 고른 것 몫만 · 상자 = 그 상자 · 알약 = 그 알약 · 선 = 그 선 */
export function drawWarnings(d: DetailParts, pick: DfPick, all: CfBreak[]): void {
const mine = all.filter((k) =>
pick?.kind === "box"
? k.box === pick.id
: pick?.kind === "pill"
? k.box === pick.box && k.port === pick.port && !k.link
: pick?.kind === "link"
? k.link === pick.id
: false,
);
d.warn.replaceChildren(
...(mine.length
? [...new Set(mine.map((k) => k.text))].map((w) => el("li", { text: w }))
: [el("li", { className: "ui-text ui-text--muted", text: tt("WarnNone") })]),
);
}
/** 다른 로직 입력이 부르는 로직들 — 그 로직이 또 부르는 것까지(겹쳐 부름 · 한 번씩) · 못 읽은 것은 빼고 까닭 */
export async function loadLogics(
lg: CfLogic,
get: (key: string) => Promise<CfLogic | null>,
): Promise<{ logics: Record<string, CfLogic>; errors: string[] }> {
const logics: Record<string, CfLogic> = {};
const errors: string[] = [];
const keysOf = (x: CfLogic): string[] =>
x.boxes.flatMap((b) =>
b.input?.mode === "logic" && b.input.logic?.key ? [b.input.logic.key] : [],
);
const seen = new Set<string>(lg.key ? [lg.key] : []);
let todo = keysOf(lg);
while (todo.length) {
const key = todo.shift()!;
if (seen.has(key)) continue;
seen.add(key);
try {
const got = await get(key);
if (!got) throw new Error(key);
logics[key] = got;
todo = [...todo, ...keysOf(got)];
} catch (e) {
errors.push(`${key} — ${e instanceof Error ? e.message : String(e)}`);
}
}
return { logics, errors };
}
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
export 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 : ["원/㎡"];
}
/** 조건 찾기를 쓰는 그룹 — 재료 밖은 인력 · 기계 · 유가전력(field 키 · 서버 Values.GROUP_RULE) */
const RULE_GROUPS = ["인력", "기계", "유가전력"];
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 재료 자리(알약 = 자리 이름 · 조건 찾기는 이어진 알약 값을
* pill 로 읽어 넘김 · 인력 · 기계 · 유가 조건 찾기도) · 표(알약 「표」) · 다른 로직(서버가 받은 설계 값으로 셈 · port 「*」)
* · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
export function queriesOf(
lg: CfLogic,
pill: (p: CfPort) => unknown = () => null,
elementPort: (e: CfElementRef) => string = (e) => e.name,
): { q: CfQuery; box: string; port: string }[] {
return lg.boxes.flatMap((b) => {
const inp = b.input;
if (b.kind === "input" && inp?.mode === "logic" && inp.logic?.key) {
const ref = inp.logic;
const designs: Record<string, unknown> = {};
for (const d of ref.designs) {
const l = lg.links.find((x) => x.to.box === b.id && x.to.name === d.name);
const v = l ? pill(l.from) : ref.examples?.[d.name];
if (v !== null && v !== undefined) designs[d.name] = v;
}
const q: CfQuery = { id: `${b.id}*`, group: "기계", logic: { key: ref.key, designs } };
return [{ q, box: b.id, port: "*" }];
}
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
const group = inp.group;
const slots = inp.elements
.filter(
(e) =>
(group === "재료" && e.mode && e.mode !== "fixed") ||
(RULE_GROUPS.includes(group) && e.mode === "rule"),
)
.map((e) => {
const port = elementPort(e);
const q: CfQuery = {
id: `${b.id}\u0001${port}`,
group,
unit: e.unit,
slot: { ...e, field: e.rule?.field, op: e.rule?.op },
};
// 조건 찾기 — 「검색어」 면 틀에 알약 값을 채운 글 · 그 밖은 이어진 알약 값
if (e.mode === "rule" && e.rule)
q.value =
e.rule.field === "검색어"
? fillTemplate(e.rule.template ?? "", e.rule.slots ?? [], pill).text
: pill(e.rule.from);
return { q, box: b.id, port };
});
const els = inp.elements
.filter((e) => e.ref && !e.ref.startsWith("UE-") && !slots.some((s) => s.port === e.name))
.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 [...slots, ...els, ...table];
});
}
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
export async function fetchValues(
ask: (qs: CfQuery[], els: never) => Promise<CfAnswer[]>,
lg: CfLogic,
pill?: (p: CfPort) => unknown,
elementPort?: (e: CfElementRef) => string,
): Promise<{ values: CfValues; errors: string[] }> {
const all = queriesOf(lg, pill, elementPort);
const asks = all.filter((a) => !a.q.logic);
const values: CfValues = {};
const errors: string[] = [];
// 다른 로직 — 서버가 받은 설계 값으로 셈(그 안 조건 찾기 · 단가까지) → 출력 이름마다 값
const logicAsks = all.filter((a) => a.q.logic);
const logicDone = logicAsks.length
? import("./M02_DF_Api").then(({ cfLogicValue }) =>
Promise.all(
logicAsks.map((a) =>
cfLogicValue(a.q.logic!.key, a.q.logic!.designs).then(
(got) => {
values[a.box] = { ...(values[a.box] ?? {}), ...(got.outputs as CfValues[string]) };
},
(e: unknown) => errors.push(`${a.q.logic!.key} — ${String(e)}`),
),
),
),
)
: Promise.resolve();
if (!asks.length) {
await logicDone;
return { values, errors };
}
const answers = await ask(
asks.map((a) => a.q),
lg.userElements as never,
);
await logicDone;
for (const a of asks) {
const got = answers.find((x) => x.id === a.q.id);
if (got && !got.ok && got.blank) (values[a.box] ??= {})[a.port] = "#N/A"; // 빈 단가 — Eval 이 NA()
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 };
}
-538
View File
@@ -1,538 +0,0 @@
/* =============================================================================
* M02_DF_Eval.ts
* 단가산출 흐름도 계산 · 검사 — 상자를 기댐 차례대로(입력 → 선 규칙 → 함수 · 다른 로직 → 출력) 셈
* 함수 상자 = 시트를 우리 스프레드시트 엔진으로 셈(받는 칸에 앞 값) · 선 규칙 · 끝수도 같은 엔진(`M02_DF_Rule`)
* 출력 상자를 비용 종류별로 모아 원가 · 끊긴 곳(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 ·
* 조건 겹침 · 조건 안 맞음 · 오류) · 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,
depends,
designInputs,
elementPort,
gateConds,
portsIn,
portsOut,
ruleSources,
type CfBox,
type CfGateRule,
type CfLink,
type CfLogic,
} from "./M02_DF_Model";
import { applyRound, applyRule, ruleLabel } from "./M02_DF_Rule";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
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;
/** 함수 상자 칸으로 가는 표 찾기 실패 값 — 칸에 NA() 를 넣음(엑셀과 같게 · IF 가 안 고른 갈래면 무시) */
const NA_VALUE = "#N/A";
const isErrorValue = (v: unknown): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v);
/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */
export interface CfBreak {
kind:
| "빈 받는 자리"
| "통과 안 됨"
| "표에 없는 조건"
| "맞는 재료 없음"
| "단가 없는 품목"
| "조건 겹침"
| "조건 안 맞음"
| "오류";
/** 무게 — 「쉼」 = 조건이 안 맞아 쉬는 갈래(그 아래로 번진 빈 값 포함) · 「문제」 = 고쳐야 할 끊김 */
level: "문제" | "쉼";
box: string;
port?: string;
link?: string;
/** 쉬운 말 */
text: string;
}
export interface CfEvalResult {
/** 상자 id → 내보내는 알약 이름 → 값 */
ports: Record<string, Record<string, CfValue>>;
/** 상자 id → 받는 알약 이름 → 선 규칙을 지나 들어온 값 */
arrived?: Record<string, Record<string, CfValue>>;
/** 출력 상자 id → 값 */
outputs: Record<string, Scalar>;
cost: {
노무비: number;
재료비: number;
경비: number;
/** 비용 아닌 출력(값) — 출력 상자 이름 → 값 */
값들: Record<string, Scalar>;
계: number;
};
/** 쉬운 말 */
errors: string[];
/** 끊긴 곳(셈 때 생긴 것) */
breaks?: CfBreak[];
}
/** 다른 로직 입력을 셀 때 쓸 로직들(저장 · 견본 key → 로직) */
export interface CfEvalOptions {
logics?: Record<string, CfLogic>;
depth?: number;
}
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"],
/** {from} {box} {rule} */
NotPassed: [
"「{from}」 → 「{box}」 선이 조건({rule})을 통과하지 않음",
"Line 「{from}」 → 「{box}」 doesn't pass ({rule})",
],
NoPrice: [
"「{box}」 — 단가 없는 품목을 씀(「{pill}」)",
"「{box}」 — uses an item with no price (「{pill}」)",
],
NoRow: [
"「{from}」 → 「{box}」 선 — 표에 맞는 줄이 없음",
"Line 「{from}」 → 「{box}」 — no matching table row",
],
ManyRows: [
"「{from}」 → 「{box}」 선 — 표에 맞는 줄이 여럿",
"Line 「{from}」 → 「{box}」 — several table rows match",
],
/** {from} {box} */
Rest: [
"「{from}」 → 「{box}」 — 앞 조건이 안 맞아 쉬는 갈래",
"「{from}」 → 「{box}」 — resting branch (an earlier condition doesn't match)",
],
RuleError: [
"「{from}」 → 「{box}」 선 규칙을 셀 수 없음",
"Line 「{from}」 → 「{box}」 rule can't be worked out",
],
/** {box} {pill} */
NoMaterial: [
"「{box}」 의 「{pill}」 — 조건에 맞는 재료가 없음",
"「{pill}」 of 「{box}」 — no material matches",
],
Overlap: [
"「{box}」 의 「{pill}」 에서 나간 조건 선이 둘 이상 통과함",
"Several condition lines from 「{pill}」 of 「{box}」 pass",
],
NoneMatch: [
"「{box}」 의 「{pill}」 에서 나간 조건 선이 하나도 통과하지 않음",
"No condition line from 「{pill}」 of 「{box}」 passes",
],
NoLogic: [
"「{box}」 이 부르는 로직을 찾을 수 없음",
"The logic used by 「{box}」 can't be found",
],
} 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): { boxes: CfBox[]; loop: CfBox[] } {
const deps = depends(lg);
const done: CfBox[] = [];
const left = new Set(lg.boxes.map((b) => b.id));
for (let moved = true; moved && left.size;) {
moved = false;
for (const b of lg.boxes) {
if (!left.has(b.id)) continue;
if (deps.some((d) => d.to === b.id && d.from !== b.id && left.has(d.from))) continue;
done.push(b);
left.delete(b.id);
moved = true;
}
}
return { boxes: done, loop: lg.boxes.filter((b) => left.has(b.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,
given: Record<string, CfValue>,
): Record<string, CfValue> {
const out: Record<string, CfValue> = {};
const inp = box.input;
if (!inp) return out;
const pills = portsOut(box);
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,
arrive: 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) {
const v = arrive[i.port] ?? given[i.port] ?? null;
if (isErrorValue(v))
sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; // 오류 값 · 빈 단가
else put(sheet.칸, i.cell, v);
}
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 && sheet.칸[cell]?.식 !== "NA()") {
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];
const got = !c ? null : c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
return typeof got === "number" && func.output.round ? applyRound(func.output.round, got) : got;
}
/* ── 계산 ───────────────────────────────────────────────────────────────── */
/** 화면 계산 — opts.logics 가 있으면 다른 로직 입력을 그 로직으로 셈(없으면 values 의 값) */
export function evalLogic(
lg: CfLogic,
values: CfValues = {},
opts: CfEvalOptions = {},
): CfEvalResult {
const errors: string[] = [];
const found: CfBreak[] = [];
const ports: CfEvalResult["ports"] = {};
const arrived: NonNullable<CfEvalResult["arrived"]> = {};
const name = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
const valueOf = (box: string, pill: string): CfValue => ports[box]?.[pill] ?? null;
const passed = new Map<string, boolean>();
/** 선 규칙에서 막힌 상자 — 그 함수는 셈하지 않고 빈 값을 넘김(갈래가 끊김) */
const blocked = new Set<string>();
/** 조건이 안 맞아 쉬는 상자 — 그 아래로 번짐 */
const resting = new Set<string>();
/** 함수 상자로 가는 표 찾기 실패 — 그 함수 결과가 오류일 때만 끊긴 곳(엑셀과 같게) */
const late = new Map<string, CfBreak[]>();
/** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */
const through = (l: CfLink): CfValue => {
const v = valueOf(l.from.box, l.from.name);
// 빈 단가(#N/A) — 옛 엔진처럼 그 값을 쓰는 식은 빈 값 · 결과가 오류일 때만 끊긴 곳(late)
if (v === NA_VALUE && lg.boxes.find((b) => b.id === l.from.box)?.input?.mode === "group") {
const text = tl("NoPrice", { box: name(l.to.box), pill: l.from.name });
const brk: CfBreak = {
kind: "단가 없는 품목",
level: "문제",
box: l.to.box,
port: l.to.name,
link: l.id,
text,
};
late.set(l.to.box, [...(late.get(l.to.box) ?? []), brk]);
}
if (!l.rule) return v;
if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null;
const conds = Object.fromEntries(
ruleSources(l.rule).flatMap((p) => [
[p.name, valueOf(p.box, p.name)],
[`${p.box}|${p.name}`, valueOf(p.box, p.name)],
]),
);
const res = applyRule(l.rule, v, conds);
if (l.rule.kind === "gate") passed.set(l.id, res.why === null);
const fill = { from: name(l.from.box), box: name(l.to.box), rule: ruleLabel(l.rule) };
if (res.why === "통과 안 됨") {
resting.add(l.to.box);
found.push({
kind: "통과 안 됨",
level: "쉼",
box: l.to.box,
port: l.to.name,
link: l.id,
text: tl("NotPassed", fill),
});
} else if (res.why) {
const kind = res.why === "표에 없는 조건" ? "표에 없는 조건" : "오류";
const key =
res.why === "표에 없는 조건"
? "NoRow"
: res.why === "표에 여럿 맞음"
? "ManyRows"
: "RuleError";
const text = tl(key, fill);
const brk: CfBreak = {
kind,
level: "문제",
box: l.to.box,
port: l.to.name,
link: l.id,
text,
};
if (
l.rule.kind === "lookup" &&
lg.boxes.find((b) => b.id === l.to.box)?.kind === "function"
) {
late.set(l.to.box, [...(late.get(l.to.box) ?? []), brk]);
return NA_VALUE;
}
found.push(brk);
errors.push(text);
}
if (res.why) blocked.add(l.to.box);
return res.why ? null : res.value;
};
/** 받는 값 — 쉬는 갈래에서 온 빈 값은 「쉼」 줄만 남김 · 들어오는 선이 모두 쉬는 갈래면 받는 상자도 쉼
* (하나라도 살아 있으면 셈 · 쉬는 갈래 값은 빈 칸) · 조건 선 바로 뒤 상자는 through 가 막음 */
const arrive = (box: CfBox): Record<string, CfValue> => {
const ins = lg.links.filter((l) => l.to.box === box.id);
const got: Record<string, CfValue> = {};
let rest = 0;
for (const l of ins) {
if (resting.has(l.from.box) && valueOf(l.from.box, l.from.name) === null) {
rest++;
got[l.to.name] = null;
const text = tl("Rest", { from: name(l.from.box), box: name(l.to.box) });
found.push({
kind: "통과 안 됨",
level: "쉼",
box: l.to.box,
port: l.to.name,
link: l.id,
text,
});
continue;
}
got[l.to.name] = through(l);
}
if (ins.length && rest === ins.length) {
resting.add(box.id);
blocked.add(box.id);
}
arrived[box.id] = got;
return got;
};
const { boxes, loop } = order(lg);
for (const b of loop) errors.push(tl("Loop", { box: b.name }));
const outputs: CfEvalResult["outputs"] = {};
const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
for (const b of boxes) {
const given = values[b.id] ?? {};
if (b.kind === "input" && b.input?.mode !== "logic") {
ports[b.id] = inputPorts(lg, b, given);
for (const e of b.input?.elements ?? [])
if (e.mode === "rule" && ports[b.id][elementPort(e)] === null) {
const text = tl("NoMaterial", { box: b.name, pill: elementPort(e) });
found.push({
kind: "맞는 재료 없음",
level: "문제",
box: b.id,
port: elementPort(e),
text,
});
errors.push(text);
}
} else if (b.kind === "input") ports[b.id] = otherLogic(b, arrive(b), given, opts, errors);
else if (b.kind === "function") {
const got = arrive(b);
const out = blocked.has(b.id) ? null : runFunc(b, got, given, errors);
ports[b.id] = { [b.func!.output.name]: out };
if (out === null || isErrorValue(out))
for (const brk of late.get(b.id) ?? []) {
found.push(brk);
errors.push(brk.text);
}
} else {
const v = arrive(b)[OUTPUT_PORT] ?? null;
let scalar: Scalar = Array.isArray(v) ? null : v;
const round = b.output?.round;
if (typeof scalar === "number" && round) scalar = applyRound(round, scalar);
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.경비;
gates(lg, passed, name, found, errors);
return { ports, arrived, outputs, cost, errors, breaks: found };
}
/** 다른 로직 입력 — 그 로직을 받은 값(설계 때 정할 것)으로 셈 · 없으면 넘긴 값 */
function otherLogic(
box: CfBox,
arrive: Record<string, CfValue>,
given: Record<string, CfValue>,
opts: CfEvalOptions,
errors: string[],
): Record<string, CfValue> {
const ref = box.input?.logic;
const out: Record<string, CfValue> = {};
// 서버가 받은 설계 값으로 셈한 출력(Entry_Detail 다른 로직 물음)이 있으면 그것 — 그 안 단가 · 조건 찾기까지 정본
if (ref?.outputs.some((p) => given[p.name] !== undefined)) {
for (const p of ref.outputs) out[p.name] = given[p.name] ?? null;
return out;
}
const other = ref ? opts.logics?.[ref.key] : undefined;
if (!other || (opts.depth ?? 0) > 8) {
if (ref && opts.logics && !other) errors.push(tl("NoLogic", { box: box.name }));
for (const p of ref?.outputs ?? []) out[p.name] = given[p.name] ?? null;
return out;
}
const inner: CfValues = {};
for (const d of designInputs(other)) {
// 값 차례 = 이어진 선 > 넘긴 값 > 상자에 둔 예시값 > 그 로직 자기 예시값(inner 에 안 넣음)
const v = d.port in arrive ? arrive[d.port] : (given[d.port] ?? ref?.examples?.[d.port]);
if (v !== undefined && v !== null) (inner[d.box] ??= {})[d.port] = v;
}
const ev = evalLogic(other, inner, { logics: opts.logics, depth: (opts.depth ?? 0) + 1 });
for (const e of ev.errors) errors.push(`「${box.name}」 · ${e}`);
for (const p of ref?.outputs ?? []) {
const ob = other.boxes.find((b) => b.kind === "output" && b.name === p.name);
out[p.name] = ob ? (ev.outputs[ob.id] ?? null) : (given[p.name] ?? null);
}
return out;
}
/** 한 알약에서 나간 조건 선(둘 이상) — 겹침 · 안 맞음 */
function gates(
lg: CfLogic,
passed: Map<string, boolean>,
name: (id: string) => string,
found: CfBreak[],
errors: string[],
): void {
const groups = new Map<string, CfLink[]>();
for (const l of lg.links)
if (l.rule?.kind === "gate") {
// 같은 알약에서 나가 같은 조건 알약들로 가르는 선끼리 — 여러 조건이면 조건 알약 묶음이 같을 때
const srcs = ruleSources(l.rule)
.map((p) => `${p.box}|${p.name}`)
.sort()
.join("+");
const k = `${srcs}|${l.from.box}|${l.from.name}`;
groups.set(k, [...(groups.get(k) ?? []), l]);
}
for (const list of groups.values()) {
if (list.length < 2) continue;
const n = list.filter((l) => passed.get(l.id)).length;
if (n === 1) continue;
const g = gateConds(list[0].rule as CfGateRule).conds[0].from;
const fill = { box: name(g.box), pill: g.name };
const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill);
found.push({
kind: n > 1 ? "조건 겹침" : "조건 안 맞음",
level: "문제",
box: g.box,
port: g.name,
text,
});
errors.push(text);
}
}
/* ── 검사 ───────────────────────────────────────────────────────────────── */
/** 끊긴 곳 목록 — 빈 받는 자리(다른 로직의 받는 알약은 설계 때 정할 것이라 뺌) + 셈 때 생긴 것 */
export function breaks(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): CfBreak[] {
const out: CfBreak[] = [];
for (const b of lg.boxes) {
if (b.kind === "input") continue;
for (const p of portsIn(b))
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
out.push({
kind: "빈 받는 자리",
level: "문제",
box: b.id,
port: p.name,
text: tl("EmptyIn", { box: b.name, pill: p.name }),
});
}
return [...out, ...(evalLogic(lg, values, opts).breaks ?? [])];
}
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 칸에 안 놓인 받는 알약 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
export function checkLogic(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): string[] {
const out: string[] = [];
for (const b of lg.boxes) {
if (b.kind !== "input")
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, opts);
for (const b of lg.boxes.filter((x) => x.kind === "input" && x.input?.mode !== "logic"))
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 [...new Set([...out, ...ev.errors])];
}
@@ -1,291 +0,0 @@
/* =============================================================================
* M02_DF_FormulaView.ts
* 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 ·
* IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀
* 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text";
import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { CfBox } from "./M02_DF_Model";
import { createEmpty } from "@ui/ui_template_empty";
export type CellScalar = number | string | boolean | null;
export interface FormulaBranch {
when: string;
then: string;
/** 지금 타는 갈래 */
now: boolean;
}
export interface FormulaLine {
/** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */
name: string;
/** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */
named: string;
/** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */
valued: string;
/** IF 면 만약 / 아니면 줄 */
branches?: FormulaBranch[];
}
const TEXT = {
Title: ["식 보기", "Formulas"],
Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"],
Else: ["아니면", "Otherwise"],
If: ["만약", "If"],
Now: ["지금", "now"],
NoValue: ["값 없음", "no value"],
} as const;
const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */
const REF = /(?<![A-Za-z0-9_.'!])\$?([A-Z]{1,3})\$?(\d+)(?![A-Za-z0-9_(])/g;
/** 따옴표 안 글은 그대로 두고 바깥 글만 바꿈 */
function outside(text: string, fn: (part: string) => string): string {
return text
.split(/("(?:[^"]|"")*")/)
.map((p, i) => (i % 2 ? p : fn(p)))
.join("");
}
const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, "");
/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */
export function cellNames(box: CfBox): Record<string, string> {
const f = box.func;
const out: Record<string, string> = {};
if (!f) return out;
for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port;
for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name;
if (f.output.cell) out[upper(f.output.cell)] = f.output.name;
return out;
}
const refsIn = (formula: string): string[] => {
const found: string[] = [];
outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m)));
return found;
};
const OPS: [RegExp, string][] = [
[/\*/g, " × "],
[/\//g, " ÷ "],
[/<>/g, " ≠ "],
[/>=/g, " ≥ "],
[/<=/g, " ≤ "],
[/(?<![<>≠≥≤])=/g, " = "],
[/\+/g, " + "],
[/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "],
];
/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */
function pretty(text: string): string {
return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p))
.replace(/\s{2,}/g, " ")
.trim();
}
const fmt = (n: number): string =>
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 오류 글(#N/A …)은 값이 없다는 뜻 — 따옴표째 보이지 않음 */
const isError = (v: CellScalar | undefined): boolean =>
typeof v === "string" && /^#[A-Z/0!?]+$/.test(v);
function showScalar(v: CellScalar | undefined): string {
if (isError(v)) return tv("NoValue");
if (v === undefined || v === null) return "0";
if (typeof v === "number") return fmt(v);
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
return `"${v}"`;
}
/** 이름으로 쓴 식 */
export function namedText(formula: string, names: Record<string, string>): string {
return pretty(
outside(formula, (p) =>
p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`),
),
);
}
/** 값 넣은 식(값이 문자면 따옴표) */
export function valuedText(formula: string, values: Record<string, CellScalar>): string {
return pretty(
outside(formula, (p) =>
p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])),
),
);
}
/* ── 갈래 ───────────────────────────────────────────────────────────────── */
const num = (v: CellScalar | undefined): number | null => {
if (typeof v === "number") return v;
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
return null;
};
/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */
function holds(cond: string, values: Record<string, CellScalar>): boolean | null {
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond);
if (!m) return null;
const read = (t: string): CellScalar | undefined => {
const x = t.trim();
const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x);
if (cell) return values[`${cell[1]}${cell[2]}`];
const q = /^"((?:[^"]|"")*)"$/.exec(x);
if (q) return q[1].replace(/""/g, '"');
return Number.isFinite(Number(x)) ? Number(x) : undefined;
};
const a = read(m[1]);
const b = read(m[3]);
if (a === undefined || b === undefined) return null;
const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)];
switch (m[2]) {
case "=":
return l === r;
case "<>":
return l !== r;
case ">=":
return l >= r;
case "<=":
return l <= r;
case ">":
return l > r;
default:
return l < r;
}
}
/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */
function branchesOf(
formula: string,
names: Record<string, string>,
values: Record<string, CellScalar>,
): FormulaBranch[] | undefined {
const out: FormulaBranch[] = [];
let rest = formula;
let taken = false;
for (;;) {
const body = ifBody(rest);
if (body === null) break;
const a = splitArgs(body);
if (a.length !== 3) return undefined;
const ok = holds(a[0], values) === true && !taken;
if (ok) taken = true;
out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok });
rest = a[2];
}
if (!out.length) return undefined;
out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken });
return out;
}
/* ── 줄 ─────────────────────────────────────────────────────────────────── */
/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */
export function formulaLines(box: CfBox, values: Record<string, CellScalar>): FormulaLine[] {
const f = box.func;
if (!f?.output.cell) return [];
const names = cellNames(box);
const cells = f.sheet.칸;
const order: string[] = [];
const seen = new Set<string>();
const visit = (a1: string): void => {
const key = upper(a1);
if (seen.has(key)) return;
seen.add(key);
const formula = cells[key]?.식;
if (formula === undefined) return;
for (const r of refsIn(formula)) visit(r);
order.push(key);
};
visit(f.output.cell);
return order.map((key) => {
const formula = cells[key].식 as string;
const result = showScalar(values[key]);
const branches = branchesOf(formula, names, values);
return {
name: names[key] ?? key,
named: namedText(formula, names),
valued: `${valuedText(formula, values)} = ${result}`,
...(branches ? { branches } : {}),
};
});
}
/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */
export function cellValuesOf(
box: CfBox,
arrived: Record<string, unknown> | undefined,
): Record<string, CellScalar> {
const f = box.func;
if (!f) return {};
const sheet = structuredClone(f.sheet);
const scalar = (v: unknown): number | string | boolean | undefined =>
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined;
const put = (cell: string, v: number | string | boolean | undefined): void => {
if (!parseA1(cell) || v === undefined) return;
sheet.칸[upper(cell)] = { 값: v };
};
for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port]));
for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example);
const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
const out: Record<string, CellScalar> = {};
for (const [key, cell] of Object.entries(sheet.칸)) {
const c: CalcValue | undefined = calc[sheet.id]?.[key];
out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null;
}
return out;
}
function fromCalc(v: CalcValue): CellScalar {
if ("수" in v) return Number(v.수);
if ("글" in v) return v.글;
if ("참" in v) return v.참;
return v.오류;
}
/* ── 그리기 ─────────────────────────────────────────────────────────────── */
/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */
export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void {
host.classList.add("ui-card", "m02-df-formula");
host.replaceChildren(
el("strong", { className: "m02-df-formula__title", text: tv("Title") }),
...(lines.length
? lines.map((l) =>
el("div", {
className: "m02-df-formula__line",
children: [
el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }),
el("div", { className: "m02-df-formula__valued", text: l.valued }),
...(l.branches
? [
el("ul", {
className: "m02-df-formula__branches",
children: l.branches.map((b, i) =>
el("li", {
className: b.now ? "is-now" : "",
text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`,
}),
),
}),
]
: []),
],
}),
)
: [createEmpty({ text: tv("Empty"), small: true })]),
);
}
@@ -1,145 +0,0 @@
/* =============================================================================
* M02_DF_FuncExtra.ts
* 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것
* 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { showToast } from "@ui/ui_template_elements";
import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition";
import { renderCellTable } from "./M02_DF_CellTable";
import type { DetailOptions } from "./M02_DF_Detail";
import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView";
import { TEXT } from "./M02_DF_FuncText";
import { valueChoices, type CfFunc, type CfLogic, type CfVariable } from "./M02_DF_Model";
export interface ExtraContext {
lg: CfLogic;
boxId: string;
detail: DetailOptions;
/** 선 규칙을 지나 들어온 값 */
arrived: Record<string, unknown>;
readOnly: boolean;
place(port: string, cell: string): void;
setOutput(cell: string): void;
patch(make: (f: CfFunc) => CfFunc): void;
used(f: CfFunc): Map<string, string>;
}
const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0];
const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId);
/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */
export function drawFormula(host: HTMLElement, x: ExtraContext): void {
const box = boxOf(x);
if (!box?.func) return;
renderFormulaView(host, formulaLines(box, cellValuesOf(box, x.arrived)));
}
/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */
export function drawCells(host: HTMLElement, x: ExtraContext): void {
const box = boxOf(x);
if (!box?.func) return;
renderCellTable(host, x.lg, box, x.readOnly, {
move: (kind, key, cell) => {
if (kind === "in") return x.place(key, cell);
if (kind === "out") return x.setOutput(cell);
if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error");
x.patch((f) => ({
...f,
variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)),
}));
},
release: (kind, key) =>
x.patch((f) =>
kind === "in"
? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) }
: kind === "var"
? { ...f, variables: f.variables.filter((v) => v.name !== key) }
: { ...f, output: { ...f.output, cell: "" } },
),
});
}
/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸
* get 으로 부를 때마다 지금 상태를 다시 읽음(나중에 놓은 알약 · 만든 변수도 목록에 뜸) */
export function conditionsOf(get: () => ExtraContext): ConditionOptions {
return {
valuesFor: (a1) => {
const x = get();
const f = boxOf(x)?.func;
const at = a1.toUpperCase();
const v = f?.variables.find((y) => y.cell?.toUpperCase() === at);
if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null;
const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port;
const link = port
? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port)
: undefined;
return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null;
},
pills: () => {
const x = get();
const f = boxOf(x)?.func;
return [
...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })),
...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })),
];
},
};
}
/** 변수 만들기 폼 → 변수 · 틀리면 까닭(글 열쇠) — 같은 이름 · 이미 쓰는 칸 · 숫자 아님 · 범위 거꾸로 */
export interface VarFormValues {
name: string;
type: CfVariable["type"];
unit: string;
min: string;
max: string;
choices: string;
example: string;
}
export function variableFrom(
form: VarFormValues,
f: CfFunc,
cell: string,
used: (f: CfFunc) => Map<string, string>,
):
| { v: CfVariable; cell: string }
| { why: "NeedName" | "SameName" | "Busy" | "NotNumber" | "BadRange" } {
const name = form.name.trim();
if (!name) return { why: "NeedName" };
const mine = f.variables.find((v) => v.name === name);
if (!mine && [...f.inputs.map((i) => i.port), ...f.variables.map((v) => v.name)].includes(name))
return { why: "SameName" };
if (used({ ...f, variables: f.variables.filter((v) => v.name !== name) }).has(cell))
return { why: "Busy" };
const v: CfVariable = {
name,
type: form.type,
unit: form.unit.trim() || "-",
example: null,
cell,
};
let example: number | string | null = form.example.trim() === "" ? null : form.example.trim();
if (form.type === "number") {
if (example !== null) {
const n = Number(String(example).replace(/,/g, ""));
if (!Number.isFinite(n)) return { why: "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 { why: "BadRange" };
v.range = [lo, hi];
}
} else if (form.type === "choice") {
v.choices = form.choices
.split(",")
.map((x) => x.trim())
.filter(Boolean);
}
v.example = example;
return { v, cell };
}
@@ -1,210 +0,0 @@
.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-label {
font-weight: var(--font-weight-bold);
}
/* 겉 = 카드 부품 · 여기는 아래 맞춤 · 안 여백만 */
.m02-dfpanel-form.ui-card {
align-items: flex-end;
padding: var(--spacing-8) var(--spacing-12);
}
.m02-dfpanel-fieldbox {
display: inline-flex;
flex-direction: column;
gap: 2px;
}
.m02-dfpanel-field {
width: 9em;
}
.m02-dfpanel-field.is-num {
width: 6em;
}
/* 알림 = 띠 알림 부품 — 숨김은 부품 display 를 이김 */
.m02-dfpanel-msg {
margin: 0;
}
.m02-dfpanel-msg[hidden] {
display: none;
}
/* 받음 · 변수 · 출력 알약 = 배지 부품(info · warning · success) — 안 놓인 받음은 점선 */
.m02-dfpanel-chip {
font-family: inherit;
}
button.m02-dfpanel-chip {
cursor: pointer;
}
button.m02-dfpanel-chip:focus-visible {
outline: 2px solid var(--color-focus-ring);
}
.m02-dfpanel-chip.is-in:not(.is-placed) {
border-color: var(--badge-tone);
border-style: dashed;
}
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
.m02-dfpanel-sheet {
position: relative;
height: max(640px, calc(100dvh - 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 var(--color-info);
}
.m02-dfpanel .aislo-cell[data-cf-kind="var"] {
box-shadow: inset 0 0 0 2px var(--color-warning);
}
.m02-dfpanel .aislo-cell[data-cf-kind="out"] {
box-shadow: inset 0 0 0 2px var(--color-success);
}
.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;
}
/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */
.m02-dfpanel-body {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: var(--spacing-12);
}
.m02-dfpanel-body > .m02-dfpanel-sheet {
flex: 1 1 28em;
min-width: 0;
}
.m02-dfcells {
flex: 0 1 24em;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-dfcells__title {
font-weight: var(--font-weight-bold);
}
.m02-dfcells__cell {
width: 5em;
}
.m02-dfcells .ui-input.is-bad {
border-color: var(--color-danger);
border-style: dashed;
}
/* 칸 종류 = 배지 부품(받음 info · 변수 warning · 출력 success) */
/* 식 보기 */
/* 겉 = 카드 부품 · 여기는 쌓기 · 안 여백만 */
.m02-df-formula.ui-card {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8) var(--spacing-12);
}
.m02-df-formula__title {
font-weight: var(--font-weight-bold);
}
.m02-df-formula__line {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: var(--spacing-4);
border-top: 1px dashed var(--color-border);
}
.m02-df-formula__valued {
color: var(--color-text-secondary);
}
.m02-df-formula__branches {
margin: 0;
padding-left: var(--spacing-16);
list-style: none;
}
.m02-df-formula__branches .is-now {
font-weight: var(--font-weight-bold);
}
@@ -1,716 +0,0 @@
/* =============================================================================
* M02_DF_FuncPanel.ts
* 단가산출 다이어그램 함수 상자 판 — 상자 하나의 시트를 우리 스프레드시트 편집기로 · 받은 알약 줄(눌러/끌어 칸에 놓기) ·
* 변수 만들기 · 출력 칸 하나 · 단위 · 틀 단추(진짜 엑셀 수식) · 한 줄 오류
* 모양은 `docs/raw/M01_쉬운단가/원가다이어그램_정함.md` 「상자 · 선 약속」 · M02_DF_Model 타입 그대로
* 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) · 옛 시험판 코드는 옮겨 고쳐 씀(import 로 기대지 않음)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type { Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type {
CalcValues,
SpreadsheetHandle,
Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet";
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
import { evalSafe, type DetailOptions } from "./M02_DF_Detail";
import {
conditionsOf,
drawCells,
drawFormula,
variableFrom,
type ExtraContext,
} from "./M02_DF_FuncExtra";
import { createRoundRow } from "./M02_DF_RoundRow";
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
import "./M02_DF_FuncPanel.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_banner.css";
import "@ui/ui_template_badge.css";
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;
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
detail: DetailOptions;
handle: SpreadsheetHandle | null;
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
snap: CalcValues;
/** 선 규칙을 지나 이 상자 받는 알약에 들어온 값 */
arrived: Record<string, unknown>;
/** 편집기가 마지막으로 낸 시트 글 — 같으면 다시 안 끼움 */
json: string;
cell: string;
form: VarForm | null;
/** 단위 표준이 제안한 출력 소수자리 */
suggest: number | null;
parts: {
head: HTMLElement;
formula: HTMLElement;
cells: 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 = (sheet: Sheet): Workbook => ({
종류: "통합문서",
판: 1,
열: "cf",
서식: [{}],
시트: [structuredClone(sheet)],
활성: sheet.id,
});
/** 보이는 시트 — 받은 알약 칸에는 선 규칙을 지나 들어온 값(arrived)을 넣어 보임(저장하는 시트에는 안 넣음) */
function withArrived(f: CfFunc, base: Sheet, arrived: Record<string, unknown>): Sheet {
const sheet = structuredClone(base);
for (const i of f.inputs) {
const v = arrived[i.port];
if (i.cell && (typeof v === "number" || typeof v === "string" || typeof v === "boolean"))
sheet.칸[i.cell.toUpperCase()] = { 값: v };
}
return sheet;
}
const shownOf = (st: State): Sheet => withArrived(funcOf(st)!, funcOf(st)!.sheet, st.arrived);
/* ------------------------------ 바꾸기 ------------------------------ */
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 made = variableFrom(form, f, st.cell, usedCells);
if ("why" in made) {
form.why = tf(made.why);
return drawVars(st);
}
const { v, cell } = made;
st.form = null;
patchFunc(st, (x) => {
const sheet = structuredClone(x.sheet);
if (v.example !== null) sheet.칸[cell] = { 값: v.example };
return {
...x,
sheet,
inputs: x.inputs.filter((i) => i.cell !== cell),
variables: [...x.variables.filter((o) => o.name !== v.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;
}
/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */
/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */
const extra = (st: State): ExtraContext => ({
lg: st.lg,
boxId: st.boxId,
detail: st.detail,
arrived: st.arrived,
readOnly: st.readOnly,
place: (port, cell) => placePort(st, port, cell),
setOutput: (cell) => setOutput(st, cell),
patch: (make) => patchFunc(st, make),
used: (f) => usedCells(f),
});
/* ------------------------------ 칸 위 표시 ------------------------------ */
function applyMarks(st: State): void {
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 = createUnitPick({
value: box?.unit ?? "-",
readOnly: st.readOnly,
extra: st.lg.resultUnit ? [st.lg.resultUnit] : [],
ariaLabel: tf("Unit"),
onPick: (v) => {
patchBox(st, (x) => ({
...x,
unit: v,
func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func,
}));
void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st)));
},
});
st.parts.head.replaceChildren(
el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }),
el("span", {
className: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "24" },
text: `${tf("Cell")} ${st.cell}`,
}),
el("label", {
className: "m02-dfpanel-unitbox",
children: [el("span", { className: "ui-text ui-text--muted", 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: `ui-badge ui-badge--pill ui-badge--info m02-dfpanel-chip is-in${at ? " is-placed" : ""}`,
text: `${port} (${unit}) · ${at ? `${tf("Placed")} ${at}` : tf("Free")}`,
attrs: { type: "button", "data-size": "24", 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: "ui-text ui-text--muted", text: tf("NoGot") })]),
...(chips.length && !st.readOnly
? [el("span", { className: "ui-text ui-text--muted", text: tf("GotHint") })]
: []),
);
}
function formField(
st: State,
label: keyof typeof TEXT,
key: "name" | "unit" | "min" | "max" | "choices" | "example",
cls = "",
): HTMLElement {
const form = st.form!;
if (key === "unit") {
return el("label", {
className: "m02-dfpanel-fieldbox",
children: [
el("span", { className: "ui-text ui-text--muted", text: tf(label) }),
createUnitPick({
value: form.unit || "-",
ariaLabel: tf(label),
onPick: (u) => ((form.unit = u), drawVars(st)),
}),
],
});
}
const input = el("input", {
className: `ui-input ui-input--sm 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: "ui-text ui-text--muted", 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: "ui-badge ui-badge--pill ui-badge--warning m02-dfpanel-chip is-var",
attrs: { "data-size": "24" },
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: "ui-text ui-text--muted", 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 ui-select--sm 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: "ui-card m02-dfpanel-row m02-dfpanel-form",
children: [
formField(st, "VarName", "name"),
el("label", {
className: "m02-dfpanel-fieldbox",
children: [
el("span", { className: "ui-text ui-text--muted", 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: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "24" },
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: "ui-text ui-text--warning", 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: "ui-text ui-text--muted", 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: "ui-badge ui-badge--pill ui-badge--success m02-dfpanel-chip is-out",
attrs: { "data-size": "24" },
text: outNow,
}),
el("span", { className: "ui-text ui-text--muted", text: tf("OutOnly") }),
],
}),
...(f?.output.cell
? [
createRoundRow({
round: f.output.round,
suggest: st.suggest,
readOnly: st.readOnly,
onApply: (round) => writeRound(st, round),
}),
]
: []),
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: "ui-text ui-text--muted", text: tf("FrameHint") }),
],
}),
);
}
/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */
function writeRound(st: State, round: CfRound | null): void {
patchFunc(st, (x) => {
const { round: _old, ...rest } = x.output;
return { ...x, output: round ? { ...rest, round } : rest };
});
}
/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */
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 shown = shownOf(st);
const hit = f.inputs.find(
(i) =>
i.cell &&
JSON.stringify(shown.칸[i.cell.toUpperCase()]) !==
JSON.stringify(sheet.칸[i.cell.toUpperCase()]),
);
if (hit) {
showToast(tf("Locked"), "error");
st.json = JSON.stringify(shown);
return st.handle?.setDoc(bookOf(shown));
}
// 보이려고 넣은 들어온 값은 저장하는 시트에 남기지 않음
for (const i of f.inputs) {
const key = i.cell.toUpperCase();
if (!i.cell) continue;
if (f.sheet.칸[key]) sheet.칸[key] = f.sheet.칸[key];
else delete sheet.칸[key];
}
st.json = JSON.stringify(withArrived(f, sheet, st.arrived));
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();
const shown = shownOf(st);
st.json = JSON.stringify(shown);
st.handle = createSpreadsheet(st.parts.sheet, bookOf(shown), {
preview: true,
tabs: false,
readOnly: st.readOnly,
conditions: conditionsOf(() => extra(st)),
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);
}
/** 이 상자 받는 알약에 선 규칙을 지나 들어온 값 — 셈이 막히면 빈 값 */
const arrivedOf = (lg: CfLogic, boxId: string, o: DetailOptions): Record<string, unknown> =>
evalSafe(lg, o)?.arrived?.[boxId] ?? {};
/** 함수 상자 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */
export function renderFuncPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: Handlers,
opts: DetailOptions & { readOnly?: boolean } = {},
): void {
const old = states.get(host);
const readOnly = !!opts.readOnly;
if (old && old.boxId === boxId && old.readOnly === readOnly) {
old.lg = lg;
old.on = on;
old.detail = opts;
old.arrived = arrivedOf(lg, boxId, opts);
const f = funcOf(old);
if (old.handle && f && JSON.stringify(shownOf(old)) !== old.json) {
old.json = JSON.stringify(shownOf(old));
old.handle.setDoc(bookOf(shownOf(old)));
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" }),
formula: el("div"),
cells: el("div"),
got: el("div", { className: "m02-dfpanel-row" }),
vars: el("div", { className: "m02-dfpanel-vars" }),
bar: el("div", { className: "m02-dfpanel-bar" }),
msg: el("p", {
className: "ui-banner ui-banner--warning m02-dfpanel-msg",
attrs: { role: "status" },
}),
sheet: el("div", { className: "m02-dfpanel-sheet", text: tf("Loading") }),
};
const st: State = {
boxId,
lg,
on,
readOnly,
detail: opts,
arrived: arrivedOf(lg, boxId, opts),
handle: null,
snap: {},
json: "",
cell: "A1",
form: null,
suggest: null,
parts,
observer: null,
redraw: () => {
drawHead(st);
drawGot(st);
drawVars(st);
drawBar(st);
drawFormula(st.parts.formula, extra(st));
drawCells(st.parts.cells, extra(st));
applyMarks(st);
},
};
states.set(host, st);
host.classList.add("m02-dfpanel");
host.replaceChildren(
parts.head,
parts.formula,
parts.got,
parts.vars,
parts.bar,
parts.msg,
el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }),
);
parts.msg.hidden = true;
st.redraw();
void mountSheet(st);
}
@@ -1,90 +0,0 @@
/* =============================================================================
* M02_DF_FuncText.ts
* 함수 상자 판 글 [한, 영] 짝 · 틀 단추 수식 · 수식 오류 말 — 판 파일이 700줄 안에 들도록 뗌
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
export 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;
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
export 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)"],
];
export 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",
};
@@ -1,119 +0,0 @@
.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__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);
}
/* 고르는 줄 = 목록 줄 부품(ui-list-row) — 여기는 안 여백 · 두 칸 벌림만 */
button.m02-dfip__row {
padding: var(--spacing-8) var(--spacing-12);
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__name .ui-badge {
margin-left: var(--spacing-8);
}
.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 {
width: auto;
}
.m02-dfip__preview th,
.m02-dfip__preview td {
white-space: nowrap;
text-align: center;
}
.m02-dfip__preview tr.is-hit td {
background: color-mix(in srgb, var(--color-warning) 35%, transparent);
font-weight: 700;
}
.m02-dfip__preview {
max-height: 24vh;
overflow: auto;
}
.m02-dfip__unitfield {
display: flex;
gap: var(--spacing-8);
align-items: center;
}
@@ -1,429 +0,0 @@
/* =============================================================================
* 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 } from "@ui/ui_template_elements";
import {
fetchFiles,
fetchRows,
fetchSubs,
type SubInfo,
} from "../../M01_MasterData/M01_MasterData_Api_Fetch";
import { cfPlain, cfValues } from "./M02_DF_Api";
import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model";
import {
GROUPS,
RULE_GROUPS,
matchesRow,
removeElementNamed,
M01_OF,
addElement,
elementOf,
machineFields,
queryOf,
reconcileUnit,
removeElement,
setKind,
valueOf,
type M01Row,
} from "./M02_DF_InputPanel_Build";
import { renderLogicInput } from "./M02_DF_InputPanel_Logic";
import { renderGroupRule } from "./M02_DF_InputPanel_Rule";
import { renderMaterial } from "./M02_DF_InputPanel_Material";
import { renderTablePick } from "./M02_DF_InputPanel_Table";
import { renderVariables } from "./M02_DF_InputPanel_Vars";
import "./M02_DF_InputPanel.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_badge.css";
import "@ui/ui_template_list_row.css";
import "@ui/ui_template_toolbar.css";
/** Api 모양 — 서버 Api 가 채워지면(사용자 요소 = Model 모양 · 값 물음 = ref) 그 모양을 그대로 씀 */
interface Answer {
id: string;
ok: boolean;
value?: number | string | null;
}
const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise<Answer[]>;
const TEXT = {
Kind: ["무엇을 넣을까요?", "What goes in?"],
Variable: ["변수", "Variable"],
ModeFixed: ["고정", "Fixed"],
ModeRule: ["조건 찾기", "Find by rule"],
RuleTag: ["조건 찾기", "Rule"],
RuleLine: ["{f} 이(가) 알약 「{n}」 값과 같음", "{f} equals pill “{n}”"],
Logic: ["다른 로직", "Another logic"],
인력: ["인력", "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}"],
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: "ui-text ui-text--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.mode === "logic"
? "다른 로직"
: (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, "변수" as const].map((k) =>
createButton({
label: k === "변수" ? t("Variable") : k === "다른 로직" ? t("Logic") : t(k),
variant: k === kind ? "filled" : "ghost",
size: "sm",
onClick: () => k !== kind && on.change(setKind(lg, boxId, k)),
}),
),
});
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 === "다른 로직") renderLogicInput(body, lg, boxId, on);
else if (kind === "소요량" || kind === "계수") renderTablePick(body, lg, boxId, kind, on);
else if (kind === "재료") renderMaterial(body, lg, boxId, on, (l, e) => addElement(l, boxId, e));
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 body = el("div", { className: "m02-dfip__stack" });
let mode: "fixed" | "rule" = "fixed";
const draw = (): void => {
if (mode === "rule")
renderGroupRule(body, lg, box.id, group, (l, e) => addElement(l, box.id, e), rulePreview, on);
else {
const find = el("div", { className: "m02-dfip__find" });
body.replaceChildren(find);
void drawFind(find, lg, box, group, on);
}
};
// 인력 · 기계 · 유가전력은 방식(고정 / 조건 찾기)을 고름 — 재료는 재료 판 · 그 밖은 고정만
const chips = el("div", { className: "m02-dfip__chips" });
const drawChips = (): void =>
chips.replaceChildren(
...(["fixed", "rule"] as const).map((m) =>
createButton({
label: m === "fixed" ? t("ModeFixed") : t("ModeRule"),
variant: mode === m ? "filled" : "ghost",
size: "sm",
onClick: () => ((mode = m), drawChips(), draw()),
}),
),
);
host.replaceChildren(chosen, ...(RULE_GROUPS.includes(group) ? [chips] : []), body);
drawChosen(chosen, lg, box, group, on);
if (RULE_GROUPS.includes(group)) drawChips();
draw();
}
/** 조건 찾기 미리 보기 — 알약 값과 이름(키)이 같은 마스터 줄 */
async function rulePreview(
group: CfGroup,
field: "이름" | "키",
value: unknown,
): Promise<{ found: number; names: string[]; unit: string }> {
if (value === null || value === undefined || value === "")
return { found: 0, names: [], unit: "" };
const page = await fetchRows(await fileOf(group), 1, 50, String(value), false, "", "");
const hit = (page.rows as M01Row[]).filter((r) => matchesRow(r, field, value));
const units = new Set(hit.map((r) => elementOf(group, r).unit));
return {
found: hit.length,
names: hit.slice(0, 3).map((r) => String(r["이름"] ?? "")),
unit: units.size === 1 ? [...units][0] : "",
};
}
/** 고른 요소마다 알약 한 줄 — 이름 · 값 · 단위 · 값은 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") });
const rule = e.mode === "rule";
if (!rule) values.set(e.ref, val);
return el("div", {
className: "m02-dfip__item",
children: [
el("span", {
className: "m02-dfip__name",
children: [
...(rule
? [
el("span", {
className: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "20" },
text: t("RuleTag"),
}),
]
: []),
el("strong", { text: ` ${e.name}` }),
...(rule && e.rule
? [
el("div", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: t("RuleLine", { f: e.rule.field, n: e.rule.from.name }),
}),
]
: []),
],
}),
val,
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: e.unit || "-",
}),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () =>
on.change(
rule
? removeElementNamed(lg, box.id, e.name)
: removeElement(lg, box.id, e.ref),
),
}),
],
});
})
: [el("p", { className: "ui-text ui-text--muted", text: t("NoneChosen") })]),
);
const items = els
.filter((e) => e.mode !== "rule")
.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
if (!items.length) return;
void apiValues(items).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);
}),
() => undefined, // 값을 못 받아도 요소는 넣을 수 있음 — 값 칸만 「—」
);
}
/** 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: "ui-text ui-text--muted", attrs: { "data-text": "14" } });
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: "ui-text ui-text--muted", text: t("Loading") }));
try {
const word = q.input.value.trim();
const page = await fetchRows(file, 1, 40, word, false, sub, detail);
if (mine !== seq) return;
const rows = page.rows as M01Row[];
count.textContent = t("Count", { n: page.total, m: rows.length });
list.replaceChildren(
...(rows.length
? rows.map((r) => rowOf(r))
: [el("p", { className: "ui-text ui-text--muted", text: t("NoRow") })]),
);
} catch (e) {
if (mine === seq)
list.replaceChildren(el("p", { className: "ui-text ui-text--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 ui-list-row ui-sidebar-row",
attrs: { type: "button" },
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("strong", { text: String(r["이름"] ?? "") }),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: ` ${r["규격"] ?? ""}`,
}),
],
}),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
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: "ui-text ui-text--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: "ui-toolbar m02-dfip__bar", children: [...bar, q.root] }),
count,
list,
);
void load();
}
@@ -1,672 +0,0 @@
/* =============================================================================
* M02_DF_InputPanel_Build.ts
* 입력 상자 판의 고르는 규칙 — M01 줄 → 요소 · 입력 값 물음 · 상자 단위 · 변수 검사 · 상자 고치기(새 CfLogic)
* DOM 없음 · 서버 없음(시험이 Node 로 바로 돌림) · 화면 = `M02_DF_InputPanel.ts`
* ========================================================================== */
import type {
CfBox,
CfElementRef,
CfGroup,
CfInput,
CfLogic,
CfLogicRef,
CfMaterialMode,
CfMaterialRule,
CfPort,
CfScope,
CfVariable,
} from "./M02_DF_Model";
import { matchLogic } from "@ui/ui_template_logic_find";
/** 입력 상자가 고르는 그룹 — 화면 차례 */
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>;
/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — 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["규격"]);
// 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름
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 ["표"];
if (i.mode === "logic") return (i.logic?.outputs ?? []).map((o) => o.name);
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 === "기계") {
const [key, field] = ref.split(MACHINE_SEP);
return { group, ref: key, field: field || "가격" };
}
return { group, ref };
}
/* ── 재료 요소 방식 셋 · 다른 로직 입력(브레인 정함 — sub7 Model 타입이 오면 그 타입으로 바꿈) ── */
/* ── 재료 요소 방식 셋 · 다른 로직 입력 — 타입은 Model(`CfMaterialMode` · `CfScope` · `CfMaterialRule` · `CfLogicRef`) ── */
/** 자재 한 품목 — 서버 `/api/m02/df/materials` 한 줄 */
export interface MatItem {
ref: string;
name: string;
spec: string;
unit: string;
price: number | null;
구분?: string;
상세구분?: string;
}
const squash = (v: unknown): string =>
String(v ?? "")
.replace(/\s+/g, "")
.toLowerCase();
/** 규칙이 이 품목을 잡나 — value = 변수 알약 값 */
export function matches(
item: Pick<MatItem, "name" | "spec">,
rule: Pick<CfMaterialRule, "field" | "op">,
value: unknown,
): boolean {
const v = squash(value);
if (!v) return false;
const target = squash(rule.field === "규격" ? item.spec : item.name);
return rule.op === "같음" ? target === v : target.includes(v);
}
/** 지금 잡히는 것 미리 보기 글 — 0 이면 까닭 · 여럿이면 알림(noun = 재료 · 줄) */
export function catchNote(
found: number,
firstName = "",
noun = "재료",
): { kind: "none" | "one" | "many"; text: string } {
if (found === 0)
return {
kind: "none",
text: `맞는 ${noun}${noun === "줄" ? "이" : "가"} 없음 — 범위나 규칙을 넓혀 봄`,
};
if (found === 1) return { kind: "one", text: `${noun} 하나가 잡힘 — ${firstName}` };
return {
kind: "many",
text: `${noun} ${found} 개가 잡힘 — 둘 이상이면 ${noun === "줄" ? "첫 줄" : "가장 싼 값"}을 씀 · 규칙을 더 좁히면 하나로 잡힘`,
};
}
/** 쪽 범위 — total 개를 size 씩 · page 는 1 부터 · 앞 번호 · 뒤 번호(1 부터) */
export function pageRange(
total: number,
page: number,
size: number,
): { from: number; to: number; pages: number } {
const pages = Math.max(1, Math.ceil(total / size));
const p = Math.min(Math.max(1, page), pages);
return { from: total ? (p - 1) * size + 1 : 0, to: Math.min(total, p * size), pages };
}
/** 이 로직의 변수 알약 — 입력 상자 변수마다(규칙의 「알약」 고르기 목록) */
export function variablePills(
lg: CfLogic,
): { port: CfPort; unit: string; example: CfVariable["example"] }[] {
return lg.boxes.flatMap((b) =>
b.input?.mode === "variable"
? b.input.variables.map((v) => ({
port: { box: b.id, name: v.name },
unit: v.unit,
example: v.example,
}))
: [],
);
}
/** 선택 자리 · 조건 찾기 요소 — ref 는 기본 품목(없으면 빈 글) */
export function materialElement(
mode: CfMaterialMode,
name: string,
scope: CfScope,
extra: { rule?: CfMaterialRule; item?: Pick<MatItem, "ref" | "spec" | "unit"> } = {},
): CfElementRef {
const item = extra.item;
return {
ref: mode === "rule" ? "" : (item?.ref ?? ""),
name: name.trim(),
slot: name.trim(),
spec: item?.spec ?? "",
unit: item?.unit || "-",
mode,
scope: Object.fromEntries(Object.entries(scope).filter(([, v]) => v)) as CfScope,
...(mode === "rule" && extra.rule ? { rule: extra.rule } : {}),
...(mode === "choice" && item?.ref ? { default: item.ref } : {}),
};
}
/** 재료 요소를 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string | null {
if (!el.name) return "자리 이름을 적어 주세요";
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
if (el.mode === "rule" && el.rule?.field === "검색어") {
if (!el.rule.template?.trim() || !templateSlots(el.rule.template).length)
return "검색어 틀에 자리 {이름} 이 하나는 있어야 함";
if (
(el.rule.slots ?? []).length < templateSlots(el.rule.template).length ||
(el.rule.slots ?? []).some((x) => !x.from.name)
)
return "틀의 자리마다 변수 알약을 골라 주세요";
return null;
}
if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
if (
el.mode !== "fixed" &&
!(el.mode === "choice" && el.default) &&
!Object.keys(el.scope ?? {}).length
)
return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요";
return null;
}
/** 다른 로직에서 읽어 둘 것 — 내보내는 알약 = 출력 상자마다 · 받는 알약 = 설계 때 정할 것(변수 · 재료 자리 · 이어지지 않은 다른 로직 받는 알약)
* examples = 받는 알약마다 그 로직 자기 예시값(선이 없을 때 값 차례의 끝) */
export function logicRefOf(key: string, other: CfLogic): CfLogicRef {
const outputs = other.boxes
.filter((b) => b.kind === "output")
.map((b) => ({ name: b.name, unit: b.output?.unit || b.unit || "-" }));
const designs: { name: string; unit: string }[] = [];
const examples: Record<string, number | string> = {};
const addDesign = (name: string, unit: string, example?: number | string | null): void => {
designs.push({ name, unit: unit || "-" });
if (example !== null && example !== undefined && example !== "") examples[name] = example;
};
for (const b of other.boxes) {
for (const v of b.input?.variables ?? b.func?.variables ?? [])
addDesign(v.name, v.unit, v.example);
if (b.input?.mode === "group")
for (const e of b.input.elements)
if (e.mode === "choice" || e.mode === "rule") addDesign(e.slot || e.name, e.unit);
if (b.input?.mode === "logic")
for (const d of b.input.logic?.designs ?? [])
if (!other.links.some((l) => l.to.box === b.id && l.to.name === d.name))
addDesign(d.name, d.unit, b.input.logic?.examples?.[d.name]);
}
const seen = new Set<string>();
return {
key,
name: other.name,
outputs: outputs.filter((o) => !seen.has(o.name) && !!seen.add(o.name)),
designs: designs.filter((d) => (seen.has(`d:${d.name}`) ? false : !!seen.add(`d:${d.name}`))),
...(Object.keys(examples).length ? { examples } : {}),
};
}
/* ── 마스터 로직 고르기 — 목록 · 거름 · 쪽 · 순환 막음(36-6) ── */
/** 마스터 로직 한 줄 — M01 `fetchLogics` 의 줄(자기 시험 로직 DF-… 도 같은 꼴로 합침) */
export interface MasterLogicBrief {
file: string;
구분: string;
상세구분: string;
키: string;
원문번호: string;
이름: string;
결과단위: string;
/** 쉼표로 여럿(208-1) */
별칭?: string;
blocked?: boolean;
reasons?: string[];
}
/** 찾기 — 공용 `matchLogic`(띄어 나눈 말마다 이름 · 원문번호 · 키 · 구분 · 상세구분 · 별칭 · 208-1) · 원문(구분) · 장(상세구분) 거름 */
export function filterLogics(
items: MasterLogicBrief[],
q: { text: string; book?: string; chapter?: string },
): MasterLogicBrief[] {
return items.filter((i) => {
if (q.book && i.구분 !== q.book) return false;
if (q.chapter && i.상세구분 !== q.chapter) return false;
return matchLogic(q.text, i).ok;
});
}
/** 원문(구분) 목록 · 장(상세구분) 목록 — 나온 차례대로 */
export function logicBooks(items: MasterLogicBrief[]): string[] {
return [...new Set(items.map((i) => i.구분).filter(Boolean))];
}
export function logicChapters(items: MasterLogicBrief[], book: string): string[] {
return [
...new Set(
items
.filter((i) => !book || i.구분 === book)
.map((i) => i.상세구분)
.filter(Boolean),
),
];
}
/** 계산 방법을 흐름도로 읽은 로직은 출력 상자가 비목별이라 노무비 · 재료비 · 경비 · 계 알약 넷으로 보임 */
export const COST_OUTPUTS = ["노무비", "재료비", "경비", "계"] as const;
/** 마스터 로직 고른 것 → 다른 로직 입력 값 — 흐름이면 출력 상자 · 변환이면 비용 넷 */
export function masterRef(
key: string,
flow: CfLogic,
converted: boolean,
resultUnit: string,
): CfLogicRef {
const ref = logicRefOf(key, flow);
return converted
? { ...ref, outputs: COST_OUTPUTS.map((name) => ({ name, unit: resultUnit || "-" })) }
: ref;
}
/** 로직 줄(입력 · 자동값)에서 바로 읽은 다른 로직 입력 값 — 흐름 변환이 아직 비어 있을 때 · 받는 알약 = 입력마다 · 예시값 = 자동값 */
export function rowRef(
key: string,
name: string,
resultUnit: string,
inputs: { 이름: string; 단위?: string }[],
auto: Record<string, unknown>,
): CfLogicRef {
const examples: Record<string, number | string> = {};
for (const i of inputs) {
const v = auto[i.이름];
if (typeof v === "number" || (typeof v === "string" && v !== "")) examples[i.이름] = v;
}
return {
key,
name,
outputs: COST_OUTPUTS.map((n) => ({ name: n, unit: resultUnit || "-" })),
designs: inputs.map((i) => ({ name: i.이름, unit: i.단위 || "-" })),
...(Object.keys(examples).length ? { examples } : {}),
};
}
/** 이 로직이 자기 자신을 부르거나 돌고 도나 — `load` 가 로직을 주면 입력 상자의 「다른 로직」 을 따라가 봄(깊이 8) */
export async function wouldCycle(
selfKey: string | null,
startKey: string,
load: (key: string) => Promise<CfLogic | null>,
depth = 8,
): Promise<boolean> {
if (!selfKey) return false;
const seen = new Set<string>();
const walk = async (key: string, left: number): Promise<boolean> => {
if (key === selfKey) return true;
if (seen.has(key) || left <= 0) return false;
seen.add(key);
const lg = await load(key).catch(() => null);
for (const b of lg?.boxes ?? []) {
const next = b.input?.mode === "logic" ? b.input.logic?.key : undefined;
if (next && (await walk(next, left - 1))) return true;
}
return false;
};
return walk(startKey, depth);
}
/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */
export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic {
return {
...lg,
boxes: lg.boxes.map((b) => {
if (b.id !== id || !b.input) return b;
const input: CfInput = ref
? { mode: "logic", elements: [], variables: [], logic: ref }
: { mode: "variable", elements: [], variables: [] };
return { ...b, input, unit: unitOfInput(input) };
}),
links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), // 나가던 선 · 그 로직의 받는 알약으로 들어오던 선 모두
};
}
/** 상자 단위 — 모두 같으면 그 단위 · 여럿이거나 없으면 「-」 */
export function unitOfInput(input: CfInput): string {
if (input.table) return "-";
if (input.mode === "logic") {
const u = (input.logic?.outputs ?? []).map((o) => o.unit || "-");
return u.length && u.every((x) => x === u[0]) ? u[0] : "-";
}
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: "logic", elements: [], variables: [] }, unit: "-" };
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) => (el.ref !== "" && e.ref === el.ref) || e.name === el.name)
? b
: { ...b, input: { ...cur, table: undefined, elements: [...cur.elements, el] } };
},
pills,
);
}
/** 이름으로 빼기 — 재료 조건 찾기 요소는 품목 키가 없어 이름(알약 이름)으로 가름 */
export function removeElementNamed(lg: CfLogic, id: string, name: string, pills?: Pills): CfLogic {
return touch(
lg,
id,
(b) => ({
...b,
input: { ...inputOf(b), elements: inputOf(b).elements.filter((e) => e.name !== name) },
}),
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)) };
}
/* ── 조건 찾기(rule) — 인력 · 기계 · 유가전력도 씀: 이름 또는 키가 변수 알약 값과 같은 줄(마스터 로직의 LB:{직종} · EQ:{기계} 꼴) ── */
/** 조건 찾기를 쓰는 그룹 — 재료는 재료 판(규격 · 이름 포함) · 선택 자리(choice)는 재료만 */
export const RULE_GROUPS: CfGroup[] = ["인력", "기계", "유가전력"];
export type GroupRuleField = "이름" | "키";
/** 이 줄이 규칙에 맞나 — 이름 또는 키(원문번호도)가 알약 값과 같음(띄어쓰기 · 대소문자는 가리지 않음) */
export function matchesRow(row: M01Row, field: GroupRuleField, value: unknown): boolean {
const v = squash(value);
if (!v) return false;
const targets = field === "키" ? [row["키"], row["원문번호"]] : [row["이름"]];
return targets.some((x) => squash(x) === v);
}
/** 조건 찾기 요소 — 품목 키는 비우고 알약 이름 = 자리 이름 · 규칙 = 같음(Model 규칙 꼴에 「키」 칸을 더해 씀) */
export function groupRuleElement(
name: string,
field: GroupRuleField,
from: CfMaterialRule["from"],
): CfElementRef {
const rule = { field, op: "같음", from } as unknown as CfMaterialRule;
return { ref: "", name: name.trim(), slot: name.trim(), spec: "", unit: "-", mode: "rule", rule };
}
/** 넣기 전 검사 — 쉬운 말 한 줄 · 괜찮으면 null */
export function checkGroupRule(el: CfElementRef, others: CfElementRef[]): string | null {
if (!el.name) return "자리 이름을 적어 주세요";
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
if (!el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
return null;
}
/* ── 재료 조건 찾기 field 「검색어」 — 틀(「레미콘 {굵은골재최대치수}-{강도}-{슬럼프}」) 의 자리마다 변수 알약 값을 끼운 글로 찾음 ── */
/** 틀 안 자리 이름들 — 나온 차례 · 겹침 없이 */
export function templateSlots(template: string): string[] {
return [
...new Set([...template.matchAll(/\{([^{}]+)\}/g)].map((m) => m[1].trim()).filter(Boolean)),
];
}
/** 틀을 채운 글 — valueOf 가 알약 값을 줌 · 값이 빈 자리는 missing 에 모음(그 자리는 그대로 둠) */
export function fillTemplate(
template: string,
slots: { name: string; from: CfPort }[],
valueOf: (port: CfPort) => unknown,
): { text: string; missing: string[] } {
const missing: string[] = [];
const text = template.replace(/\{([^{}]+)\}/g, (all, raw: string) => {
const name = raw.trim();
const slot = slots.find((x) => x.name === name);
const v = slot ? valueOf(slot.from) : undefined;
if (v === null || v === undefined || v === "") {
if (!missing.includes(name)) missing.push(name);
return all;
}
return String(v);
});
return { text, missing };
}
/** 검색어 틀 규칙 — 첫 자리의 알약이 from(Model 호환) */
export function templateRule(
template: string,
slots: { name: string; from: CfPort }[],
): CfMaterialRule {
return {
field: "검색어",
op: "포함",
from: slots[0]?.from ?? { box: "", name: "" },
template,
slots,
};
}
@@ -1,389 +0,0 @@
/* =============================================================================
* M02_DF_InputPanel_Logic.ts
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽) 가운데 하나를 고르면
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
* 흐름이 없는 로직은 서버가 계산 방법을 흐름도로 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { aliasNote, matchLogic } from "@ui/ui_template_logic_find";
import {
fetchAuto,
fetchLogic,
fetchLogics,
} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
import {
filterLogics,
logicBooks,
logicChapters,
logicRefOf,
masterRef,
rowRef,
pageRange,
setKind,
setLogicInput,
wouldCycle,
type MasterLogicBrief,
} from "./M02_DF_InputPanel_Build";
import "@ui/ui_template_text.css";
import "@ui/ui_template_badge.css";
import "@ui/ui_template_list_row.css";
import "@ui/ui_template_toolbar.css";
const TEXT = {
Help: [
"마스터 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
"Use any master logic or a logic of mine as an input",
],
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
Book: ["원문", "Source"],
Chapter: ["장", "Chapter"],
All: ["전체", "All"],
Loading: ["찾는 중…", "Searching…"],
None: ["찾는 로직 없음", "No logic found"],
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
Prev: ["이전", "Prev"],
Next: ["다음", "Next"],
Blocked: ["읽을 수 없음", "Unreadable"],
Chosen: ["고른 로직", "Chosen logic"],
Converted: [
"이 로직의 계산 방법을 흐름도로 읽음 — 파일은 안 바뀜",
"Old logic read as a flow — the file is unchanged",
],
Notes: ["못 옮긴 곳", "Not carried over"],
Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"],
Designs: [
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)",
"Pills it takes — its design inputs",
],
NoOut: ["출력 상자가 없음", "No output box"],
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
Change: ["다른 로직 고르기", "Pick another"],
Remove: ["빼기", "Remove"],
Self: ["이 로직 자신은 못 고름", "Can't pick itself"],
Cycle: [
"돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름",
"That would loop — a logic that uses this one can't be picked",
],
NoFlow: [
"이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음",
"Can't read this logic's flow yet",
],
Failed: ["불러오지 못함", "Could not load"],
Wait: ["값 셈 중…", "Calculating…"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
TEXT[key][currentLanguageIndex] as string,
);
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
const plain = (e: unknown): string => {
try {
return cfPlain(e);
} catch {
return e instanceof Error ? e.message : t("Failed");
}
};
const PAGE = 12;
/** 마스터 로직 목록 — 한 번 받아 둠(M01 `fetchLogics`) */
let cache: Promise<MasterLogicBrief[]> | null = null;
const loadAll = (): Promise<MasterLogicBrief[]> =>
(cache ??= fetchLogics().catch((e: unknown) => ((cache = null), Promise.reject(e))));
/** 고른 로직에서 읽은 것 — 계산 방법을 흐름도로 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
/** 마스터 로직 하나를 읽어 흐름도로 — 흐름 받기 길(흐름이 없으면 계산 방법을 흐름도로 읽음) */
const flowOf = (
item: Pick<MasterLogicBrief, "file" | "키">,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> =>
cfLogicFlow(item.file, item.키);
/** 다른 로직 입력 판 */
export function renderLogicInput(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
): void {
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
if (ref) return void showChosen(host, lg, boxId, ref, on);
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const all = { value: "", text: t("All") };
let book = "";
let chapter = "";
let page = 1;
let items: MasterLogicBrief[] = [];
const bookBox = createSelectField({
label: t("Book"),
options: [all],
value: "",
compact: true,
onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()),
});
const chapterBox = createSelectField({
label: t("Chapter"),
options: [all],
value: "",
compact: true,
onChange: (v) => ((chapter = v), (page = 1), draw()),
});
const fillChapters = (): void =>
chapterBox.setOptions(
[all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))],
chapter,
);
const list = el("div", { className: "m02-dfip__list" });
const info = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } });
const prev = createButton({
label: t("Prev"),
variant: "ghost",
size: "sm",
onClick: () => ((page -= 1), draw()),
});
const next = createButton({
label: t("Next"),
variant: "ghost",
size: "sm",
onClick: () => ((page += 1), draw()),
});
const draw = (): void => {
const rows = filterLogics(
items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌
{ text: q.input.value, book, chapter },
);
const range = pageRange(rows.length, page, PAGE);
page = Math.min(page, range.pages);
const shown = rows.slice((page - 1) * PAGE, page * PAGE);
info.textContent = t("Total", {
n: rows.length,
a: range.from,
b: range.to,
p: page,
m: range.pages,
});
prev.disabled = page <= 1;
next.disabled = page >= range.pages;
list.replaceChildren(
...(shown.length
? shown.map((i) => {
const b = el("button", {
className: "m02-dfip__row ui-list-row ui-sidebar-row",
attrs: {
type: "button",
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
},
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("strong", { text: i.이름 }),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: ` ${i.원문번호}`,
}),
// 별칭으로 맞았으면 그 별칭을 작게(208-1)
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: ` ${aliasNote(matchLogic(q.input.value, i).alias)}`,
}),
el("span", {
className: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "20" },
text: i.blocked ? t("Blocked") : i.상세구분,
}),
],
}),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: i.결과단위,
}),
],
});
b.addEventListener("click", () => void pick(i));
return b;
})
: [el("p", { className: "ui-text ui-text--muted", text: t("None") })]),
);
};
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
const loadForCycle = async (key: string): Promise<CfLogic | null> => {
const it = items.find((x) => x.키 === key);
return it ? (await flowOf(it)).flow : null;
};
const pick = async (i: MasterLogicBrief): Promise<void> => {
try {
if (i.키 === lg.key) return void showToast(t("Self"), "error");
if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error");
const got = await flowOf(i);
readNotes.set(i.키, { converted: got.converted, notes: got.notes });
// 흐름도로 읽은 것이 아직 비어 있으면 로직 줄의 입력 · 자동값에서 바로 읽음
const empty = got.converted && got.flow.boxes.length === 0;
const row = empty ? await fetchLogic(i.키).catch(() => null) : null;
const picked = row
? rowRef(i.키, i.이름, i.결과단위, row.logic.입력 ?? [], await fetchAuto(i.키))
: got.converted
? masterRef(i.키, got.flow, true, i.결과단위)
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
const done = setLogicInput(lg, boxId, picked);
// 기본 이름이면 고른 로직 이름으로
on.change({
...done,
boxes: done.boxes.map((b) =>
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
),
});
} catch (e) {
showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error");
}
};
let timer = 0;
q.input.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => ((page = 1), draw()), 200);
});
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") }));
host.replaceChildren(
el("p", { className: "ui-text ui-text--muted", text: t("Help") }),
el("div", {
className: "ui-toolbar m02-dfip__bar",
children: [bookBox.root, chapterBox.root, q.root],
}),
info,
list,
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
);
void loadAll().then(
(rows) => {
items = rows;
bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], "");
fillChapters();
draw();
},
(e) => list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: plain(e) })),
);
}
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
function showChosen(
host: HTMLElement,
lg: CfLogic,
boxId: string,
ref: CfLogicRef,
on: { change(lg: CfLogic): void },
): void {
const read = readNotes.get(ref.key);
const values = new Map<string, HTMLElement>();
const errors = el("p", { className: "ui-text ui-text--error" });
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
el("div", {
className: "m02-dfip__item",
children: [
el("span", { className: "m02-dfip__name", text: name }),
...(valueNode
? [valueNode]
: extra
? [
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: extra,
}),
]
: []),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: unit || "-",
}),
],
});
host.replaceChildren(
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
...(read?.converted
? [
el("p", { className: "ui-text ui-text--muted", text: t("Converted") }),
...(read.notes.length
? [
el("details", {
children: [
el("summary", { text: `${t("Notes")} · ${read.notes.length}` }),
...read.notes.map((n) => el("p", { text: n })),
],
}),
]
: []),
]
: []),
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
...(ref.outputs.length
? ref.outputs.map((o) => {
const v = el("strong", { className: "m02-dfip__value", text: "—" });
values.set(o.name, v);
return line(o.name, o.unit, v);
})
: [el("p", { className: "ui-text ui-text--muted", text: t("NoOut") })]),
errors,
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
...(ref.designs.length
? ref.designs.map((d) =>
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
)
: [el("p", { className: "ui-text ui-text--muted", text: t("NoDesign") })]),
el("div", {
className: "m02-dfip__chips",
children: [
createButton({
label: t("Change"),
variant: "ghost",
size: "sm",
onClick: () => on.change(setKind(lg, boxId, "다른 로직")),
}),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(setLogicInput(lg, boxId, null)),
}),
],
}),
);
// 값 — 서버 계산 길(받는 알약 예시값으로)
errors.textContent = t("Wait");
void cfLogicValue(ref.key, ref.examples ?? {}).then(
(r) => {
for (const [name, node] of values) {
const v = r.outputs[name] ?? (r.cost as unknown as Record<string, unknown>)[name];
if (typeof v === "number") node.textContent = fmt(v);
else if (typeof v === "string") node.textContent = v;
}
errors.textContent = r.errors.join(" · ");
},
(e) => (errors.textContent = plain(e)),
);
}
@@ -1,648 +0,0 @@
/* =============================================================================
* M02_DF_InputPanel_Material.ts
* 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수)
* 서버 `/api/m02/df/materials`(cfMaterials)
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch";
import { cfMaterials, cfValues } from "./M02_DF_Api";
import { templateBox } from "./M02_DF_InputPanel_Template";
import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model";
import {
catchNote,
fillTemplate,
templateRule,
checkMaterial,
elementOf,
matches,
materialElement,
pageRange,
removeElementNamed,
variablePills,
type MatItem,
} from "./M02_DF_InputPanel_Build";
import "@ui/ui_template_text.css";
import "@ui/ui_template_badge.css";
import "@ui/ui_template_list_row.css";
import "@ui/ui_template_toolbar.css";
const TEXT = {
Chosen: ["넣은 재료 — 알약마다 이름 · 값 · 단위", "Chosen materials"],
None: ["아직 고른 재료가 없음", "Nothing chosen yet"],
Remove: ["빼기", "Remove"],
Fixed: ["고정", "Fixed"],
Choice: ["선택 자리", "Choice slot"],
Rule: ["조건 찾기", "Find by rule"],
FixedHelp: ["품목 하나를 못 박음", "One fixed item"],
ChoiceHelp: [
"자리만 두고 프로젝트에서 품목을 고름(기본 품목 하나)",
"A slot — pick the item per project",
],
RuleHelp: [
"변수 알약 값으로 재료를 잡음 — 예: 관경 알약 값이 규격에 들어간 흄관",
"Find by a variable pill",
],
Search: ["찾는 말 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
AllSub: ["전체", "All"],
SubLabel: ["구분", "Group"],
DetailLabel: ["상세구분", "Detail"],
Loading: ["찾는 중…", "Searching…"],
NoRow: ["찾는 재료 없음", "Nothing found"],
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
Prev: ["이전", "Prev"],
Next: ["다음", "Next"],
SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"],
Scope: ["범위", "Scope"],
Word: ["찾는 말(범위 안에서)", "Word in scope"],
Default: ["기본 품목", "Default item"],
DefaultNone: ["아직 안 고름 — 아래 목록에서 눌러 고름", "Not chosen — pick one below"],
DefaultClear: ["기본 품목 비움", "Clear"],
RuleHow: ["찾는 방식", "How to find"],
HowPlain: ["규격 · 이름에 알약 값", "Spec / name vs a pill"],
HowTemplate: ["검색어 틀", "Search template"],
TemplateLine: ["검색어 틀 「{t}」", "template “{t}”"],
Missing: ["자리 「{n}」 알약 값이 비어 있음", "Slot “{n}” has no value"],
RuleField: ["찾는 칸", "Field"],
FieldSpec: ["규격", "Spec"],
FieldName: ["이름", "Name"],
RuleOp: ["조건", "Condition"],
OpIn: ["에 알약 값이 들어 있음", "contains the pill value"],
OpEq: ["이 알약 값과 같음", "equals the pill value"],
RulePill: ["변수 알약", "Variable pill"],
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
Put: ["넣기", "Add"],
Pick: ["넣기", "Add"],
Preview: ["지금 잡히는 재료", "Items caught now"],
Price: ["가장 싼 값 {p} {u}", "cheapest {p} {u}"],
RuleLine: ["{f}{o} · 알약 「{n}」 = {v}", "{f} {o} · pill “{n}” = {v}"],
ScopeLine: ["범위 {s}", "scope {s}"],
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,
);
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
/* ── 자재 찾기 ── */
export interface MatQuery {
q: string;
sub?: string;
detail?: string;
page: number;
size: number;
}
export interface MatPage {
total: number;
page: number;
size: number;
items: MatItem[];
}
/** 서버 자재 찾기 — 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기 · 전체 수 */
export async function searchMaterials(q: MatQuery): Promise<MatPage> {
return cfMaterials({ q: q.q, sub: q.sub, detail: q.detail, page: q.page, size: q.size });
}
let subsCache: SubInfo[] | null = null;
async function materialSubs(): Promise<SubInfo[]> {
if (subsCache) return subsCache;
const files = await fetchFiles("재료");
const f = files.find((x) => x.file.includes("자재품목")) ?? files[0];
subsCache = await fetchSubs({ file: f.file });
return subsCache;
}
/** 구분 · 상세구분 고르기 칸 — 바뀌면 onChange */
function scopeBoxes(
init: { sub: string; detail: string },
onChange: () => void,
): { root: HTMLElement; get(): { sub: string; detail: string } } {
const state = { ...init };
const all = { value: "", text: t("AllSub") };
const subBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: t("SubLabel"),
onChange: (v) => ((state.sub = v), (state.detail = ""), fill(), onChange()),
});
const detailBox = createSelectField({
options: [all],
value: "",
compact: true,
ariaLabel: t("DetailLabel"),
onChange: (v) => ((state.detail = v), onChange()),
});
let subs: SubInfo[] = [];
const fill = (): void => {
const d = subs.find((s) => s.name === state.sub)?.details ?? [];
detailBox.root.hidden = !d.length;
detailBox.setOptions([all, ...d.map((x) => ({ value: x, text: x }))], state.detail);
};
fill();
void materialSubs().then(
(s) => {
subs = s;
subBox.setOptions([all, ...s.map((x) => ({ value: x.name, text: x.name }))], state.sub);
fill();
},
() => undefined,
);
return {
root: el("div", {
className: "ui-toolbar m02-dfip__bar",
children: [subBox.root, detailBox.root],
}),
get: () => ({ ...state }),
};
}
/** 품목 찾기 목록 — 찾는 말 · 구분 · 쪽 넘기기 · 눌러 고르기 */
function itemList(
onPick: (item: MatItem) => void,
base?: () => { sub: string; detail: string },
): HTMLElement {
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
const list = el("div", { className: "m02-dfip__list" });
const info = el("span", { className: "ui-text ui-text--muted", attrs: { "data-text": "14" } });
let page = 1;
let seq = 0;
const own = base ? null : scopeBoxes({ sub: "", detail: "" }, () => ((page = 1), void load()));
const scope = (): { sub: string; detail: string } => (base ? base() : own!.get());
const prev = createButton({
label: t("Prev"),
variant: "ghost",
size: "sm",
onClick: () => ((page -= 1), void load()),
});
const next = createButton({
label: t("Next"),
variant: "ghost",
size: "sm",
onClick: () => ((page += 1), void load()),
});
const load = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Loading") }));
try {
const r = await searchMaterials({ q: q.input.value.trim(), ...scope(), page, size: 12 });
if (mine !== seq) return;
const range = pageRange(r.total, page, 12);
info.textContent = t("Total", {
n: r.total,
a: range.from,
b: range.to,
p: Math.min(page, range.pages),
m: range.pages,
});
prev.disabled = page <= 1;
next.disabled = page >= range.pages;
list.replaceChildren(
...(r.items.length
? r.items.map((it) => {
const b = el("button", {
className: "m02-dfip__row ui-list-row ui-sidebar-row",
attrs: { type: "button" },
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("strong", { text: it.name }),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: ` ${it.spec}`,
}),
],
}),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: `${it.price === null ? "" : fmt(it.price)} ${it.unit}`.trim(),
}),
],
});
b.addEventListener("click", () => onPick(it));
return b;
})
: [el("p", { className: "ui-text ui-text--muted", text: t("NoRow") })]),
);
} catch {
if (mine === seq)
list.replaceChildren(el("p", { className: "ui-text ui-text--muted", text: t("Failed") }));
}
};
let timer = 0;
q.input.addEventListener(
"input",
() => (
window.clearTimeout(timer),
(timer = window.setTimeout(() => ((page = 1), void load()), 250))
),
);
void load();
return el("div", {
className: "m02-dfip__find",
children: [
...(own ? [own.root] : []),
q.root,
info,
list,
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
],
});
}
/* ── 잡히는 재료 미리 보기 ── */
export async function previewOf(
el1: CfElementRef,
lg: CfLogic,
): Promise<{
found: number;
names: string[];
cheapest: MatItem | null;
unit: string;
missing?: string[];
}> {
const scope = el1.scope ?? {};
const word = scope.찾는말 ?? "";
if (el1.mode === "rule" && el1.rule?.field === "검색어") {
// 틀의 자리마다 알약 값을 끼운 글로 찾음(띄어쓴 말은 모두 들어간 것)
const valueOf = (port: { box: string; name: string }): unknown =>
variablePills(lg).find((p) => p.port.box === port.box && p.port.name === port.name)?.example;
const { text, missing } = fillTemplate(el1.rule.template ?? "", el1.rule.slots ?? [], valueOf);
if (missing.length) return { found: 0, names: [], cheapest: null, unit: "", missing };
const r = await searchMaterials({
q: `${word} ${text}`.trim(),
sub: scope.구분,
detail: scope.상세구분,
page: 1,
size: 200,
});
return {
found: r.total,
names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
cheapest: cheapestOf(r.items),
unit: commonUnit(r.items),
};
}
if (el1.mode === "rule" && el1.rule) {
const pill = variablePills(lg).find(
(p) => p.port.box === el1.rule!.from.box && p.port.name === el1.rule!.from.name,
);
const value = pill?.example;
if (value === null || value === undefined || value === "")
return { found: 0, names: [], cheapest: null, unit: "" };
const r = await searchMaterials({
q: `${word} ${value}`.trim(),
sub: scope.구분,
detail: scope.상세구분,
page: 1,
size: 200,
});
const hit = r.items.filter((i) => matches(i, el1.rule!, value));
return {
found: hit.length,
names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
cheapest: cheapestOf(hit),
unit: commonUnit(hit),
};
}
const r = await searchMaterials({
q: word,
sub: scope.구분,
detail: scope.상세구분,
page: 1,
size: 200,
});
return {
found: r.total,
names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
cheapest: cheapestOf(r.items),
unit: commonUnit(r.items),
};
}
/** 잡힌 품목 단위가 모두 같으면 그 단위 · 아니면 빈 글 */
const commonUnit = (items: MatItem[]): string =>
items.length && items.every((i) => i.unit === items[0].unit) ? items[0].unit : "";
const cheapestOf = (items: MatItem[]): MatItem | null =>
items
.filter((i) => i.price !== null)
.sort((a, b) => (a.price as number) - (b.price as number))[0] ?? null;
/** 미리 보기 글 — 틀 자리 값이 비면 그 까닭 · 아니면 잡힌 수에 따른 알림 */
const noteOf = (p: {
found: number;
names: string[];
missing?: string[];
}): ReturnType<typeof catchNote> =>
p.missing?.length
? { kind: "none", text: t("Missing", { n: p.missing.join(" · ") }) }
: catchNote(p.found, p.names[0]);
/** 요소 한 줄 요약 — 방식 · 범위 · 규칙 */
function summaryOf(e: CfElementRef, lg: CfLogic): string {
const parts: string[] = [];
const scope = Object.entries(e.scope ?? {})
.map(([, v]) => v)
.join(" / ");
if (scope) parts.push(t("ScopeLine", { s: scope }));
if (e.rule?.field === "검색어") parts.push(t("TemplateLine", { t: e.rule.template ?? "" }));
else if (e.rule) {
const p = variablePills(lg).find(
(x) => x.port.box === e.rule!.from.box && x.port.name === e.rule!.from.name,
);
parts.push(
t("RuleLine", {
f: e.rule.field,
o: e.rule.op === "포함" ? t("OpIn") : t("OpEq"),
n: e.rule.from.name,
v: p?.example ?? "—",
}),
);
}
return parts.join(" · ");
}
/** 재료 판 — 넣은 재료 목록 + 방식 셋 */
export function renderMaterial(
host: HTMLElement,
lg: CfLogic,
boxId: string,
on: { change(lg: CfLogic): void },
addEl: (lg: CfLogic, el: CfElementRef) => CfLogic,
): void {
const box = lg.boxes.find((b) => b.id === boxId);
const els = (box?.input?.elements ?? []) as CfElementRef[];
const chosen = el("div", { className: "m02-dfip__chosen" });
const body = el("div", { className: "m02-dfip__stack" });
let mode: CfMaterialMode = "fixed";
let how: "plain" | "template" = "plain";
const drawChosen = (): void => {
chosen.replaceChildren(
el("h5", { text: `${t("Chosen")} · ${els.length}` }),
...(els.length
? els.map((e) => {
const val = el("strong", { className: "m02-dfip__value", text: "—" });
const note = el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
});
const kind =
e.mode === "choice" ? t("Choice") : e.mode === "rule" ? t("Rule") : t("Fixed");
const row = el("div", {
className: "m02-dfip__item",
children: [
el("span", {
className: "m02-dfip__name",
children: [
el("span", {
className: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "20" },
text: kind,
}),
el("strong", { text: ` ${e.name}` }),
el("div", { className: "ui-text ui-text--muted", text: summaryOf(e, lg) }),
note,
],
}),
val,
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: e.unit || "-",
}),
createButton({
label: t("Remove"),
variant: "ghost",
size: "sm",
onClick: () => on.change(removeElementNamed(lg, boxId, e.name)),
}),
],
});
const ref = e.mode === "choice" ? e.default : e.mode === "rule" ? "" : e.ref;
if (ref)
void cfValues([{ id: ref, group: "재료", ref }] as never)
.then((a) =>
a.forEach(
(x) => x.ok && typeof x.value === "number" && (val.textContent = fmt(x.value)),
),
)
.catch(() => undefined);
if (e.mode && e.mode !== "fixed")
void previewOf(e, lg).then(
(p) => {
const n = noteOf(p);
note.textContent = `${t("Preview")}: ${n.text}`;
note.classList.toggle("is-bad", n.kind !== "one");
if (p.cheapest?.price != null && !ref) val.textContent = fmt(p.cheapest.price);
},
() => undefined,
);
return row;
})
: [el("p", { className: "ui-text ui-text--muted", text: t("None") })]),
);
};
const chips = el("div", { className: "m02-dfip__chips" });
const drawChips = (): void =>
chips.replaceChildren(
...(["fixed", "choice", "rule"] as CfMaterialMode[]).map((m) =>
createButton({
label: m === "fixed" ? t("Fixed") : m === "choice" ? t("Choice") : t("Rule"),
variant: mode === m ? "filled" : "ghost",
size: "sm",
onClick: () => ((mode = m), drawChips(), drawBody()),
}),
),
);
const drawBody = (): void => {
if (mode === "fixed") {
body.replaceChildren(
el("p", { className: "ui-text ui-text--muted", text: t("FixedHelp") }),
itemList((it) =>
on.change(
addEl(lg, {
...elementOf("재료", { 키: it.ref, 이름: it.name, 규격: it.spec, 단위: it.unit }),
mode: "fixed",
}),
),
),
);
return;
}
// 선택 자리 · 조건 찾기 — 이름 · 범위 · (기본 품목 | 규칙) · 미리 보기
const name = createInputField({
type: "text",
label: t("SlotName"),
compact: true,
placeholder: "예: 흄관",
});
const word = createInputField({ type: "text", label: t("Word"), compact: true });
let def: MatItem | null = null;
const scope = scopeBoxes({ sub: "", detail: "" }, () => void refresh());
const note = el("p", { className: "m02-dfip__note" });
const pills = variablePills(lg);
const fieldBox = createSelectField({
label: t("RuleField"),
options: [
{ value: "규격", text: t("FieldSpec") },
{ value: "이름", text: t("FieldName") },
],
value: "규격",
compact: true,
onChange: () => void refresh(),
});
const opBox = createSelectField({
label: t("RuleOp"),
options: [
{ value: "포함", text: t("OpIn") },
{ value: "같음", text: t("OpEq") },
],
value: "포함",
compact: true,
onChange: () => void refresh(),
});
const pillBox = createSelectField({
label: t("RulePill"),
options: pills.map((p, i) => ({
value: String(i),
text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`,
})),
value: "0",
compact: true,
onChange: () => void refresh(),
});
const howBox = createSelectField({
label: t("RuleHow"),
options: [
{ value: "plain", text: t("HowPlain") },
{ value: "template", text: t("HowTemplate") },
],
value: how,
compact: true,
onChange: (v) => ((how = v as "plain" | "template"), drawBody()),
});
const tpl = templateBox(lg, () => void refresh());
const defNote = el("p", { className: "ui-text ui-text--muted" });
const build = (): CfElementRef => {
const sc = scope.get();
if (mode === "rule" && howBox.select.value === "template") {
const g = tpl.get();
const sc2 = scope.get();
return materialElement(
mode,
name.input.value,
{ 구분: sc2.sub, 상세구분: sc2.detail, 찾는말: word.input.value.trim() },
{ rule: templateRule(g.template, g.slots) },
);
}
const rule: CfMaterialRule | undefined =
mode === "rule" && pills[Number(pillBox.select.value)]
? {
field: fieldBox.select.value as CfMaterialRule["field"],
op: opBox.select.value as CfMaterialRule["op"],
from: pills[Number(pillBox.select.value)].port,
}
: undefined;
return materialElement(
mode,
name.input.value,
{ 구분: sc.sub, 상세구분: sc.detail, 찾는말: word.input.value.trim() },
{ rule, item: def ?? undefined },
);
};
let seq = 0;
const refresh = async (): Promise<void> => {
const mine = ++seq;
defNote.textContent = def ? `${t("Default")}: ${def.name} ${def.spec}` : t("DefaultNone");
const cand = build();
note.textContent = `${t("Preview")}: …`;
try {
const p = await previewOf(cand, lg);
if (mine !== seq) return;
const n = noteOf(p);
note.textContent = `${t("Preview")}: ${n.text}${p.cheapest?.price != null ? ` · ${t("Price", { p: fmt(p.cheapest.price), u: p.cheapest.unit })}` : ""}`;
note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`;
} catch {
if (mine === seq) note.textContent = t("Failed");
}
};
name.input.addEventListener("input", () => name.setError(undefined));
word.input.addEventListener("input", () => void refresh());
const add = createButton({
label: t("Put"),
size: "sm",
onClick: () => {
const cand = build();
const why = checkMaterial(cand, els);
name.setError(why ?? undefined);
if (why) return void showToast(why, "error");
// 잡히는 재료의 단위가 하나로 같으면 그 단위를 알약 단위로
void previewOf(cand, lg).then(
(p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)),
() => on.change(addEl(lg, cand)),
);
},
});
const clear = createButton({
label: t("DefaultClear"),
variant: "ghost",
size: "sm",
onClick: () => ((def = null), void refresh()),
});
body.replaceChildren(
el("p", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: mode === "choice" ? t("ChoiceHelp") : t("RuleHelp"),
}),
name.root,
el("h5", { text: t("Scope") }),
scope.root,
word.root,
...(mode === "rule"
? [
howBox.root,
...(howBox.select.value === "template"
? [tpl.root]
: pills.length
? [fieldBox.root, opBox.root, pillBox.root]
: [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })]),
]
: [
el("h5", { text: t("Default") }),
defNote,
clear,
itemList((it) => ((def = it), void refresh())),
]),
note,
add,
);
void refresh();
};
host.replaceChildren(chosen, chips, body);
drawChosen();
drawChips();
drawBody();
}
@@ -1,142 +0,0 @@
/* =============================================================================
* M02_DF_InputPanel_Rule.ts
* 입력 판 — 인력 · 기계 · 유가전력의 「조건 찾기」: 이름 또는 키가 이 로직 변수 알약 값과 같은 줄을 잡음
* (마스터 로직의 LB:{직종} · EQ:{기계} 꼴) · 지금 잡히는 줄 미리 보기(0 · 여럿이면 까닭) · 글 [한, 영] 짝은 아래
* 선택 자리(choice)는 재료만 — 이 판에 없음
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
import type { CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model";
import {
catchNote,
checkGroupRule,
groupRuleElement,
variablePills,
type GroupRuleField,
} from "./M02_DF_InputPanel_Build";
import "@ui/ui_template_text.css";
import "@ui/ui_template_badge.css";
import "@ui/ui_template_list_row.css";
import "@ui/ui_template_toolbar.css";
const TEXT = {
Help: [
"변수 알약 값과 이름(또는 키)이 같은 줄을 잡음 — 예: 직종 알약 값이 「석공」이면 석공 줄",
"Catches the row whose name (or key) equals a variable pill's value",
],
SlotName: ["자리 이름(알약 이름)", "Slot name (pill name)"],
Field: ["같아야 하는 칸", "Column"],
FieldName: ["이름", "Name"],
FieldKey: ["키(원문번호)", "Key (source no.)"],
Pill: ["변수 알약", "Variable pill"],
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
Preview: ["지금 잡히는 줄", "Rows caught now"],
Put: ["넣기", "Add"],
Failed: ["불러오지 못함", "Could not load"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
/** 미리 보기 길 — 그룹 · 칸 · 알약 값으로 마스터 줄을 읽어 잡힌 수 · 이름 · 단위(부르는 쪽이 줌) */
export type RulePreview = (
group: CfGroup,
field: GroupRuleField,
value: unknown,
) => Promise<{ found: number; names: string[]; unit: string }>;
/** 조건 찾기 요소 추가 판 */
export function renderGroupRule(
host: HTMLElement,
lg: CfLogic,
boxId: string,
group: CfGroup,
addEl: (lg: CfLogic, el: CfElementRef) => CfLogic,
preview: RulePreview,
on: { change(lg: CfLogic): void },
): void {
const els = lg.boxes.find((b) => b.id === boxId)?.input?.elements ?? [];
const pills = variablePills(lg);
const name = createInputField({
type: "text",
label: t("SlotName"),
compact: true,
placeholder: group === "인력" ? "예: 직종" : "예: 기종",
});
const field = createSelectField({
label: t("Field"),
options: [
{ value: "이름", text: t("FieldName") },
{ value: "키", text: t("FieldKey") },
],
value: "이름",
compact: true,
onChange: () => void refresh(),
});
const pill = createSelectField({
label: t("Pill"),
options: pills.map((p, i) => ({
value: String(i),
text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`,
})),
value: "0",
compact: true,
onChange: () => void refresh(),
});
const note = el("p", { className: "m02-dfip__note" });
const build = (): CfElementRef =>
groupRuleElement(
name.input.value,
field.select.value as GroupRuleField,
pills[Number(pill.select.value)]?.port ?? { box: "", name: "" },
);
let seq = 0;
const refresh = async (): Promise<void> => {
const mine = ++seq;
const value = pills[Number(pill.select.value)]?.example;
note.textContent = `${t("Preview")}: …`;
try {
const p = await preview(group, field.select.value as GroupRuleField, value);
if (mine !== seq) return;
const n = catchNote(p.found, p.names[0], "줄");
note.textContent = `${t("Preview")}: ${n.text}`;
note.className = `m02-dfip__note${n.kind === "one" ? "" : " is-bad"}`;
} catch {
if (mine === seq) note.textContent = t("Failed");
}
};
const add = createButton({
label: t("Put"),
size: "sm",
onClick: () => {
const cand = build();
const why = checkGroupRule(cand, els);
name.setError(why ?? undefined);
if (why) return void showToast(why, "error");
// 잡히는 줄이 하나면 그 단위를 알약 단위로
void preview(
group,
field.select.value as GroupRuleField,
pills[Number(pill.select.value)]?.example,
).then(
(p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)),
() => on.change(addEl(lg, cand)),
);
},
});
host.replaceChildren(
el("p", { className: "ui-text ui-text--muted", text: t("Help") }),
name.root,
...(pills.length
? [field.root, pill.root, note, add]
: [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })]),
);
if (pills.length) void refresh();
}
@@ -1,332 +0,0 @@
/* =============================================================================
* 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";
import "@ui/ui_template_text.css";
import "@ui/ui_template_badge.css";
import "@ui/ui_template_list_row.css";
import "@ui/ui_template_toolbar.css";
import "@ui/ui_template_table.css";
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", {
className: "ui-table ui-table--data",
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: "ui-text ui-text--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: "ui-text ui-text--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 ui-list-row ui-sidebar-row",
attrs: { type: "button" },
children: [
el("strong", { text: `${s.section} ${s.title}` }),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: `${s.chapter} · ${t("SecLine", { t: s.tables })}`,
}),
],
});
b.addEventListener("click", () => void pickSection(s));
return b;
})
: [el("p", { className: "ui-text ui-text--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 ui-list-row ui-sidebar-row",
attrs: { type: "button" },
children: [
el("strong", { text: h.이름 }),
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
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: "ui-text ui-text--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: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: e instanceof Error ? e.message : t("Failed"),
}),
),
);
}
@@ -1,98 +0,0 @@
/* =============================================================================
* M02_DF_InputPanel_Template.ts
* 입력 판 — 재료 조건 찾기 「검색어 틀」: 틀(예: 「레미콘 {굵은골재최대치수}-{강도}-{슬럼프}」) 입력 · 틀 자리 {이름} 마다
* 값을 줄 변수 알약 고르기 · 글 [한, 영] 짝은 아래 · 재료 판(`M02_DF_InputPanel_Material.ts`)이 부름
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
import type { CfLogic, CfPort } from "./M02_DF_Model";
import { templateSlots, variablePills } from "./M02_DF_InputPanel_Build";
import "@ui/ui_template_text.css";
import "@ui/ui_template_badge.css";
import "@ui/ui_template_list_row.css";
import "@ui/ui_template_toolbar.css";
const TEXT = {
Template: ["검색어 틀 — 자리는 {이름} 으로", "Search template — slots as {name}"],
Placeholder: ["예: 레미콘 {굵은골재최대치수}-{강도}-{슬럼프}", "e.g. Ready-mix {a}-{b}-{c}"],
Help: [
"틀의 자리마다 변수 알약을 고르면 그 값이 끼워진 글로 재료를 찾음(띄어쓴 말은 모두 들어간 것)",
"Each slot takes a variable pill's value; words must all match",
],
Slot: ["자리 「{n}」 ← 알약", "Slot “{n}” ← pill"],
NoSlot: ["틀에 자리 {이름} 을 넣어 주세요", "Add a {slot} to the template"],
NoPill: ["변수 알약이 없음 — 변수 입력 상자를 먼저 만듦", "No variable pill yet"],
} as const;
type TKey = keyof typeof TEXT;
const t = (key: TKey, v: Record<string, string> = {}): string =>
Object.entries(v).reduce(
(s, [k, x]) => s.replace(`{${k}}`, x),
TEXT[key][currentLanguageIndex] as string,
);
export interface TemplateBox {
root: HTMLElement;
/** 지금 틀 · 자리별 알약 — 틀이 비었으면 null */
get(): { template: string; slots: { name: string; from: CfPort }[] };
}
/** 틀 칸 + 자리 고르기 칸 — 바뀌면 onChange */
export function templateBox(lg: CfLogic, onChange: () => void): TemplateBox {
const pills = variablePills(lg);
const tpl = createInputField({
type: "text",
label: t("Template"),
compact: true,
placeholder: t("Placeholder"),
});
const slotBox = el("div", { className: "m02-dfip__stack" });
const picks = new Map<string, HTMLSelectElement>();
const draw = (): void => {
const names = templateSlots(tpl.input.value);
const before = new Map([...picks].map(([k, s]) => [k, s.value]));
picks.clear();
slotBox.replaceChildren(
...(names.length
? pills.length
? names.map((n) => {
// 자리 이름과 같은 알약이 있으면 먼저 고름
const same = pills.findIndex((p) => p.port.name === n);
const sel = createSelectField({
label: t("Slot", { n }),
options: pills.map((p, i) => ({
value: String(i),
text: `${p.port.name}${p.example !== null ? ` = ${p.example}` : ""}`,
})),
value: before.get(n) ?? String(same >= 0 ? same : 0),
compact: true,
onChange: () => onChange(),
});
picks.set(n, sel.select);
return sel.root;
})
: [el("p", { className: "ui-text ui-text--error", text: t("NoPill") })]
: [el("p", { className: "ui-text ui-text--muted", text: t("NoSlot") })]),
);
};
tpl.input.addEventListener("input", () => (draw(), onChange()));
draw();
return {
root: el("div", {
className: "m02-dfip__stack",
children: [
el("p", { className: "ui-text ui-text--muted", text: t("Help") }),
tpl.root,
slotBox,
],
}),
get: () => ({
template: tpl.input.value.trim(),
slots: templateSlots(tpl.input.value).flatMap((n) => {
const p = pills[Number(picks.get(n)?.value)];
return p ? [{ name: n, from: p.port }] : [];
}),
}),
};
}
@@ -1,216 +0,0 @@
/* =============================================================================
* 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 { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick";
import { checkVariable, putVariable, removeVariable } from "./M02_DF_InputPanel_Build";
import "@ui/ui_template_text.css";
import "@ui/ui_template_badge.css";
import "@ui/ui_template_list_row.css";
import "@ui/ui_template_toolbar.css";
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(),
});
// 단위 = 고르기만(표준 단위 목록) — 손글 안 받음
let unitValue = cur?.unit ?? "";
const unit = el("div", { className: "m02-dfip__unitfield" });
const drawUnit = (): void =>
unit.replaceChildren(
el("span", {
className: "ui-text ui-text--muted",
attrs: { "data-text": "14" },
text: t("Unit"),
}),
createUnitPick({
value: unitValue || NO_UNIT,
ariaLabel: t("Unit"),
onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()),
}),
);
drawUnit();
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: unitValue,
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,
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: "ui-text ui-text--muted", text: t("None") })];
host.replaceChildren(el("h5", { text: t("List") }), ...list, form());
};
draw();
}
@@ -1,63 +0,0 @@
.m02-dflink {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m02-dflink-head,
.m02-dflink-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dflink-label {
font-weight: var(--font-weight-bold);
}
.m02-dflink-note {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-dflink-num {
width: 7em;
}
/* 겉 = 띠 알림 부품 */
.m02-dflink-warn {
margin: 0;
}
/* 알약 판 */
.m02-dfpill {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m02-dfpill-title {
font-weight: var(--font-weight-bold);
}
.m02-dfpill-list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0;
padding: 0;
list-style: none;
}
.m02-dfpill-step {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
@@ -1,541 +0,0 @@
/* =============================================================================
* M02_DF_LinkPanel.ts
* 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ·
* 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import {
gateConds,
makeGate,
portsOut,
reaches,
valueChoices,
type CfGateCond,
type CfGateOp,
type CfLogic,
type CfPort,
type CfRound,
type CfRule,
} from "./M02_DF_Model";
import {
GATE_OPS,
commas,
SCALE_OPS,
evalSafe,
labelOf,
linkNotes,
portText,
preview,
unitOf,
valueOf,
type DetailOptions,
} from "./M02_DF_Detail";
import { suggestPlaces } from "./M02_DF_UnitPick";
import "./M02_DF_LinkPanel.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_banner.css";
import "@ui/ui_template_badge.css";
const TEXT = {
Title: ["선 규칙", "Line rule"],
Kinds: ["규칙 고르기", "Pick a rule"],
K_none: ["없음", "None"],
K_lookup: ["표에서 찾기", "Look up in table"],
K_gate: ["조건 통과", "Pass if"],
K_scale: ["바꾸기", "Change"],
K_round: ["끝수", "Rounding"],
Label: ["선 위 글", "Text on the line"],
Before: ["지나가기 전 값", "Value before"],
After: ["지난 뒤 값", "Value after"],
Col: ["조건 칸", "Condition column"],
Match: ["맞출 알약", "Pill to match"],
Take: ["꺼낼 칸", "Column to take"],
AddCond: ["+ 조건 칸", "+ Condition"],
Remove: ["지움", "Remove"],
NoHeader: [
"보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름",
"The sender is not a table or it is not loaded yet",
],
Pick: ["고르기", "pick"],
GateHint: [
"알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음",
"Sent only when the pill passes",
],
Value: ["값", "Value"],
ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"],
Places: ["소수 {n}자리", "{n} decimals"],
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
Apply: ["적용", "Apply"],
WarnBoth: [
"같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침",
"{n} conditional lines pass at once — overlap",
],
WarnNone: [
"같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음",
"None of the {n} conditional lines match",
],
WarnLookup: [
"조건 칸이 없음 — 표에서 줄을 못 고름",
"No condition column — a row can't be picked",
],
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
Count: ["선", "Line"],
And: ["그리고", "and"],
Or: ["또는", "or"],
If: ["조건", "If"],
AddLine: ["+ 조건 줄", "+ Condition line"],
PickOne: ["고르세요", "choose"],
Join: ["잇는 말", "Join with"],
OutRange: ["범위 {n} 밖", "Outside the range {n}"],
Range: ["{n} (범위)", "{n} (range)"],
} as const;
const tk = (key: keyof typeof TEXT, n?: number | string): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
type Kind = "none" | CfRule["kind"];
const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"];
const SEP = "\u0001";
interface Ctx {
lg: CfLogic;
linkId: string;
readOnly: boolean;
headers: string[];
detail: DetailOptions;
change(lg: CfLogic): void;
}
const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`;
const fromKey = (k: string): CfPort => {
const [box, ...rest] = k.split(SEP);
return { box, name: rest.join(SEP) };
};
function setRule(ctx: Ctx, rule: CfRule | undefined): void {
ctx.change({
...ctx.lg,
links: ctx.lg.links.map((l) => {
if (l.id !== ctx.linkId) return l;
const { rule: _old, ...rest } = l;
return rule ? { ...rest, rule } : rest;
}),
});
}
function select(
ctx: Ctx,
options: { value: string; text: string }[],
value: string,
onPick: (v: string) => void,
aria: string,
): HTMLElement {
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
if (!options.some((o) => o.value === value))
s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") }));
s.value = value;
s.disabled = ctx.readOnly;
s.addEventListener("change", () => onPick(s.value));
return s;
}
/** 숫자 칸 — 보일 때만 천 단위 쉼표 · 고치는 동안은 쉼표 없이 */
function numberBox(
ctx: Ctx,
value: number | string,
onCommit: (v: number | string) => void,
aria: string,
): HTMLElement {
const i = el("input", {
className: "ui-input ui-input--sm m02-dflink-num",
attrs: { type: "text", inputmode: "decimal", "aria-label": aria },
});
const shown = (): string => (typeof value === "number" ? commas(value) : String(value));
i.value = shown();
i.readOnly = ctx.readOnly;
i.addEventListener("focus", () => (i.value = String(value)));
i.addEventListener("blur", () => (i.value = shown()));
i.addEventListener("change", () => {
const t = i.value.trim().replace(/,/g, "");
onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t);
});
return i;
}
/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */
const pillOptions = (ctx: Ctx): { value: string; text: string }[] => {
const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? "";
return ctx.lg.boxes
.filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id))
.flatMap((b) =>
portsOut(b).map((p) => ({
value: toKey({ box: b.id, name: p.name }),
text: portText(ctx.lg, { box: b.id, name: p.name }),
})),
);
};
const row = (...children: (HTMLElement | string)[]): HTMLElement =>
el("div", { className: "m02-dflink-row", children });
/* ── 규칙마다 ─────────────────────────────────────────────────────────── */
function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTMLElement[] {
// 꺼낼 칸 = 표 머리 칸만
const cols = [...new Set([...ctx.headers, rule.col].filter(Boolean))];
const colOpts = cols.map((c) => ({ value: c, text: c }));
// 조건 칸 — 「이름·아래」 「이름·위」 짝은 「이름 (범위)」 한 줄만(고르면 칸 이름 = 이름 · Rule 이 아래 < 값 ≤ 위 로 찾음)
const range = (c: string): string | null => {
const m = /^(.+)·(아래|위)$/.exec(c);
return m && cols.includes(`${m[1]}·아래`) && cols.includes(`${m[1]}·위`) ? m[1] : null;
};
const bases = [...new Set(cols.map(range).filter((x): x is string => !!x))];
const condOpts = [
...cols.filter((c) => !range(c) && !bases.includes(c)).map((c) => ({ value: c, text: c })),
...bases.map((x) => ({ value: x, text: tk("Range", x) })),
];
const pills = pillOptions(ctx);
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
const rows = rule.conds.map((c, i) =>
row(
select(
ctx,
condOpts,
c.col,
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }),
tk("Col"),
),
"=",
select(
ctx,
pills,
toKey(c.from),
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }),
tk("Match"),
),
ctx.readOnly
? ""
: createButton({
label: tk("Remove"),
variant: "ghost",
size: "sm",
onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }),
}),
),
);
const out: HTMLElement[] = [...rows];
if (!ctx.readOnly) {
out.push(
row(
createButton({
label: tk("AddCond"),
variant: "ghost",
size: "sm",
disabled: !pills.length || !condOpts.length,
onClick: () =>
put({
conds: [...rule.conds, { col: condOpts[0].value, from: fromKey(pills[0].value) }],
}),
}),
),
);
}
out.push(
row(
el("span", { className: "m02-dflink-label", text: tk("Take") }),
select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")),
),
);
if (!cols.length)
out.push(el("p", { className: "ui-text ui-text--muted", text: tk("NoHeader") }));
return out;
}
/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */
function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null {
const v = ctx.lg.boxes
.find((b) => b.id === p.box)
?.input?.variables.find((x) => x.name === p.name);
return v?.type === "number" && v.range ? v.range : null;
}
/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */
function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] {
const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values });
if (list?.length) {
const key = (v: string | number): string => `${typeof v}:${v}`;
const known = list.some((v) => key(v) === key(c.value));
const options = [
...(known ? [] : [{ value: "", text: tk("PickOne") }]),
...list.map((v) => ({ value: key(v), text: String(v) })),
];
return [
select(
ctx,
options,
known ? key(c.value) : "",
(k) => {
const hit = list.find((v) => key(v) === k);
if (hit !== undefined) onPick(hit);
},
tk("Value"),
),
];
}
const range = rangeOf(ctx, c.from);
const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))];
const n = Number(c.value);
if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) {
out.push(
el("span", {
className: "ui-text ui-text--warning",
text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`,
}),
);
}
return out;
}
function gateEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "gate" }>): HTMLElement[] {
const g = gateConds(rule);
const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join));
const edit = (i: number, patch: Partial<CfGateCond>): void =>
put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c)));
const out: HTMLElement[] = [
el("p", { className: "ui-text ui-text--muted", text: tk("GateHint") }),
];
g.conds.forEach((c, i) => {
out.push(
row(
el("span", {
className: "m02-dflink-label",
text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"),
}),
select(
ctx,
pillOptions(ctx),
toKey(c.from),
(v) => {
const from = fromKey(v);
edit(i, {
from,
value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0,
});
},
tk("Match"),
),
select(
ctx,
GATE_OPS.map((o) => ({ value: o, text: o })),
c.op,
(v) => edit(i, { op: v as CfGateOp }),
tk("Kinds"),
),
...gateValue(ctx, c, (v) => edit(i, { value: v })),
ctx.readOnly || g.conds.length < 2
? ""
: createButton({
label: tk("Remove"),
variant: "ghost",
size: "sm",
onClick: () => put(g.conds.filter((_, j) => j !== i)),
}),
),
);
});
if (!ctx.readOnly) {
const first = g.conds[0];
out.push(
row(
createButton({
label: tk("AddLine"),
variant: "ghost",
size: "sm",
onClick: () => {
const from = first?.from ?? { box: "", name: "" };
// 새 줄 기본 값 — 목록이 있는 알약이면 그 첫 값 · 아니면 0
const value = valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0;
put([...g.conds, { from, op: "=", value }]);
},
}),
...(g.conds.length > 1
? [
el("span", { className: "m02-dflink-label", text: tk("Join") }),
select(
ctx,
[
{ value: "그리고", text: tk("And") },
{ value: "또는", text: tk("Or") },
],
g.join,
(v) => put(g.conds, v as "그리고" | "또는"),
tk("Join"),
),
]
: []),
),
);
}
return out;
}
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
return [
el("p", { className: "ui-text ui-text--muted", text: tk("ScaleHint") }),
row(
select(
ctx,
SCALE_OPS.map((o) => ({ value: o, text: o })),
rule.op,
(v) => setRule(ctx, { ...rule, op: v as typeof rule.op }),
tk("Kinds"),
),
numberBox(
ctx,
rule.value,
(v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }),
tk("Value"),
),
),
];
}
function roundEditor(
ctx: Ctx,
rule: Extract<CfRule, { kind: "round" }>,
unit: string,
): HTMLElement[] {
const put = (next: Partial<CfRound>): void => setRule(ctx, { ...rule, ...next });
const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) }));
const hint = el("span", { className: "m02-dflink-note" });
void suggestPlaces(unit).then((n) => {
if (n === null || n === rule.places || ctx.readOnly) return;
hint.append(
tk("Suggest", n),
" ",
createButton({
label: tk("Apply"),
variant: "ghost",
size: "sm",
onClick: () => put({ places: n }),
}),
);
});
return [
row(
select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")),
select(
ctx,
(["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })),
rule.method,
(v) => put({ method: v as CfRound["method"] }),
tk("K_round"),
),
hint,
),
];
}
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
gate: (from) => makeGate([{ from, op: "≥", value: 0 }]),
scale: () => ({ kind: "scale", op: "×", value: 1 }),
round: () => ({ kind: "round", places: 0, method: "반올림" }),
};
/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */
export function renderLinkPanel(
host: HTMLElement,
lg: CfLogic,
linkId: string,
on: { change(lg: CfLogic): void },
opts: DetailOptions & { readOnly?: boolean } = {},
): void {
const link = lg.links.find((l) => l.id === linkId);
host.classList.add("m02-dflink");
if (!link) return host.replaceChildren();
const ev = evalSafe(lg, opts);
const table = valueOf(ev, link.from);
const headers =
Array.isArray(table) && Array.isArray(table[0])
? table[0].map((h) => String(h ?? "")).filter(Boolean)
: [];
const ctx: Ctx = {
lg,
linkId,
readOnly: !!opts.readOnly,
headers,
detail: opts,
change: on.change,
};
const rule = link.rule;
const kind: Kind = rule?.kind ?? "none";
const parts: HTMLElement[] = [
el("div", {
className: "m02-dflink-head",
children: [
el("strong", {
text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`,
}),
el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
],
}),
row(
el("span", { className: "m02-dflink-label", text: tk("Kinds") }),
...KINDS.map((k) =>
createButton({
label: tk(`K_${k}` as "K_none"),
variant: k === kind ? "filled" : "ghost",
size: "sm",
disabled: ctx.readOnly,
onClick: () =>
k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)),
}),
),
),
];
if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule));
if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule));
if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule));
if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from)));
const pv = preview(link, ev);
parts.push(
row(
el("span", { className: "m02-dflink-label", text: tk("Before") }),
el("span", {
className: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "20" },
text: pv.before,
}),
"→",
el("span", { className: "m02-dflink-label", text: tk("After") }),
el("span", {
className: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "20" },
text: pv.after,
}),
),
row(
el("span", { className: "m02-dflink-label", text: tk("Label") }),
el("span", { text: labelOf(rule) }),
),
);
const warns: string[] = linkNotes(link, ev);
if (rule?.kind === "lookup") {
if (!rule.conds.length) warns.push(tk("WarnLookup"));
if (!rule.col) warns.push(tk("WarnTake"));
}
parts.push(
...warns.map((w) =>
el("p", { className: "ui-banner ui-banner--warning m02-dflink-warn", text: `⚠ ${w}` }),
),
);
host.replaceChildren(...parts);
}
@@ -1,112 +0,0 @@
"""M02 흐름도 — 선 규칙 「표에서 찾기」 · 값 같음(Compute 에서 나눔 · Rule lookup 과 같은 뜻).
표 블록 = 머리 줄 + 줄 · 범위 칸 「이름·아래」 「이름·위」 · 끝 칸 「·범위규칙」.
빈 조건 칸 = 무엇이든 · 꺼낼 칸 빈 줄은 뺌 · 글은 대소문자까지 · 여럿이면 위 끝 최소 줄(옛 find).
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE)
def _num_of(v: Any) -> Decimal | None:
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float, Decimal)):
return Decimal(str(v))
return None
def _same(a: Any, b: Any) -> bool:
"""엑셀 「=」 — 수끼리는 수로 · 글끼리는 대소문자 무시 · 빈 칸은 0 · 빈 글과 같음."""
na, nb = _num_of(a), _num_of(b)
if a is None or b is None:
other = b if a is None else a
return other is None or other == "" or _num_of(other) == 0
if na is not None and nb is not None:
return na == nb
if isinstance(a, str) and isinstance(b, str):
return a.lower() == b.lower()
return False
def _is_err(v: Any) -> bool:
return isinstance(v, str) and re.fullmatch(r"#[A-Z/0!?]+", v) is not None
def _exact(a: Any, b: Any) -> bool:
"""표 찾기 같음 — 글끼리는 대소문자까지(옛 find · Rule lookup FIND) · 그 밖은 엑셀 「=」."""
return a == b if isinstance(a, str) and isinstance(b, str) else _same(a, b)
def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
"""표에서 찾기 — 같음 · 범위(아래 < 값 ≤ 위 · 빈 끝 열림 · 아래 = 위면 같음) · 맞은 줄 하나
· 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌 · 여럿이면 범위 위 끝이 가장 작은 줄
(옛 엔진 find 와 같음 · Rule lookup 과 같음)."""
if not isinstance(block, list) or len(block) < 2:
return None, "오류"
head = [str(h if h is not None else "") for h in block[0]]
out = rule.get("col")
two = out not in head and {out + dv.RANGE_LOW, out + dv.RANGE_HIGH} <= set(head)
if out not in head and not two:
return None, "오류"
tests, ups = [], []
rk = head.index(RANGE_RULE) if RANGE_RULE in head else None
for c in rule.get("conds") or []:
col, want = str(c.get("col")), _cond_of(conds, c.get("from") or {})
if col in head:
i = head.index(col)
tests.append(lambda row, i=i, want=want: row[i] is None or _exact(row[i], want))
elif col + dv.RANGE_LOW in head and col + dv.RANGE_HIGH in head:
lo, hi, x = (
head.index(col + dv.RANGE_LOW),
head.index(col + dv.RANGE_HIGH),
_num_of(want),
)
ups.append(hi)
def fits(row, lo=lo, hi=hi, x=x) -> bool:
low, high = _num_of(row[lo]), _num_of(row[hi])
if low is not None and low == high: # 숫자 하나(아래 = 위) = 같음(브레인 정함)
return x is not None and x == low
word = str(row[rk] or "") if rk is not None else "" # 경계 — 이상 · 미만(Rule 같음)
return (
x is not None
and (
row[lo] is None
or (low is not None and (low <= x if word[:2] == "이상" else low < x))
)
and (
row[hi] is None
or (high is not None and (x < high if word[-2:] == "미만" else x <= high))
)
)
tests.append(fits)
else:
return None, "오류"
at = head.index(out + dv.RANGE_LOW if two else out) # 값 칸이 범위로 갈림 — 아래 = 위면 그 값
rows = [list(r) + [None] * len(head) for r in block[1:]]
hits = [r for r in rows if r[at] not in (None, "") and all(t(r) for t in tests)]
if not hits:
return None, "표에 없는 조건"
for hi in ups: # 위 끝만 적힌 범위(「20 이하」 「40 이하」 …) — 위 끝이 가장 작은 줄
tops = [_num_of(r[hi]) for r in hits if _num_of(r[hi]) is not None]
hits = [r for r in hits if r[hi] is None or not tops or _num_of(r[hi]) == min(tops)]
if len(hits) > 1:
return None, "표에 여럿 맞음"
if two and not _same(hits[0][at], hits[0][head.index(out + dv.RANGE_HIGH)]):
return None, "오류"
return hits[0][at], None
def _cond_of(conds: dict, port: dict) -> Any:
"""조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름(Rule condOf)."""
key = f"{port.get('box')}|{port.get('name')}"
return conds[key] if key in conds else conds.get(port.get("name"))
@@ -1,49 +0,0 @@
/* =============================================================================
* M02_DF_Material.ts
* 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials`(Api `cfMaterials` ·
* 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기) · 단가 = 그 줄의 단가(값을 받는 길과 같은 셈)
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 5. 이 폴더째 지워도 됨.
* ========================================================================== */
import { cfMaterials, type CfMaterial } from "./M02_DF_Api";
export type CfMat = CfMaterial;
export interface CfMatPage {
total: number;
page: number;
size: number;
items: CfMat[];
}
/** 재료 찾기 — q = 찾는 말 · sub = 구분 · detail = 상세구분 · page 는 1 부터 */
export function findMaterials(cond: {
q?: string;
sub?: string;
detail?: string;
page?: number;
size?: number;
}): Promise<CfMatPage> {
return cfMaterials({ ...cond, page: cond.page ?? 1, size: cond.size ?? 10 });
}
const byKey = new Map<string, CfMat | null>();
/** 재료 키 → 그 줄(이름 · 규격 · 단가) — 한 번 물은 것은 다시 안 물음 */
export async function materialOf(key: string): Promise<CfMat | null> {
if (!key) return null;
if (byKey.has(key)) return byKey.get(key) ?? null;
let hit: CfMat | null = null;
try {
hit = (await cfMaterials({ q: key, size: 20 })).items.find((m) => m.ref === key) ?? null;
} catch {
hit = null;
}
byKey.set(key, hit);
return hit;
}
/** 재료 키 → 단가(원) — 못 구하면 null */
export async function priceOf(key: string): Promise<number | null> {
return (await materialOf(key))?.price ?? null;
}
-697
View File
@@ -1,697 +0,0 @@
/* =============================================================================
* M02_DF_Model.ts
* 단가산출 흐름도 Model — 입력 · 함수 · 출력 상자를 선으로 이음 · 함수 상자 = 시트 하나(출력 하나)
* 선 규칙(표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) · 재료 방식 셋 · 다른 로직 입력 · 흐름 따라가기 · 설계 때 정할 것
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 4~8 · 12 · 1판 약속 `원가다이어그램_정함.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 = "노무비" | "재료비" | "경비" | "값";
/** 재료 요소 방식 — 고정(품목 하나) · 선택 자리(프로젝트에서 고름) · 조건 찾기(규격 / 이름에 알약 값)
* 조건 찾기는 인력 · 기계 · 유가전력 요소도 씀(field 「키」 — 입력값으로 요소를 고름) */
export type CfMaterialMode = "fixed" | "choice" | "rule";
/** 재료 찾을 범위 */
export interface CfScope {
구분?: string;
상세구분?: string;
찾는말?: string;
}
/** 재료 조건 찾기 — 품목의 규격 / 이름에 [알약 값] 이 포함 · 같음
* field 「키」 = 요소 ref 의 자리 표시 {…} 에 알약 값을 끼운 요소(op 같음) — ref 꼴 「EQ:{기계}」 · 「EQ:{기계}#가격」 ·
* 「MO:경유:{지역}」 · 「LB:{직종}」 · 재료 검색어 묶음 JSON(검색어 안 {…}) · 옛 엔진 `fill` → `Master.get` 과 같은 뜻 */
export interface CfMaterialRule {
field: "규격" | "이름" | "키" | "검색어";
op: "포함" | "같음";
from: CfPort;
/** field 「검색어」 — 옛 재료 검색어 찾기(master_pick.search) 의 틀(「레미콘 {a}-{b}-{c}」) · scope 구분 · 상세구분 안 */
template?: string;
/** 틀의 자리 {이름} 마다 값을 줄 알약 — from = 첫 자리 */
slots?: { name: string; from: CfPort }[];
}
/** 재료 조건 찾기가 값을 받는 알약 — from + 틀 자리들(겹침 없이) */
export function materialSources(rule: CfMaterialRule): CfPort[] {
const all = [rule.from, ...(rule.slots ?? []).map((x) => x.from)];
return all.filter((p, i) => all.findIndex((q) => q.box === p.box && q.name === p.name) === i);
}
/** 입력 상자가 고른 요소 — ref = 마스터 키 또는 사용자 요소 id
* 재료면 mode · scope · rule · default · slot(알약 이름 = 자리 이름 · 없으면 name) */
export interface CfElementRef {
ref: string;
name: string;
spec: string;
unit: string;
mode?: CfMaterialMode;
scope?: CfScope;
rule?: CfMaterialRule;
/** 기본 품목 키(선택 자리 · 조건 찾기) */
default?: string;
slot?: 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;
}
/** 다른 로직 입력 — 저장 로직(DF-…) · 견본 id · outputs · designs 는 고를 때 그 로직에서 읽어 둔 것 */
export interface CfLogicRef {
key: string;
name?: string;
/** 내보내는 알약 = 그 로직 출력 상자 이름(비용 · 값) */
outputs: CfPill[];
/** 받는 알약 = 그 로직의 「설계 때 정할 것」(변수 · 재료 자리) */
designs: CfPill[];
/** 선 없는 받는 알약의 예시값(키 = 받는 알약 이름) — 값 차례 = 이어진 선 > 넘긴 값 > 이것 > 그 로직 자기 예시값 */
examples?: Record<string, number | string>;
}
export interface CfInput {
mode: "group" | "variable" | "logic";
group?: CfGroup;
elements: CfElementRef[];
/** 소요량 · 계수 = 표(row 가 있으면 그 한 줄) */
table?: { key: string; row?: number };
variables: CfVariable[];
/** mode "logic" */
logic?: CfLogicRef;
}
/** 끝수 — 소수 자리 · 방법 */
export interface CfRound {
places: number;
method: "반올림" | "올림" | "버림";
}
export interface CfFunc {
/** 우리 스프레드시트 시트 하나 */
sheet: Sheet;
/** 받는 알약 → 칸 */
inputs: { port: string; cell: string }[];
variables: CfVariable[];
/** 내보내는 알약 하나 */
output: { cell: string; name: string; unit: string; round?: CfRound };
}
export interface CfOutput {
cost: CfCost;
unit: string;
round?: CfRound;
}
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 type CfGateOp = "=" | "≠" | "≥" | "≤" | ">" | "<";
/** 선 규칙(선 하나에 하나 · 없으면 그대로) — 받는 칸의 엑셀 수식으로 바뀌어 엔진 한 벌로 셈
* lookup = 보낸 표에서 조건 칸마다 맞출 알약 · 꺼낼 칸(범위 칸은 「이름·아래」 「이름·위」)
* gate = 알약 값이 조건을 통과할 때만 보냄 · scale = × ÷ +% -% · round = 끝수 */
/** 조건 통과의 조건 한 줄 */
export interface CfGateCond {
from: CfPort;
op: CfGateOp;
value: number | string;
}
/** 조건 통과 — conds 여러 줄을 join(그리고 · 또는)으로 · 옛 한 줄 꼴(from · op · value)도 읽음
* from · op · value 는 첫 조건과 같게 둠(옛 화면 코드가 그대로 돌게 · makeGate 가 맞춰 줌) — conds 가 있으면 conds 가 이김 */
export type CfGateRule = {
kind: "gate";
conds?: CfGateCond[];
join?: "그리고" | "또는";
from: CfPort;
op: CfGateOp;
value: number | string;
};
export type CfRule =
| { kind: "lookup"; conds: { col: string; from: CfPort }[]; col: string }
| CfGateRule
| { kind: "scale"; op: "×" | "÷" | "+%" | "-%"; value: number }
| ({ kind: "round" } & CfRound);
export interface CfLink {
id: string;
from: CfPort;
to: CfPort;
rule?: CfRule;
}
/** 흐름 한 걸음 — 그 알약 · 그 알약으로 들어온(나간) 선의 규칙 */
export interface CfStep {
box: string;
port: string;
dir: "in" | "out";
link?: string;
rule?: CfRule;
}
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직의 받는 알약(선이 없을 때) */
export interface CfDesign {
box: string;
port: string;
kind: "변수" | "재료 자리" | "다른 로직";
unit: string;
variable?: CfVariable;
element?: CfElementRef;
/** 보일 예시값 — 변수 = variable.example · 다른 로직 = logic.examples(없으면 빈 칸 → 그 로직 자기 예시값) */
example?: number | string | null;
}
/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_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[];
/** 원가 합계 끝수 — 없으면 기본(일위대가표 계금 1원 미만 버림) · null = 끝수 없음(값 그대로 · 옛 로직 변환) */
totalRound?: CfRound | null;
}
/** 알약 하나의 모습 */
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"],
CondPill: ["규칙의 조건 알약이 없음", "The rule's condition pill doesn't exist"],
} 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);
}
/* ── 알약 ───────────────────────────────────────────────────────────────── */
/** 요소 알약 이름 — 재료 선택 자리 · 조건 찾기는 자리 이름(slot) · 나머지는 element.name */
export const elementPort = (e: CfElementRef): string =>
e.mode && e.mode !== "fixed" && e.slot?.trim() ? e.slot.trim() : e.name;
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소(elementPort) · 표면 「표」 · 변수면 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;
if (inp.mode === "logic") return (inp.logic?.outputs ?? []).map((p) => ({ ...p }));
return [
...(inp.mode === "group"
? inp.elements.map((e) => ({ name: elementPort(e), 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 }];
if (box.input?.mode === "logic") return (box.input.logic?.designs ?? []).map((p) => ({ ...p }));
return [];
}
/** 알약 쪽 — 함수 출력 이름이면 out · 출력 상자는 in · 다른 로직 입력은 출력 이름이면 out */
export function portDir(box: CfBox, name: string): "in" | "out" {
if (box.kind === "output") return "in";
if (portsOut(box).some((p) => p.name === name)) return "out";
return "in";
}
/* ── 선 ─────────────────────────────────────────────────────────────────── */
/** 선 규칙이 기대는 알약(조건 찾기 · 조건 통과) */
export function ruleSources(rule: CfRule | undefined): CfPort[] {
if (!rule) return [];
if (rule.kind === "lookup") return rule.conds.map((c) => c.from);
if (rule.kind === "gate") return gateConds(rule).conds.map((c) => c.from);
return [];
}
/** 조건 통과의 조건 줄 · 잇는 말 — 옛 한 줄 꼴은 조건 하나 */
export function gateConds(rule: CfGateRule): { conds: CfGateCond[]; join: "그리고" | "또는" } {
const conds = rule.conds?.length
? rule.conds
: [{ from: rule.from, op: rule.op, value: rule.value }];
return { conds, join: rule.join ?? "그리고" };
}
/** 조건 통과 만들기 — 옛 칸(from · op · value)은 첫 조건과 같게 */
export function makeGate(conds: CfGateCond[], join: "그리고" | "또는" = "그리고"): CfGateRule {
const first = conds[0] ?? { from: { box: "", name: "" }, op: "=" as const, value: "" };
return {
kind: "gate",
conds: clone(conds),
join,
from: { ...first.from },
op: first.op,
value: first.value,
};
}
/** 알약이 가질 수 있는 값 목록 — 조건 값 고르기 · null = 숫자 칸(범위 검사)
* 입력 변수 고르기 = choices · 재료 알약 = 그 요소들의 규격 · 표 알약 = opts.col 칸 값들(opts.values 의 표 덩어리)
* · 함수 결과 · 다른 로직 출력 · 수 변수 = null */
export function valueChoices(
lg: CfLogic,
port: CfPort,
opts: { col?: string; values?: Record<string, Record<string, unknown>> } = {},
): (string | number)[] | null {
const box = boxOf(lg, port.box);
const inp = box?.input;
if (!box || box.kind !== "input" || !inp || inp.mode === "logic") return null;
const v = inp.variables.find((x) => x.name === port.name);
if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null;
if (port.name === TABLE_PORT && inp.table) {
const block = opts.values?.[box.id]?.[TABLE_PORT];
if (!opts.col || !Array.isArray(block) || !Array.isArray(block[0])) return null;
const head = (block[0] as unknown[]).map(String);
const at = head.indexOf(opts.col);
if (at < 0) return null;
const seen: (string | number)[] = [];
for (const row of block.slice(1) as unknown[][]) {
const x = row[at];
if ((typeof x === "number" || typeof x === "string") && x !== "" && !seen.includes(x))
seen.push(x);
}
return seen.length ? seen : null;
}
const specs = inp.elements
.filter((e) => elementPort(e) === port.name && e.spec.trim())
.map((e) => e.spec.trim());
const uniq = [...new Set(specs)];
return uniq.length ? uniq : null;
}
/** 상자 사이 기댐 — 선 + 선 규칙의 조건 알약 + 재료 조건 찾기의 알약(→ 그 상자) */
export function depends(lg: CfLogic): { from: string; to: string }[] {
const out: { from: string; to: string }[] = [];
for (const l of lg.links) {
out.push({ from: l.from.box, to: l.to.box });
for (const p of ruleSources(l.rule)) out.push({ from: p.box, to: l.to.box });
}
for (const b of lg.boxes)
for (const e of b.input?.elements ?? [])
if (e.mode === "rule" && e.rule)
for (const p of materialSources(e.rule)) out.push({ from: p.box, to: b.id });
return out;
}
/** from 상자에서 기댐을 따라 to 상자에 닿나 */
export function reaches(lg: CfLogic, from: string, to: string): boolean {
const deps = depends(lg);
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 deps.filter((d) => d.from === id).some((d) => walk(d.to));
};
return walk(from);
}
/** 선 잇기 — 입력 → 함수 · 출력 · 다른 로직 입력 / 함수 → 함수 · 출력 · 다른 로직 입력 · 출력에서 나감 없음 ·
* 순환 · 같은 받는 자리 두 번 막음 · 규칙(rule)은 함께 붙임(조건 알약이 없으면 막음)
* 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 ·
* 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */
export function link(
lg: CfLogic,
from: CfPort,
to: CfPort,
rule?: CfRule,
): { 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" && b.input?.mode !== "logic") 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") };
const why = ruleWhy(lg, b.id, rule);
if (why) return { lg, why };
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),
);
const made: CfLink = { id, from: { ...from }, to: { box: to.box, name: port } };
if (rule) made.rule = clone(rule);
return { lg: { ...next, links: [...next.links, made] }, why: null };
}
/** 규칙의 조건 알약 검사 — 없거나 돌고 돌면 까닭 */
function ruleWhy(lg: CfLogic, target: string, rule: CfRule | undefined): string | null {
for (const p of ruleSources(rule)) {
const box = boxOf(lg, p.box);
if (!box || !portsOut(box).some((x) => x.name === p.name)) return tl("CondPill");
if (box.id === target || reaches(lg, target, box.id)) return tl("Loop");
}
return null;
}
/** 선 규칙 바꾸기 · 빼기(null) — 조건 알약이 없거나 돌고 돌면 막고 까닭 */
export function setRule(
lg: CfLogic,
id: string,
rule: CfRule | null,
): { lg: CfLogic; why: string | null } {
const l = lg.links.find((x) => x.id === id);
if (!l) return { lg, why: tl("NoPill") };
const why = rule ? ruleWhy(lg, l.to.box, rule) : null;
if (why) return { lg, why };
const links = lg.links.map((x) => {
if (x.id !== id) return x;
const { rule: _old, ...rest } = x;
return rule ? { ...rest, rule: clone(rule) } : rest;
});
return { lg: { ...lg, links }, 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 } });
}
/* ── 흐름 따라가기 ──────────────────────────────────────────────────────── */
/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터 · 한 번씩) */
export function traceBack(lg: CfLogic, port: CfPort): CfStep[] {
const steps: CfStep[] = [];
const seen = new Set<string>();
const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => {
const box = boxOf(lg, p.box);
const key = `${p.box}|${p.name}|${dir}`;
if (!box || seen.has(key)) return;
seen.add(key);
steps.push({
box: p.box,
port: p.name,
dir,
...(via ? { link: via.id } : {}),
...(via?.rule ? { rule: clone(via.rule) } : {}),
});
if (dir === "in") {
for (const l of lg.links.filter((x) => x.to.box === p.box && x.to.name === p.name)) {
visit(l.from, "out");
for (const c of ruleSources(l.rule)) visit(c, "out");
}
return;
}
// 내보내는 알약 — 함수 · 다른 로직은 받는 알약들에서 옴 · 재료 조건 찾기는 그 알약에서 옴
for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsIn(box) : [])
visit(
{ box: box.id, name: q.name },
"in",
lg.links.find((l) => l.to.box === box.id && l.to.name === q.name),
);
for (const e of box.input?.elements ?? [])
if (e.mode === "rule" && e.rule && elementPort(e) === p.name)
for (const q of materialSources(e.rule)) visit(q, "out");
};
const box = boxOf(lg, port.box);
if (!box) return [];
const dir = portDir(box, port.name);
visit(
port,
dir,
dir === "in"
? lg.links.find((l) => l.to.box === port.box && l.to.name === port.name)
: undefined,
);
return steps;
}
/** 그 알약 값이 가는 길 — 선 · 선 규칙의 조건으로 쓰이는 곳 · 재료 조건 찾기 · 함수 · 다른 로직을 지나 출력까지 */
export function traceForward(lg: CfLogic, port: CfPort): CfStep[] {
const steps: CfStep[] = [];
const seen = new Set<string>();
const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => {
const box = boxOf(lg, p.box);
const key = `${p.box}|${p.name}|${dir}`;
if (!box || seen.has(key)) return;
seen.add(key);
steps.push({
box: p.box,
port: p.name,
dir,
...(via ? { link: via.id } : {}),
...(via?.rule ? { rule: clone(via.rule) } : {}),
});
if (dir === "in") {
// 받는 알약 → 그 상자의 내보내는 알약
for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsOut(box) : [])
visit({ box: box.id, name: q.name }, "out");
return;
}
for (const l of lg.links.filter((x) => x.from.box === p.box && x.from.name === p.name))
visit(l.to, "in", l);
// 이 알약을 조건으로 쓰는 선 · 재료 조건 찾기
for (const l of lg.links.filter((x) =>
ruleSources(x.rule).some((c) => c.box === p.box && c.name === p.name),
))
visit(l.to, "in", l);
for (const b of lg.boxes)
for (const e of b.input?.elements ?? [])
if (
e.mode === "rule" &&
e.rule &&
materialSources(e.rule).some((q) => q.box === p.box && q.name === p.name)
)
visit({ box: b.id, name: elementPort(e) }, "out");
};
const box = boxOf(lg, port.box);
if (!box) return [];
visit(port, portDir(box, port.name));
return steps;
}
/* ── 설계 때 정할 것 ─────────────────────────────────────────────────────── */
/** 설계 때 정할 것 — 변수(입력 · 함수 상자) · 재료 선택 자리 · 다른 로직의 받는 알약(선이 없을 때) */
export function designInputs(lg: CfLogic): CfDesign[] {
const out: CfDesign[] = [];
for (const b of lg.boxes) {
const vars = b.input?.variables ?? b.func?.variables ?? [];
for (const v of vars)
out.push({
box: b.id,
port: v.name,
kind: "변수",
unit: v.unit || "-",
variable: clone(v),
example: v.example,
});
for (const e of b.input?.mode === "group" ? b.input.elements : [])
if (e.mode === "choice")
out.push({
box: b.id,
port: elementPort(e),
kind: "재료 자리",
unit: e.unit || "-",
element: clone(e),
});
if (b.input?.mode === "logic")
for (const d of b.input.logic?.designs ?? [])
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === d.name))
out.push({
box: b.id,
port: d.name,
kind: "다른 로직",
unit: d.unit || "-",
example: b.input.logic?.examples?.[d.name] ?? null,
});
}
return out;
}
@@ -1,63 +0,0 @@
.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 {
width: 160px;
max-width: 100%;
}
.m02-dfout__round {
margin: var(--spacing-8) 0;
color: var(--color-text-body);
}
@@ -1,292 +0,0 @@
/* =============================================================================
* M02_DF_OutputPanel.ts
* 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸
* 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, 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, type CfRound } from "./M02_DF_Model";
import { defaultItemRound, roundText } from "./M02_DF_Round";
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
import "./M02_DF_OutputPanel.css";
import "@ui/ui_template_text.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"],
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"],
Round: ["끝수", "Rounding"],
RoundPlaces: ["자리", "Places"],
RoundMethod: ["방법", "Method"],
RoundDefault: ["끝수 기본", "Default rounding"],
RoundStd: ["일위대가표 금액란", "unit-price sheet amount column"],
RoundNone: ["끝수 없음(값 그대로)", "No rounding (value as is)"],
Suggest: ["단위 표준 제안", "Unit standard suggests"],
Apply: ["적용", "Apply"],
RoundReset: ["기본으로", "Back to default"],
RoundUser: ["고른 끝수", "Chosen rounding"],
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: "ui-text ui-text--error" });
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)}${unit && unit !== "-" ? ` ${unit}` : ""}`,
}),
],
}),
);
} else {
got.replaceChildren(
el("strong", { text: to("Got") }),
el("p", { className: "ui-text ui-text--error", text: to("NoLink") }),
);
}
/* 이름 · 비용 종류 · 단위 */
const nameBox = el("input", {
className: "ui-input m02-dfout__input",
attrs: { "data-size": "32", 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: { ...b.output, unit: b.output?.unit ?? b.unit, cost: v as CfCost },
})),
),
});
const setUnit = (next: string): void =>
emit(() =>
withBox(lg, box.id, (b) => ({
...b,
unit: next,
output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next },
})),
);
const unitBox = createUnitPick({
value: box.output?.unit ?? box.unit,
ariaLabel: to("Unit"),
extra: lg.resultUnit.includes("/") ? [lg.resultUnit.split("/")[0]] : [],
onPick: setUnit,
});
/** 단위 표준의 소수자리 제안(주 ① 반올림) — 값(비용 아님)이고 끝수를 안 정했을 때 */
const suggestNote = el("span", { className: "ui-text ui-text--muted" });
if (cost === "값" && !box.output?.round)
void suggestPlaces(box.output?.unit ?? box.unit).then((p) => {
if (p === null) return;
suggestNote.textContent = `${to("Suggest")}: ${to("RoundPlaces")} ${p} · 반올림`;
suggestNote.append(
createButton({
label: to("Apply"),
size: "sm",
variant: "ghost",
onClick: () => setRound({ places: p, method: "반올림" }),
}),
);
});
const field = (label: string, control: HTMLElement): HTMLElement =>
el("label", {
className: "m02-dfout__field",
children: [el("span", { text: label }), control],
});
/* 끝수 — 기본 = 일위대가표 금액란 0.1원 미만 버림(표준 표를 읽음) · 값(비용 아님)은 정하지 않으면 그대로 */
const round = box.output?.round;
const stepName = (n: number): string => (n === 0 ? "1" : String(Number((10 ** -n).toFixed(n))));
let fallback: CfRound = { places: 1, method: "버림" };
const roundNote = el("span", { className: "ui-text ui-text--muted" });
const noteOf = (r: CfRound | undefined): string =>
r
? `${to("RoundUser")}: ${roundText(r)}`
: cost === "값"
? to("RoundNone")
: `${to("RoundDefault")}: ${to("RoundStd")} ${roundText(fallback)}`;
roundNote.textContent = noteOf(round);
if (cost !== "값")
void defaultItemRound().then((r) => {
fallback = r;
roundNote.textContent = noteOf(round);
});
const setRound = (next: CfRound | undefined): void =>
emit(() =>
withBox(lg, box.id, (b) => {
const output = { ...(b.output ?? { cost, unit: b.unit }) };
if (next) output.round = next;
else delete output.round;
return { ...b, output };
}),
);
const placesField = createSelectField({
options: [
{ value: "", text: cost === "값" ? to("RoundNone") : to("RoundDefault") },
...[0, 1, 2, 3, 4].map((n) => ({ value: String(n), text: `${stepName(n)}원 단위` })),
],
value: round ? String(round.places) : "",
compact: true,
ariaLabel: to("RoundPlaces"),
onChange: (v) =>
setRound(v === "" ? undefined : { places: Number(v), method: round?.method ?? "버림" }),
});
const methodField = createSelectField({
options: (["버림", "올림", "반올림"] as const).map((m) => ({ value: m, text: `미만 ${m}` })),
value: round?.method ?? fallback.method,
compact: true,
disabled: !round,
ariaLabel: to("RoundMethod"),
onChange: (v) => round && setRound({ places: round.places, method: v as CfRound["method"] }),
});
const roundBlock = el("div", {
className: "m02-dfout__round",
children: [
el("strong", { text: to("Round") }),
el("div", {
className: "m02-dfout__fields",
children: [
field(to("RoundPlaces"), placesField.root),
field(to("RoundMethod"), methodField.root),
...(round
? [
createButton({
label: to("RoundReset"),
size: "sm",
variant: "ghost",
onClick: () => setRound(undefined),
}),
]
: []),
],
}),
roundNote,
suggestNote,
],
});
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)],
}),
],
}),
roundBlock,
...(cost === "값"
? [el("p", { className: "ui-text ui-text--muted", text: to("PlainHint") })]
: []),
msg,
);
}
@@ -1,172 +0,0 @@
/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */
.m02-dfp {
position: relative;
display: flex;
width: 100%;
min-width: 0;
height: 100%;
border-left: 1px solid var(--color-border);
background: var(--color-canvas);
}
.m02-dfp__open {
display: flex;
flex: 1;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
}
.m02-dfp__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfp__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m02-dfp__tools {
display: flex;
justify-content: flex-end;
gap: var(--spacing-8);
}
/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */
.m02-dfp .m02-dfdg__view {
height: max(320px, 45vh);
}
.m02-dfp__lock {
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.m02-dfp__cost > summary {
cursor: pointer;
font-weight: 600;
padding: var(--spacing-4) 0;
}
/* 왼쪽 가장자리 끌기 — 늘 보이는 손잡이(세로 막대 + 가운데 잡이) */
.m02-dfp__grip {
position: absolute;
top: 0;
bottom: 0;
left: -5px;
z-index: 2;
width: 10px;
cursor: col-resize;
touch-action: none;
background: color-mix(in srgb, var(--color-border) 70%, transparent);
border-radius: 4px;
}
.m02-dfp__grip::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 4px;
height: 40px;
transform: translate(-50%, -50%);
border-radius: 2px;
background: var(--color-text-muted);
}
.m02-dfp__grip:hover,
.m02-dfp__grip:focus-visible,
.m02-dfp.is-resizing .m02-dfp__grip {
background: color-mix(in srgb, var(--color-accent) 40%, transparent);
outline: none;
}
.m02-dfp__grip:hover::after,
.m02-dfp__grip:focus-visible::after,
.m02-dfp.is-resizing .m02-dfp__grip::after {
background: var(--color-accent);
}
.m02-dfp.is-resizing {
user-select: none;
}
/* 접기 = 얇은 띠 — 같은 「흐름도」 단추를 세로로 */
.m02-dfp__strip {
display: flex;
justify-content: center;
width: 100%;
padding: var(--spacing-8) 0;
background: var(--color-surface);
}
/* 공용 작은 단추 크기는 그대로 — 글 방향만 세로 */
.m02-dfp__strip .m02-dfp__toggle {
writing-mode: vertical-rl;
}
.m02-dfp__toggle[aria-expanded="true"] {
color: var(--color-accent);
}
.m02-dfp__strip[hidden],
.m02-dfp__open[hidden],
.m02-dfp__grip[hidden] {
display: none;
}
/* 크게 보기 — 화면 전체 */
.m02-dfp-full {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
overflow: auto;
background: var(--color-canvas);
outline: none;
}
.m02-dfp-full__head {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.m02-dfp-full__head strong {
flex: 1;
font-size: var(--text-body);
}
.m02-dfp-full__diagram .m02-dfdg__view {
height: max(420px, 60vh);
}
.m02-dfp-full__lower {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.m02-dfp-full__lower h3 {
margin: 0 0 var(--spacing-8);
font-size: var(--text-body);
}
@media (max-width: 900px) {
.m02-dfp-full__lower {
grid-template-columns: minmax(0, 1fr);
}
}
-303
View File
@@ -1,303 +0,0 @@
/* =============================================================================
* M02_DF_Panel.ts
* 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand }
* 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) →
* 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange.
* 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage ·
* 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로).
* 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움).
* ========================================================================== */
import { isCtrlKey } from "../../A00_Common/key_shortcut";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { CfLogic } from "./M02_DF_Model";
import { createPaper } from "./M02_DF_Paper";
import { renderStandards } from "./M02_DF_Standards";
import "./M02_DF_Panel.css";
import "@ui/ui_template_toolbar.css";
const TEXT = {
Big: ["크게 보기", "Full screen"],
Standards: ["표준 표", "Standards"],
Toggle: ["흐름도", "Flow"],
ToggleOpen: ["흐름도 펴기", "Open the flow panel"],
ToggleClose: ["흐름도 접기", "Collapse the flow panel"],
Close: ["닫기", "Close"],
Detail: ["상세 내용", "Details"],
Cost: ["원가 · 검증", "Cost · check"],
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
} as const;
const tf = (key: keyof typeof TEXT): string =>
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
export interface FlowPanelOpts {
logic: CfLogic;
readOnly?: boolean;
title: string;
/** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 */
logics?(key: string): Promise<CfLogic | null>;
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
onResize?(px: number): void;
/** 접힘을 부르는 쪽이 정함(처음 값) — 주면 접기 단추는 onFold 만 부르고 기억도 부르는 쪽 몫 */
folded?: boolean;
onFold?(on: boolean): void;
}
export interface FlowPanel {
/** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */
set(lg: CfLogic): void;
destroy(): void;
/** 크게 보기 열기 */
expand(): void;
/** 밖에서 접기 · 펴기(onFold 를 부르지 않음) */
fold(on: boolean): void;
}
/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */
export const PANEL_MIN = 320;
export const STRIP_WIDTH = 40;
/** 패널에서 저절로 맞출 때 가장 작은 확대 — 글이 읽히는 최소 */
export const PANEL_FIT_MIN = 0.6;
/** 패널 「한눈에」 단추 — 전체가 보이게(가장 작은 확대) */
export const PANEL_FIT_ALL_MIN = 0.25;
const WIDTH_KEY = "m02.dfpanel.width";
const FOLD_KEY = "m02.dfpanel.folded";
/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */
export function dragWidth(start: number, startX: number, x: number, viewport: number): number {
const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85));
return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x))));
}
/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */
export function storedWidth(raw: string | null): number | null {
const n = Number(raw);
return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null;
}
const readStore = (key: string): string | null => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
};
const writeStore = (key: string, value: string): void => {
try {
localStorage.setItem(key, value);
} catch {
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */
}
};
/* ── 패널 ───────────────────────────────────────────────────────────── */
export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel {
/** 크게 보기 화면 — 열려 있을 때만 */
let full: HTMLElement | null = null;
const paper = createPaper({
logic: opts.logic,
readOnly: opts.readOnly,
onChange: opts.onChange,
logics: opts.logics,
// 패널 처음 배율 = 글이 읽히는 최소(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체 · 크게 보기는 90%
fitMin: () => (full ? 0.9 : PANEL_FIT_MIN),
fitAllMin: () => (full ? 0.9 : PANEL_FIT_ALL_MIN),
});
const { diagram, detail, summary, note, undo } = paper.parts;
let width = storedWidth(readStore(WIDTH_KEY));
let folded = opts.folded ?? readStore(FOLD_KEY) === "1";
const tell = (): void =>
opts.onResize?.(folded ? STRIP_WIDTH : (width ?? Math.max(PANEL_MIN, host.clientWidth)));
const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") =>
createButton({ label, size: "sm", variant, onClick });
/* 「흐름도」 단추 하나 — 누르면 접고 · 다시 누르면 펼침(펼치면 머리 · 접으면 얇은 띠에 놓임) */
const toggle = createButton({
label: tf("Toggle"),
size: "sm",
variant: "ghost",
onClick: () => (opts.onFold ? opts.onFold(!folded) : setFolded(!folded)),
});
toggle.classList.add("m02-dfp__toggle");
const head = el("header", {
className: "m02-dfp__head",
children: [
toggle,
el("strong", { className: "m02-dfp__title", text: opts.title }),
sm(tf("Big"), () => expand()),
sm(tf("Standards"), () => openStandards()),
],
});
/** 「표준 표」(단위 · 금액 표준) — 고치기는 시스템 관리자만(보기만이면 칸 · 단추를 막음) */
const openStandards = (): void => {
openModal({
title: tf("Standards"),
closeLabel: tf("Close"),
mount: (box: HTMLElement) => {
const lock = el("fieldset", { className: "m02-dfp__lock" });
lock.disabled = !!opts.readOnly;
box.append(lock);
renderStandards(lock, { change: () => paper.redraw() }, { title: false });
},
});
};
/* 펼친 패널 */
const costBox = el("details", {
className: "m02-dfp__cost",
attrs: { open: "" },
children: [el("summary", { text: tf("Cost") })],
});
const tools = el("div", { className: "ui-toolbar m02-dfp__tools", children: [undo] });
const open = el("div", {
className: "m02-dfp__open",
children: [head, tools, note, diagram, detail, costBox],
});
costBox.append(summary);
/* 접힌 띠 — 같은 「흐름도」 단추만 */
const strip = el("div", { className: "m02-dfp__strip" });
/* 왼쪽 가장자리 — 끌기 · 화살표 키 */
const grip = el("div", {
className: "m02-dfp__grip",
attrs: {
role: "separator",
"aria-orientation": "vertical",
"aria-label": tf("Grip"),
tabindex: "0",
},
});
const setWidth = (px: number, save: boolean): void => {
width = px;
if (save) writeStore(WIDTH_KEY, String(px));
tell();
};
grip.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
ev.preventDefault();
const start = root.getBoundingClientRect().width;
const startX = ev.clientX;
root.classList.add("is-resizing");
const move = (m: PointerEvent): void =>
setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false);
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
root.classList.remove("is-resizing");
setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true);
paper.refit();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
grip.addEventListener("keydown", (ev) => {
const step = ev.shiftKey ? 80 : 20;
const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0;
if (!d) return;
ev.preventDefault();
const now = root.getBoundingClientRect().width;
setWidth(dragWidth(now, 0, -d, window.innerWidth), true);
});
const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] });
root.addEventListener("keydown", (ev) => {
if (isCtrlKey(ev, "z") && !ev.shiftKey) {
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
ev.preventDefault();
paper.undo();
}
});
const setFolded = (on: boolean): void => {
// 접기 전 너비를 기억 — 끈 적이 없어도 펴면 그 너비로
if (on && !folded && width === null && root.isConnected)
width = Math.round(root.getBoundingClientRect().width);
folded = on;
if (!opts.onFold) writeStore(FOLD_KEY, on ? "1" : "0");
root.classList.toggle("is-folded", on);
open.hidden = on;
strip.hidden = !on;
grip.hidden = on;
(on ? strip : head).prepend(toggle);
toggle.setAttribute("aria-expanded", String(!on));
toggle.title = tf(on ? "ToggleOpen" : "ToggleClose");
tell();
if (!on) paper.refit();
};
/* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */
const expand = (): void => {
if (full) return;
const close = sm(tf("Close"), () => shrink());
full = el("div", {
className: "m02-dfp-full",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" },
children: [
el("header", {
className: "m02-dfp-full__head",
children: [el("strong", { text: opts.title }), undo, close],
}),
note,
el("div", { className: "m02-dfp-full__diagram", children: [diagram] }),
el("div", {
className: "m02-dfp-full__lower",
children: [
el("section", { children: [el("h3", { text: tf("Detail") }), detail] }),
el("section", { children: [el("h3", { text: tf("Cost") }), summary] }),
],
}),
],
});
full.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && !ev.defaultPrevented) shrink();
if (isCtrlKey(ev, "z") && !ev.shiftKey) {
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
ev.preventDefault();
paper.undo();
}
});
document.body.append(full);
full.focus();
paper.refit();
};
const shrink = (): void => {
if (!full) return;
full.remove();
full = null;
tools.append(undo);
costBox.append(summary);
// 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움
open.replaceChildren(
open.firstElementChild as HTMLElement,
tools,
note,
diagram,
detail,
costBox,
);
paper.refit();
};
host.replaceChildren(root);
setFolded(folded);
return {
set: (lg) => paper.set(lg),
destroy: () => {
shrink();
root.remove();
},
expand,
fold: (on) => on !== folded && setFolded(on),
};
}
@@ -1,113 +0,0 @@
/* 흐름도 고치는 핵심(M02_DF_Paper) · 상세 내용 틀(M02_DF_Entry_Detail) 꾸밈 — 옛 시험판 Entry.css 에서 옮김 */
.m02-df__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.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__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;
}
.m02-df__detail-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--spacing-12);
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m02-df__detail-head h3 {
margin: 0;
padding: 0;
border: 0;
}
.m02-df__detail-name {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: flex-end;
gap: var(--spacing-8);
}
.m02-df__detail-pill {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-df__detail-row {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8) 0;
border-bottom: 1px dashed var(--color-border);
}
.m02-df__detail-row:last-child {
border-bottom: 0;
}
.m02-df__detail-row[hidden] {
display: none;
}
.m02-df__detail-row h4 {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-df__detail-warn {
margin: 0;
padding-left: var(--spacing-16);
font-size: var(--text-body-sm);
}
-349
View File
@@ -1,349 +0,0 @@
/* =============================================================================
* M02_DF_Paper.ts
* 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소
* (옛 시험판 고치는 화면을 옮겨 적은) 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀.
* 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로).
* readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름).
* 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createInputField, el } from "@ui/ui_template_elements";
import * as api from "./M02_DF_Api";
import { renderDiagram, type DfPillSel } from "./M02_DF_Diagram";
import * as ev from "./M02_DF_Eval";
import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
import { renderFuncPanel } from "./M02_DF_FuncPanel";
import { renderInputPanel } from "./M02_DF_InputPanel";
import { renderLinkPanel } from "./M02_DF_LinkPanel";
import * as model from "./M02_DF_Model";
import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model";
import { renderOutputPanel } from "./M02_DF_OutputPanel";
import { renderPillPanel } from "./M02_DF_PillPanel";
import { renderSummary } from "./M02_DF_Summary";
import type { CfSel } from "./M02_DF_Verify";
import {
detailFrame,
drawHead,
drawWarnings,
fetchValues,
loadLogics,
queriesOf,
type DfPick,
} from "./M02_DF_Entry_Detail";
import "./M02_DF_Paper.css";
import "@ui/ui_template_text.css";
const TEXT = {
Undo: ["실행 취소", "Undo"],
Color: ["상자 색", "Box colour"],
BoxName: ["상자 이름", "Box name"],
NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"],
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"],
ReadOnly: ["보기만 — 고치기는 시스템 관리자", "View only — editing is for system admins"],
} as const;
const tp = (key: keyof typeof TEXT, v = ""): string =>
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]).replace("{v}", v);
export interface PaperOpts {
logic: CfLogic;
readOnly?: boolean;
/** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 — 없으면 못 부름(빈 값) */
logics?(key: string): Promise<CfLogic | null>;
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
fitMin?(): number;
/** 「한눈에」 단추의 가장 작은 확대 — 없으면 fitMin */
fitAllMin?(): number;
}
export interface Paper {
/** 다이어그램 자리 · 상세 내용 · 원가 · 검증 · 알림 줄 · 실행 취소 단추 */
parts: {
diagram: HTMLElement;
detail: HTMLElement;
summary: HTMLElement;
note: HTMLElement;
undo: HTMLButtonElement;
};
get(): CfLogic;
/** 밖에서 바꿈(고친 것 버리기 등) — 실행 취소 기록은 비움 */
set(lg: CfLogic): void;
undo(): void;
/** 다이어그램을 다시 맞춤(자리를 옮겨 끼운 뒤) */
refit(): void;
redraw(): void;
}
export function createPaper(opts: PaperOpts): Paper {
const readOnly = !!opts.readOnly;
let lg = opts.logic;
let stack: CfLogic[] = [];
let pick: DfPick = null;
let values: CfValues = {};
let valueSig = "";
let logics: NonNullable<CfEvalOptions["logics"]> = {};
let logicSig = "";
let logicNotes: string[] = [];
let notes: string[] = [];
let broken: CfBreak[] = [];
let result: CfEvalResult;
let fitKey = 0;
const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null);
const note = el("p", { className: "m02-df__note", attrs: { role: "status" } });
const diagramHost = el("div", { className: "m02-dfp__diagram" });
const summaryHost = el("div", { className: "m02-dfp__summary" });
const colorBox = el("input", {
className: "m02-df__color-input",
attrs: { type: "color", "aria-label": tp("Color") },
});
colorBox.addEventListener("change", () => {
const id = pickedBox();
if (id) edit(model.setColor(lg, id, colorBox.value));
});
const colorRow = el("label", {
className: "m02-df__color",
children: [el("span", { text: tp("Color") }), colorBox],
});
const boxName = createInputField({ label: tp("BoxName"), compact: true });
boxName.input.addEventListener("change", () => {
const box = lg.boxes.find((b) => b.id === pickedBox());
if (!box) return;
const next = boxName.input.value.trim();
const taken = lg.boxes.some((b) => b.id !== box.id && b.name === next);
if (taken) note.textContent = tp("NameTaken", next);
if (!next || taken || next === box.name) return void (boxName.input.value = box.name);
edit(model.updateBox(lg, box.id, { name: next }));
});
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만
const funcHost = el("div");
const outHost = el("div");
const inHost = el("div");
const linkHost = el("div");
const pillHost = el("div");
const detail = detailFrame();
detail.settings.append(boxName.root, colorRow);
// 보기만 — 판 안 칸 · 단추를 한 번에 막음(고르기 · 흐름 따라가기는 다이어그램에서 됨)
const lock = el("fieldset", {
className: "m02-dfp__lock",
children: [inHost, funcHost, outHost, linkHost, pillHost],
});
lock.disabled = readOnly;
detail.body.append(lock);
if (readOnly)
detail.root.prepend(el("p", { className: "ui-text ui-text--muted", text: tp("ReadOnly") }));
const evalNow = (): CfEvalResult => {
try {
return ev.evalLogic(lg, values, { logics });
} catch (e) {
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
return { ports: {}, outputs: {}, cost, errors: [api.cfPlain(e)] };
}
};
/** 고침 — readOnly 면 무시 · 기록에 쌓고 onChange */
const edit = (next: CfLogic): void => {
if (readOnly || next === lg) return;
stack = [...stack, lg];
lg = next;
opts.onChange(lg);
redraw();
};
/** 검증 칸 예시값 — readOnly 에서도 됨(보기용 · onChange 안 부름) */
const editExample = (next: CfLogic): void => {
if (!readOnly) return edit(next);
lg = next;
redraw();
};
const editFromDiagram = (next: CfLogic): void => {
edit(next);
setTimeout(redraw);
};
const refreshValues = (): void => {
const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null;
const sig = JSON.stringify(queriesOf(lg, pill, model.elementPort).map((a) => a.q));
if (sig === valueSig) return;
valueSig = sig;
void fetchValues(api.cfValues, lg, pill, model.elementPort).then(
(got) => {
if (sig !== valueSig) return;
values = got.values;
notes = got.errors.length ? [tp("Values", got.errors.join(" · "))] : [];
redraw();
},
(e: unknown) => {
notes = [tp("Values", api.cfPlain(e))];
redraw();
},
);
};
const refreshLogics = (): void => {
const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
if (sig === logicSig) return;
logicSig = sig;
const get = opts.logics ?? (async () => null);
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;
logicNotes = got.errors.length ? [tp("Logics", got.errors.join(" · "))] : [];
redraw();
});
};
const choose = (sel: CfSel): void => {
const box = lg.boxes.find((b) => b.id === sel.box);
pick = sel.link
? { kind: "link", id: sel.link }
: box && sel.port
? { kind: "pill", box: box.id, port: sel.port, dir: model.portDir(box, sel.port) }
: box
? { kind: "box", id: box.id }
: null;
redraw();
};
/** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */
const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => {
try {
if (pick?.kind === "pill") {
const p = { box: pick.box, name: pick.port };
return { back: model.traceBack(lg, p), forward: model.traceForward(lg, p) };
}
const link = lg.links.find((l) => pick?.kind === "link" && l.id === pick.id);
if (link)
return { back: model.traceBack(lg, link.from), forward: model.traceForward(lg, link.to) };
} catch {
/* 따라갈 수 없으면 칠하지 않음 */
}
return null;
};
const diagramOn = {
select: (id: string | null, pill?: DfPillSel): void => {
pick = pill
? { kind: "pill", ...pill }
: !id
? null
: lg.links.some((l) => l.id === id)
? { kind: "link", id }
: { kind: "box", id };
setTimeout(redraw);
},
move: (id: string, x: number, y: number): void => edit(model.moveBox(lg, id, x, y)),
link: (from: CfPort, to: CfPort): string | null => {
const got = model.link(lg, from, to);
if (got.why) return got.why;
editFromDiagram(got.lg);
return null;
},
unlink: (id: string): void => editFromDiagram(model.unlink(lg, id)),
add: (kind: CfKind, x: number, y: number): void => {
const got = model.addBox(lg, kind, x, y);
pick = { kind: "box", id: got.box.id };
editFromDiagram(got.lg);
},
remove: (id: string): void => {
if (pickedBox() === id) pick = null;
editFromDiagram(model.removeBox(lg, id));
},
};
const drawPanel = (): void => {
const boxId = pickedBox();
if (boxId && !lg.boxes.some((b) => b.id === boxId)) pick = null;
const box = lg.boxes.find((b) => b.id === pickedBox());
drawHead(detail, lg, pick);
(detail.settings.parentElement as HTMLElement).hidden = !box || readOnly;
colorRow.hidden = !box;
boxName.root.hidden = !box || box.kind === "output";
if (box && document.activeElement !== boxName.input) boxName.input.value = box.name;
linkHost.hidden = pick?.kind !== "link";
pillHost.hidden = pick?.kind !== "pill";
if (pick?.kind === "link")
renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics });
if (pick?.kind === "pill")
renderPillPanel(
pillHost,
lg,
pick.box,
pick.port,
{ pick: (id) => choose({ box: id }) },
{ values, logics },
);
drawWarnings(detail, pick, broken);
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") renderInputPanel(inHost, lg, box.id, { change: edit });
if (box?.kind === "function")
renderFuncPanel(funcHost, lg, box.id, { change: edit }, { values, logics });
if (box?.kind === "output")
renderOutputPanel(outHost, lg, box.id, { change: edit }, { result });
};
const redraw = (): void => {
result = evalNow();
refreshValues();
refreshLogics();
const linkId = pick?.kind === "link" ? pick.id : "";
if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null;
try {
broken = ev.breaks(lg, values, { logics });
} catch {
broken = result.breaks ?? [];
}
undo.disabled = readOnly || !stack.length;
renderDiagram(diagramHost, lg, diagramOn, {
values: result.ports,
selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null,
selectedPill:
pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null,
trace: traceNow(),
breaks: broken,
readOnly,
fitKey,
fitMin: opts.fitMin?.(),
fitAllMin: opts.fitAllMin?.(),
});
drawPanel();
renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values });
note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n");
};
const doUndo = (): void => {
const back = stack[stack.length - 1];
if (!back || readOnly) return;
stack = stack.slice(0, -1);
lg = back;
opts.onChange(lg);
redraw();
};
const undo = createButton({ label: tp("Undo"), variant: "ghost", size: "sm", onClick: doUndo });
redraw();
return {
parts: { diagram: diagramHost, detail: detail.root, summary: summaryHost, note, undo },
get: () => lg,
set: (next) => {
lg = next;
stack = [];
pick = null;
fitKey += 1;
redraw();
},
undo: doUndo,
refit: () => {
fitKey += 1;
redraw();
},
redraw,
};
}
@@ -1,169 +0,0 @@
/* =============================================================================
* M02_DF_PillPanel.ts
* 상세 내용 판 — 알약: 알약을 고르면 이름 · 값 · 단위 · 온 길(출처 사슬) · 가는 곳(이어진 선 · 받는 상자) · 끊김 까닭
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 8 · 9 · M02_DF_Model 의 CfStep 그대로 · 읽기만(고치기는 선 · 상자 판에서)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfLogic, CfStep } from "./M02_DF_Model";
import {
boxOf,
breakLines,
evalSafe,
isOut,
labelOf,
portText,
showValue,
trace,
unitOf,
valueOf,
type DetailOptions,
} from "./M02_DF_Detail";
import "./M02_DF_LinkPanel.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_banner.css";
import "@ui/ui_template_badge.css";
const TEXT = {
Name: ["이름", "Name"],
Value: ["값", "Value"],
Unit: ["단위", "Unit"],
Kind_out: ["내보내는 알약", "Sending pill"],
Kind_in: ["받는 알약", "Receiving pill"],
From: ["온 길", "Where it came from"],
To: ["가는 곳", "Where it goes"],
Start: ["여기서 시작 — 더 거슬러 갈 곳이 없음", "It starts here — nothing before it"],
End: ["여기가 끝 — 더 가는 곳이 없음", "It ends here — nothing after it"],
Breaks: ["끊긴 곳", "Breaks"],
NoBreak: ["끊긴 곳 없음", "No breaks"],
Open: ["이 상자 보기", "Open this box"],
Line: ["선", "Line"],
} as const;
const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement =>
el("div", {
className: "m02-dflink-row",
children: [el("span", { className: "m02-dflink-label", text: label }), ...value],
});
/** 한 줄 = 선 하나면 「보낸 알약 → (규칙 글) → 받는 알약」 · 함수 안 걸음이면 알약 하나 */
function steps(
lg: CfLogic,
list: CfStep[],
arrow: string,
empty: string,
onBox?: (id: string) => void,
): HTMLElement {
if (!list.length) return el("p", { className: "ui-text ui-text--muted", text: empty });
const pill = (box: string, name: string): HTMLElement | string =>
onBox
? createButton({
label: portText(lg, { box, name }),
variant: "ghost",
size: "sm",
onClick: () => onBox(box),
})
: portText(lg, { box, name });
return el("ol", {
className: "m02-dfpill-list",
children: list.map((s) => {
const link = s.link ? lg.links.find((l) => l.id === s.link) : undefined;
const parts: (HTMLElement | string)[] = link
? [
arrow,
pill(link.from.box, link.from.name),
"→",
...(s.rule
? [
el("span", {
className: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "20" },
text: `(${labelOf(s.rule)})`,
}),
"→",
]
: []),
pill(link.to.box, link.to.name),
]
: [arrow, pill(s.box, s.port)];
return el("li", { className: "m02-dfpill-step", children: parts });
}),
});
}
/** 알약 판 그리기 — 알약을 고르면 부름 · on.pick 이 있으면 줄의 상자 이름을 눌러 그 상자로 감 */
export function renderPillPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
port: string,
on: { pick?(boxId: string): void } = {},
opts: DetailOptions = {},
): void {
host.classList.add("m02-dfpill");
const box = boxOf(lg, boxId);
if (!box) return host.replaceChildren();
const ev = evalSafe(lg, opts);
const out = isOut(box, port);
// 받는 알약의 값 = 선 규칙을 지나 실제로 들어온 값(없으면 보낸 알약 값)
const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port);
const value = out
? valueOf(ev, { box: boxId, name: port })
: (ev?.arrived?.[boxId]?.[port] ?? (incoming ? valueOf(ev, incoming.from) : undefined));
const unit = out
? unitOf(lg, { box: boxId, name: port })
: incoming
? unitOf(lg, incoming.from)
: box.unit;
const back = trace(lg, { box: boxId, name: port }, "in");
const forward = trace(lg, { box: boxId, name: port }, "out");
const breaks = breakLines(lg, boxId, port, opts);
host.replaceChildren(
el("div", {
className: "m02-dflink-head",
children: [
el("strong", { className: "m02-dfpill-title", text: `${box.name} › ${port}` }),
el("span", { className: "m02-dflink-note", text: tp(out ? "Kind_out" : "Kind_in") }),
],
}),
line(tp("Name"), port),
line(
tp("Value"),
el("span", {
className: "ui-badge ui-badge--pill ui-badge--neutral",
attrs: { "data-size": "20" },
text: showValue(value),
}),
),
line(tp("Unit"), unit || "-"),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("From") }),
steps(lg, back, "◀", tp("Start"), on.pick),
],
}),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("To") }),
steps(lg, forward, "▶", tp("End"), on.pick),
],
}),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("Breaks") }),
...(breaks.length
? breaks.map((b) =>
el("p", {
className: "ui-banner ui-banner--warning m02-dflink-warn",
text: `⚠ ${b}`,
}),
)
: [el("p", { className: "ui-text ui-text--muted", text: tp("NoBreak") })]),
],
}),
);
}
+7 -2
View File
@@ -3,11 +3,16 @@
* 끝수 기본값 — 금액 단위 표준(산림품셈 1-2-2 「금액의 단위표준」)을 읽어
* 출력(비용) 기본 = 일위대가표 금액란 · 원가 합계 기본 = 일위대가표 계금 (모두 미만 버림)
* 표 읽기 = 서버 `GET /api/m02/df/standards`(Api `cfStandards`) → {unit, money, saved}
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. 이 폴더째 지워도 됨.
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12.
* ========================================================================== */
import { cfStandards } from "./M02_DF_Api";
import type { CfRound } from "./M02_DF_Model";
/** 끝수 — 자리 · 방법 */
export interface CfRound {
places: number;
method: "반올림" | "올림" | "버림";
}
/** 표 한 줄 — 원문 그대로(종목 · 단위 · 지위 · 비고) */
export interface CfMoneyRow {
@@ -1,79 +0,0 @@
/* =============================================================================
* M02_DF_RoundRow.ts
* 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추)
* 모양은 M02_DF_Model 의 CfRound · 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfRound } from "./M02_DF_Model";
import "@ui/ui_template_text.css";
const TEXT = {
Label: ["끝수", "Rounding"],
None: ["안 씀", "none"],
Places: ["소수 {n}자리", "{n} decimals"],
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
Apply: ["적용", "Apply"],
PlacesAria: ["소수자리", "Decimal places"],
ModeAria: ["방법", "Method"],
} as const;
const METHODS: CfRound["method"][] = ["반올림", "올림", "버림"];
const tr = (key: keyof typeof TEXT, n?: number): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
export interface RoundRowOptions {
/** 지금 끝수 — 없으면 undefined */
round: CfRound | undefined;
/** 단위 표준이 제안한 소수자리 */
suggest: number | null;
readOnly?: boolean;
/** null = 끝수 벗김 */
onApply(round: CfRound | null): void;
}
export function createRoundRow(opts: RoundRowOptions): HTMLElement {
const now = opts.round ?? null;
const places = el("select", {
className: "ui-select ui-select--sm",
attrs: { "aria-label": tr("PlacesAria") },
});
places.append(el("option", { attrs: { value: "" }, text: tr("None") }));
for (let n = 0; n <= 6; n++) {
places.append(el("option", { attrs: { value: String(n) }, text: tr("Places", n) }));
}
places.value = now ? String(now.places) : "";
const method = el("select", {
className: "ui-select ui-select--sm",
attrs: { "aria-label": tr("ModeAria") },
});
for (const m of METHODS) method.append(el("option", { attrs: { value: m }, text: m }));
method.value = now?.method ?? "반올림";
if (opts.readOnly) places.disabled = method.disabled = true;
const emit = (): void =>
opts.onApply(
places.value === ""
? null
: { places: Number(places.value), method: method.value as CfRound["method"] },
);
places.addEventListener("change", emit);
method.addEventListener("change", () => places.value !== "" && emit());
const row = el("div", {
className: "m02-dfpanel-row",
children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, method],
});
if (opts.suggest !== null && !now && !opts.readOnly) {
row.append(
el("span", { className: "ui-text ui-text--muted", text: tr("Suggest", opts.suggest) }),
createButton({
label: tr("Apply"),
variant: "ghost",
size: "sm",
onClick: () => opts.onApply({ places: opts.suggest!, method: "반올림" }),
}),
);
}
return row;
}
+8 -117
View File
@@ -1,136 +1,27 @@
"""단가산출 흐름도 서버 길 — 입력 값 · 다른 로직 값 · 설계 때 정할 것 · 자재 찾기 · 표준 표.
"""단위 · 금액 표준 표 읽기 — `GET /api/m02/df/standards` → {unit, money, saved}.
POST /api/m02/df/values {items, userElements?} → {values: CfAnswer[]}
POST /api/m02/df/logic-value {key, designs} → {key, outputs, cost, errors}
GET /api/m02/df/design-inputs?key= → {key, designs: CfDesign[]}
GET /api/m02/df/materials?q=&sub=&detail=&page=&size= → {total, page, size, items}
GET · PUT · DELETE /api/m02/df/standards → {unit, money, saved}
모양 = sub7 Model `M02_DF_Model.ts` CfLogic.
흐름 저장 = 로직 줄 「흐름」 칸(M01 `/save` · `M01_MasterData_Store_Flow`) — 이 길은 쓰지 않음.
다른 로직 = 마스터 로직 키(흐름이면 흐름 셈 · 아니면 지금 계산 · `M01_MasterData_Store_Flow.load`).
사용자 요소 = 흐름 안 `userElements` 만. 권한 = 시스템 관리자(`main.py` 한 줄).
부르는 곳 = 단위 고르기(`M02_DF_UnitPick.ts`) · 끝수 표준(`M02_DF_Round.ts`).
표 = `_standard/` 파일(git 이 따라감) · 권한 = 시스템 관리자(`main.py` 한 줄).
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData import M01_MasterData_Store_Flow as sflow
from M01_MasterData.M01_MasterData_Router import _num
from M02_MasterTemplete.DataFlow import M02_DF_Compute as dc
from M02_MasterTemplete.DataFlow import M02_DF_Values as dv
router = APIRouter(prefix="/api/m02/df", tags=["M02 DataFlow"])
HERE = Path(__file__).resolve().parent
STANDARD = HERE / "_standard" # 단위 · 금액 표준 표 — 고치면 이 파일에 바로(git 이 따라감)
class ValuesBody(BaseModel):
items: list[dict[str, Any]]
userElements: list[dict[str, Any]] = []
class LogicValueBody(BaseModel):
key: str
designs: dict[str, Any] = {}
class StandardsBody(BaseModel):
unit: dict[str, Any]
money: dict[str, Any]
# ── 다른 로직(`M02_DF_Compute`) ────────────────────────────────────────
def _load(key: str) -> dict:
"""다른 로직 입력이 부르는 마스터 로직 — 흐름이면 흐름 · 아니면 지금 계산(정함 11)."""
return sflow.load(key)
def logic_value(key: str, designs: dict) -> dict:
"""다른 로직 값 — 받은 설계 값(없으면 예시값)으로 셈 · 출력 이름 → 값."""
other = _load(key)
if callable(other.get("calc")): # 흐름 없는 로직 — 노무비 · 재료비 · 경비 · 계
got = other["calc"](designs)
return {"key": key, "outputs": got["outputs"], "cost": {}, "errors": got["errors"]}
local = other.get("userElements") or []
got = dc.run(other, dc._design_values(other, designs), _load, local, [key])
names = {b["id"]: b.get("name") for b in other.get("boxes") or []}
outputs = {names[i]: v for i, v in got["outputs"].items() if i in names}
return {"key": key, "outputs": outputs, "cost": got["cost"], "errors": got["errors"]}
def design_inputs(key: str) -> dict:
"""설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(선이 없을 때) · CfDesign 꼴."""
other = _load(key)
if callable(other.get("calc")):
return {"key": key, "designs": sflow.old_designs(key)}
return {"key": key, "designs": dc.design_inputs(other)}
# ── 표준 표 ────────────────────────────────────────────────────────────
def _standards_put(body: dict) -> dict:
"""고친 표준 표 = 본 프로그램 값 — git 이 따라가는 `_standard/` 파일에 바로(시스템 관리자)."""
dv.standards_check(body)
for part, name in dv.STANDARDS.items():
tmp = STANDARD / f".{name}.tmp"
text = json.dumps(body[part], ensure_ascii=False, indent=2) + "\n"
tmp.write_text(text, encoding="utf-8", newline="\n")
os.replace(tmp, STANDARD / name)
return dv.standards(STANDARD)
def _standards_reset() -> dict:
"""고친 값을 따로 두지 않음(바로 저장) — 되돌릴 처음 값 없음 · 지금 값 그대로."""
return dv.standards(STANDARD)
# ── 길 ────────────────────────────────────────────────────────────────
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.post("/values")
def post_values(body: ValuesBody) -> dict:
return _call(dv.values, body.items, body.userElements)
@router.post("/logic-value")
def post_logic_value(body: LogicValueBody) -> dict:
return _call(logic_value, body.key, body.designs)
@router.get("/design-inputs")
def get_design_inputs(key: str) -> dict:
return _call(design_inputs, key)
@router.get("/materials")
def get_materials(q: str = "", sub: str = "", detail: str = "", page: int = 1, size: int = 30):
return _call(dv.materials, q, sub, detail, page, size)
STANDARD = Path(__file__).resolve().parent / "_standard"
@router.get("/standards")
def get_standards() -> dict:
return _call(dv.standards, STANDARD)
@router.put("/standards")
def put_standards(body: StandardsBody) -> dict:
return _call(_standards_put, body.model_dump())
@router.delete("/standards")
def delete_standards() -> dict:
return _call(_standards_reset)
try:
return _num(dv.standards(STANDARD))
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
-261
View File
@@ -1,261 +0,0 @@
/* =============================================================================
* M02_DF_Rule.ts
* 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음
*
* 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식
* lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림 · 아래 = 위면 값 == 그 수)
* → 맞은 줄 수 · INDEX/MATCH
* gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { gateConds, type CfGateOp, type CfRound, type CfRule } from "./M02_DF_Model";
type Scalar = number | string | boolean | null;
/** 규칙을 지난 값 — 통과 안 됨 · 표에 없는 조건 · 둘 이상 맞음은 value null + why */
export interface CfRuleResult {
value: Scalar;
why: "통과 안 됨" | "표에 없는 조건" | "표에 여럿 맞음" | "오류" | null;
/** 받는 칸에 들어갈 엑셀 수식(보이기 · 시험) */
formula: string;
}
const TEXT = {
Lookup: ["표에서 찾기", "Look up in table"],
Gate: ["일 때만", "only when"],
And: ["그리고", "and"],
Or: ["또는", "or"],
Places: ["소수 {n}자리", "{n} decimals"],
Round: ["반올림", "round"],
Up: ["올림", "round up"],
Down: ["버림", "round down"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 엑셀 비교 기호 */
const OP: Record<CfGateOp, string> = {
"=": "=",
"≠": "<>",
"≥": ">=",
"≤": "<=",
">": ">",
"<": "<",
};
const ROUND_FN: Record<CfRound["method"], string> = {
반올림: "ROUND",
올림: "ROUNDUP",
버림: "ROUNDDOWN",
};
/** 선 위에 늘 보일 규칙 글 */
export function ruleLabel(rule: CfRule): string {
switch (rule.kind) {
case "lookup": {
const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · ");
return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`;
}
case "gate": {
const g = gateConds(rule);
const join = g.join === "또는" ? tl("Or") : tl("And");
return `${g.conds.map((c) => `${c.from.name} ${c.op} ${c.value}`).join(` ${join} `)} ${tl("Gate")}`;
}
case "scale":
return rule.op === "+%" || rule.op === "-%"
? `${rule.op[0]}${rule.value}%`
: `${rule.op} ${rule.value}`;
case "round":
return `${tl("Places").replace("{n}", String(rule.places))} ${roundWord(rule.method)}`;
}
}
const roundWord = (m: CfRound["method"]): string =>
m === "반올림" ? tl("Round") : m === "올림" ? tl("Up") : tl("Down");
/* ── 엔진 한 번 ─────────────────────────────────────────────────────────── */
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.오류;
};
const cellOf = (v: unknown): Cell | null =>
v === null || v === undefined || v === ""
? null
: typeof v === "number" || typeof v === "string" || typeof v === "boolean"
? { 값: v }
: { 값: String(v) };
/** 칸들을 시트 하나로 셈 → 읽을 칸 값 */
function run(칸: Record<string, Cell>, read: string[]): Scalar[] {
const id = "rule";
const values = recalcWorkbook({
종류: "통합문서",
판: 1,
열: "DF",
서식: [{}],
시트: [{ id, 이름: "규칙", 칸 }],
});
return read.map((a) =>
칸[a]?.식 !== undefined ? fromCalc(values[id]?.[a]) : (칸[a]?.값 ?? null),
);
}
const isError = (v: Scalar): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v);
const lit = (v: number | string): string =>
typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`;
/* ── 규칙 ───────────────────────────────────────────────────────────────── */
/** 조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름 */
const condOf = (conds: Record<string, unknown>, p: { box: string; name: string }): unknown =>
`${p.box}|${p.name}` in conds ? conds[`${p.box}|${p.name}`] : conds[p.name];
/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 이름 → 값(lookup · gate) */
export function applyRule(
rule: CfRule,
value: unknown,
conds: Record<string, unknown> = {},
): CfRuleResult {
if (rule.kind === "lookup") return lookup(rule, value, conds);
const 칸: Record<string, Cell> = {};
const a1 = cellOf(Array.isArray(value) ? null : value);
if (a1) 칸.A1 = a1;
let formula: string;
if (rule.kind === "gate") {
// 조건 값 = C1 · D1 · E1 … · 여러 줄은 AND / OR
const g = gateConds(rule);
const tests = g.conds.map((c, i) => {
const at = `${colName(2 + i)}1`;
const cv = cellOf(condOf(conds, c.from));
if (cv) 칸[at] = cv;
return `${at}${OP[c.op]}${lit(c.value)}`;
});
const test =
tests.length > 1 ? `${g.join === "또는" ? "OR" : "AND"}(${tests.join(",")})` : tests[0];
formula = `IF(${test},A1,NA())`;
} else if (rule.kind === "scale") {
const v = rule.value;
formula =
rule.op === "×"
? `A1*${v}`
: rule.op === "÷"
? `A1/${v}`
: rule.op === "+%"
? `A1*(1+${v}/100)`
: `A1*(1-${v}/100)`;
} else formula = `${ROUND_FN[rule.method]}(A1,${rule.places})`;
칸.B1 = { 식: formula };
const [got] = run(칸, ["B1"]);
if (rule.kind === "gate" && got === "#N/A") return { value: null, why: "통과 안 됨", formula };
if (isError(got)) return { value: null, why: "오류", formula };
return { value: got, why: null, formula };
}
/** 표 블록 범위 경계 규칙 칸(줄마다 「초과·이하」 「이상·이하」 「이상·미만」 「이하」 · Values._table) */
export const RANGE_RULE = "·범위규칙";
/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하)
* 빈 조건 칸 = 무엇이든 · 꺼낼 칸이 빈 줄은 뺌(옛 엔진 find · 서버 Compute `_lookup` 과 같음) */
function lookup(
rule: Extract<CfRule, { kind: "lookup" }>,
value: unknown,
conds: Record<string, unknown>,
): CfRuleResult {
const block = Array.isArray(value) ? (value as unknown[][]) : [];
const head = (block[0] ?? []).map((h) => String(h ?? ""));
const rows = block.length - 1;
const W = head.length;
const 칸: Record<string, Cell> = {};
block.forEach((line, r) =>
line.forEach((x, c) => {
const cell = cellOf(x);
if (cell) 칸[`${colName(c)}${r + 1}`] = cell;
}),
);
const at = (name: string): string | null => {
const i = head.indexOf(name);
return i < 0 ? null : colName(i);
};
// 값 칸이 범위로 갈림(「이름·아래」 「이름·위」) — 아래 = 위면 그 값
const two = !at(rule.col) && !!at(`${rule.col}·아래`) && !!at(`${rule.col}·위`);
const out = two ? at(`${rule.col}·아래`) : at(rule.col);
// 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고
const tests: ((r: number) => string)[] = out ? [(r) => `NOT(ISBLANK(${out}${r}))`] : [];
const ups: string[] = [];
// 범위 경계 — 줄마다 「·범위규칙」 칸(이상 … = 아래 끝 포함 · … 미만 = 위 끝 뺌) · 없으면 초과 · 이하
const rc = at(RANGE_RULE);
const lowCmp = (c: string, r: number, x: string): string =>
rc ? `IF(LEFT(${rc}${r},2)="이상",${c}${r}<=${x},${c}${r}<${x})` : `${c}${r}<${x}`;
const highCmp = (c: string, r: number, x: string): string =>
rc ? `IF(RIGHT(${rc}${r},2)="미만",${x}<${c}${r},${x}<=${c}${r})` : `${x}<=${c}${r}`;
let bad = !out || rows < 1;
rule.conds.forEach((c, i) => {
const ref = `$${colName(W + 1 + i)}$1`;
const cv = cellOf(condOf(conds, c.from));
if (cv) 칸[`${colName(W + 1 + i)}1`] = cv;
const same = at(c.col);
const low = at(`${c.col}·아래`);
const high = at(`${c.col}·위`);
// 글끼리는 대소문자까지 같아야 맞음(옛 find · 「t_2」 ≠ 「T_2」) — FIND 는 대소문자를 가림
if (same)
tests.push(
(r) =>
`OR(ISBLANK(${same}${r}),IF(AND(ISTEXT(${same}${r}),ISTEXT(${ref})),` +
`AND(LEN(${same}${r})=LEN(${ref}),ISNUMBER(FIND(${ref},${same}${r}))),${same}${r}=${ref}))`,
);
else if (low && high) {
ups.push(high);
tests.push(
(r) =>
`IF(AND(NOT(ISBLANK(${low}${r})),${low}${r}=${high}${r}),${ref}=${low}${r},` +
`AND(OR(ISBLANK(${low}${r}),${lowCmp(low, r, ref)}),OR(ISBLANK(${high}${r}),${highCmp(high, r, ref)})))`,
);
} else bad = true;
});
const hc = colName(W + 1 + rule.conds.length);
const last = rows + 1;
for (let r = 2; r <= last; r++)
칸[`${hc}${r}`] = {
식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1",
};
// 여럿이면 범위 위 끝이 가장 작은 줄(옛 find · 「20 이하」 「40 이하」 …) — 둘째 맞음 칸
const hc2 = colName(W + 3 + rule.conds.length);
const span = (c: string): string => `${c}2:${c}${Math.max(2, last)}`;
for (let r = 2; r <= last; r++) {
const top = ups.map((h) => `OR(ISBLANK(${h}${r}),${h}${r}=MINIFS(${span(h)},${span(hc)},1))`);
칸[`${hc2}${r}`] = {
식: top.length ? `IF(AND(${hc}${r}=1,${top.join(",")}),1,0)` : `${hc}${r}`,
};
}
const range = span(hc2);
const count = `${colName(W + 2 + rule.conds.length)}1`;
const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()";
칸[count] = { 식: `SUM(${range})` };
칸.ZZ1 = { 식: formula };
const up = at(`${rule.col}·위`);
if (two && up) 칸.ZZ2 = { 식: formula.replace(`INDEX(${out}2:${out}`, `INDEX(${up}2:${up}`) };
if (bad) return { value: null, why: "오류", formula };
const [n, got, top] = run(칸, [count, "ZZ1", "ZZ2"]);
if (two && n === 1 && got !== top) return { value: null, why: "오류", formula };
if (n === 0) return { value: null, why: "표에 없는 조건", formula };
if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula };
if (isError(got)) return { value: null, why: "오류", formula };
return { value: got, why: null, formula };
}
/** 끝수 적용 — 엔진 ROUND · ROUNDUP · ROUNDDOWN */
export function applyRound(round: CfRound, value: number): number {
const [got] = run(
{ A1: { 값: value }, B1: { 식: `${ROUND_FN[round.method]}(A1,${round.places})` } },
["B1"],
);
return typeof got === "number" ? got : value;
}
@@ -1,37 +0,0 @@
/* 단가산출 흐름도 — 표준 표 판(M02_DF_Standards.ts) · 단추 · 칸 크기는 공용 그대로(덮지 않음) */
.m02-df-std {
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
}
.m02-df-std__head {
display: flex;
align-items: center;
gap: var(--spacing-8, 8px);
}
/* 첫 칸(제목 · 제목이 없으면 상태 글)이 단추를 오른쪽으로 밂 */
.m02-df-std__head > :first-child {
margin: 0 auto 0 0;
}
.m02-df-std__line {
color: var(--color-text-muted);
}
/* 겉 = 표 부품 데이터표(ui-table-wrap) · 여기는 줄바꿈 막기 · 누르는 칸만 */
.m02-df-std__table th,
.m02-df-std__table td {
white-space: nowrap;
}
.m02-df-std__table td[tabindex] {
cursor: pointer;
}
.m02-df-std__notes {
margin: 0;
padding-left: 0;
list-style: none;
}
@@ -1,365 +0,0 @@
/* =============================================================================
* M02_DF_Standards.ts
* 단가산출 흐름도 — 단위 · 금액 표준 표(정함 11 · 12)
* 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침)
* 서버 = `M02_DF_Api` cfStandards · cfStandardsSave · cfStandardsReset(`/api/m02/df/standards`) → {unit, money, saved} ·
* 고친 것은 `_data`(마스터에 안 씀)
* `renderStandards(host, on, { title })` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로)
* `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것)
* 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { cfStandards, cfStandardsReset, cfStandardsSave, type CfStandards } from "./M02_DF_Api";
import unitDefault from "./_standard/단위표준.json";
import moneyDefault from "./_standard/금액단위표준.json";
import "./M02_DF_Standards.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_table.css";
const TEXT = {
Title: ["표준 표", "Standards"],
UnitTitle: ["설계서의 단위 및 소수의 표준", "Units and decimals in design documents"],
MoneyTitle: ["금액의 단위표준", "Money rounding units"],
Source: ["원문 · 산림품셈 1-2-2", "Source · Forest standard 1-2-2"],
Hint: [
"칸을 눌러 고침 · 소수자리 「-」 = 0 · 단위 「-」 = 없음 · 고친 것은 이 시험판에만 저장",
"Click a cell to edit · decimals “-” = 0 · unit “-” = none · edits are saved in this trial only",
],
Item: ["종목", "Item"],
SpecUnit: ["규격 단위", "Size unit"],
SpecPlaces: ["규격 소수자리", "Size decimals"],
QtyUnit: ["단위수량 단위", "Quantity unit"],
QtyPlaces: ["단위수량 소수자리", "Quantity decimals"],
Note: ["비고", "Note"],
Line: ["원문 줄", "Source line"],
Unit: ["단위", "Unit"],
Step: ["지위", "Step"],
Save: ["저장", "Save"],
Reset: ["처음 값으로", "Back to original"],
ResetAsk: [
"고친 표준 표를 지우고 원문 처음 값으로 되돌릴까요?",
"Discard edits and go back to the original values?",
],
Saved: ["표준 표를 저장했어요", "Standards saved"],
Restored: ["처음 값으로 되돌렸어요", "Back to the original values"],
Unsaved: ["저장 안 함", "Not saved"],
Edited: ["고친 표", "Edited"],
Original: ["원문 처음 값", "Original"],
Failed: [
"표준 표를 못 가져왔어요 — 처음 값을 보여 줘요",
"Could not load — showing the original",
],
SaveFailed: ["저장하지 못했어요", "Could not save"],
Bad: ["숫자를 넣어 주세요", "Enter a number"],
Merged: ["위 종목과 한 칸(원문 병합)", "Same cell as the item above (merged in source)"],
} as const;
const tx = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex === 1 ? 1 : 0];
/* ── 모양 ─────────────────────────────────────────────────────────────── */
export interface UnitStdRow {
종목: string;
"규격 단위": string | null;
"규격 소수자리": number | null;
"단위수량 단위": string | null;
"단위수량 소수자리": number | null;
비고: string;
"원문 줄": number;
병합?: boolean;
}
export interface UnitStd {
이름: string;
출처: string;
원문: string;
바꿈?: string;
줄: UnitStdRow[];
주: string[];
}
export interface MoneyStdRow {
종목: string;
단위: string;
지위: number;
비고: string;
"원문 줄": number;
}
export interface MoneyStd {
이름: string;
출처: string;
원문: string;
줄: MoneyStdRow[];
}
export interface Standards {
unit: UnitStd;
money: MoneyStd;
/** `_data` 에 고친 것이 있음 */
saved: boolean;
}
/** 단위 고르기 · 끝수 제안이 쓰는 한 줄 */
export interface StandardItem {
종목: string;
갈래: "규격" | "단위수량" | "금액";
단위: string;
/** 규격 · 단위수량 = 소수자리 · 금액 = 지위에서 나온 자리(1,000 원 → -3 · 0.1 원 → 1) */
소수자리: number;
/** 금액만 — 지위(원) */
지위?: number;
방법: "반올림" | "버림";
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
const ORIGINAL: Standards = {
unit: unitDefault as UnitStd,
money: moneyDefault as MoneyStd,
saved: false,
};
let current: Standards = clone(ORIGINAL);
/* ── 목록(동기) ───────────────────────────────────────────────────────── */
/** 지위 → 소수자리 — 1,000 원 → -3 · 1 원 → 0 · 0.1 원 → 1 */
const placesOf = (step: number): number => -Math.round(Math.log10(step));
/** 단위 표준(주 ① 반올림) 줄마다 규격 · 단위수량 둘 + 금액(미만 버림) — 단위가 비었거나 「없음」 이면 뺌 */
export function standardsList(std: Standards = current): StandardItem[] {
const out: StandardItem[] = [];
for (const r of std.unit.줄) {
for (const [갈래, unit, places] of [
["규격", r["규격 단위"], r["규격 소수자리"]],
["단위수량", r["단위수량 단위"], r["단위수량 소수자리"]],
] as const) {
if (!unit || unit === "없음") continue;
out.push({ 종목: r.종목, 갈래, 단위: unit, 소수자리: places ?? 0, 방법: "반올림" });
}
}
for (const r of std.money.줄)
out.push({
종목: r.종목,
갈래: "금액",
단위: r.단위,
소수자리: placesOf(r.지위),
지위: r.지위,
방법: "버림",
});
return out;
}
/** 지금 표(판이 서버 값을 받았으면 그것) — 사본 */
export const standardsNow = (): Standards => clone(current);
/* ── 서버 ─────────────────────────────────────────────────────────────── */
/** 서버 답(`M02_DF_Api` CfStandards) → 이 판의 모양 */
const asStd = (v: CfStandards): Standards => v as unknown as Standards;
/** 서버 표 받기 — 못 받으면 처음 값 */
export async function loadStandards(): Promise<Standards> {
current = asStd(await cfStandards());
return clone(current);
}
/* ── 판 ───────────────────────────────────────────────────────────────── */
export interface StandardsOn {
/** 저장 · 되돌리기 뒤 — 단위 고르기 등이 새 목록을 다시 읽게 */
change?(std: Standards): void;
}
type UnitKey = "규격 단위" | "규격 소수자리" | "단위수량 단위" | "단위수량 소수자리" | "비고";
type MoneyKey = "단위" | "지위" | "비고";
/** 칸 글 → 값 — 소수자리 · 지위는 수(「-」 = 0 · 빈 칸 = null) · 단위는 글(「-」 = 없음 · 빈 칸 = null) */
function parseCell(key: string, text: string): string | number | null | undefined {
const t = text.trim();
if (key === "비고") return t;
if (key.includes("소수자리") || key === "지위") {
if (t === "") return key === "지위" ? undefined : null;
if (t === "-") return 0;
const n = Number(t.replace(/,/g, ""));
return Number.isFinite(n) ? n : undefined;
}
if (t === "-") return "없음";
return t || null;
}
const show = (v: unknown): string =>
v === null || v === undefined ? "" : typeof v === "number" ? v.toLocaleString() : String(v);
export interface StandardsOpts {
/** 판 제목 「표준 표」 — 모달처럼 겉에 제목이 있으면 false(기본 true) */
title?: boolean;
}
/** 시험판 안 「표준 표」 판 */
export function renderStandards(
host: HTMLElement,
on: StandardsOn = {},
opts: StandardsOpts = {},
): void {
let work = clone(current);
let dirty = false;
const state = el("span", { className: "ui-text ui-text--muted m02-df-std__state" });
const save = createButton({ label: tx("Save"), size: "sm", onClick: () => void doSave() });
const reset = createButton({
label: tx("Reset"),
size: "sm",
variant: "ghost",
onClick: () => void doReset(),
});
const body = el("div", { className: "m02-df-std__body" });
const mark = (): void => {
state.textContent = dirty ? tx("Unsaved") : work.saved ? tx("Edited") : tx("Original");
save.disabled = !dirty;
};
/** 칸 하나 — 누르면 입력 칸 · Enter / 칸 밖 = 끝 · Esc = 취소 */
const cell = (value: unknown, commit: (text: string) => boolean, title = ""): HTMLElement => {
const td = el("td", {
text: show(value),
attrs: { tabindex: "0", ...(title ? { title } : {}) },
});
const edit = (): void => {
if (td.querySelector("input")) return;
const input = el("input", { className: "ui-input ui-input--sm" });
input.value = show(value).replace(/,/g, "");
td.replaceChildren(input);
input.focus();
let done = false;
const finish = (keep: boolean): void => {
if (done) return;
done = true;
if (keep && !commit(input.value)) {
showToast(tx("Bad"), "error");
done = false;
input.focus();
return;
}
draw();
};
input.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") finish(true);
if (ev.key === "Escape") finish(false);
});
input.addEventListener("blur", () => finish(true));
};
td.addEventListener("click", edit);
td.addEventListener("keydown", (ev) => ev.key === "Enter" && edit());
return td;
};
const table = (heads: string[], rows: HTMLElement[][]): HTMLElement =>
el("div", {
className: "ui-table-wrap m02-df-std__scroll",
children: [
el("table", {
className: "ui-table ui-table--data m02-df-std__table",
children: [
el("thead", {
children: [el("tr", { children: heads.map((h) => el("th", { text: h })) })],
}),
el("tbody", { children: rows.map((cells) => el("tr", { children: cells })) }),
],
}),
],
});
const put = <T extends object>(row: T, key: string, text: string): boolean => {
const v = parseCell(key, text);
if (v === undefined) return false;
(row as Record<string, unknown>)[key] = v;
dirty = true;
return true;
};
const draw = (): void => {
const unitKeys: UnitKey[] = ["규격 단위", "규격 소수자리", "단위수량 단위", "단위수량 소수자리", "비고"]; // prettier-ignore
const unitRows = work.unit.줄.map((r) => [
el("td", { text: r.종목, attrs: r.병합 ? { title: tx("Merged") } : {} }),
...unitKeys.map((k) => cell(r[k], (t) => put(r, k, t))),
el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }),
]);
const moneyKeys: MoneyKey[] = ["단위", "지위", "비고"];
const moneyRows = work.money.줄.map((r) => [
el("td", { text: r.종목 }),
...moneyKeys.map((k) => cell(r[k], (t) => put(r, k, t))),
el("td", { className: "m02-df-std__line", text: String(r["원문 줄"]) }),
]);
body.replaceChildren(
el("h4", { text: tx("UnitTitle") }),
table(
[tx("Item"), tx("SpecUnit"), tx("SpecPlaces"), tx("QtyUnit"), tx("QtyPlaces"), tx("Note"), tx("Line")], // prettier-ignore
unitRows,
),
el("ol", {
className: "m02-df-std__notes",
children: work.unit.주.map((n) => el("li", { text: n })),
}),
el("h4", { text: tx("MoneyTitle") }),
table([tx("Item"), tx("Unit"), tx("Step"), tx("Note"), tx("Line")], moneyRows),
);
mark();
};
const take = (std: Standards): void => {
current = std;
work = clone(std);
dirty = false;
draw();
on.change?.(clone(std));
};
async function doSave(): Promise<void> {
save.disabled = true;
try {
take(
asStd(
await cfStandardsSave({ unit: work.unit, money: work.money } as unknown as Omit<
CfStandards,
"saved"
>),
),
);
showToast(tx("Saved"), "success");
} catch {
showToast(tx("SaveFailed"), "error");
mark();
}
}
async function doReset(): Promise<void> {
if (!(await showConfirmDialog(tx("ResetAsk"), tx("Reset")))) return;
try {
take(asStd(await cfStandardsReset()));
} catch {
take(clone(ORIGINAL)); // 서버가 없으면 화면만 처음 값
}
showToast(tx("Restored"), "success");
}
host.replaceChildren(
el("div", {
className: "m02-df-std",
children: [
el("div", {
className: "m02-df-std__head",
children: [
...(opts.title === false ? [] : [el("h3", { text: tx("Title") })]),
state,
reset,
save,
],
}),
el("p", { className: "ui-text ui-text--muted", text: `${tx("Source")} · ${tx("Hint")}` }),
body,
],
}),
);
draw();
void loadStandards().then(
(std) => take(std),
() => showToast(tx("Failed"), "info"),
);
}
@@ -1,75 +0,0 @@
.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%;
margin-top: var(--spacing-4);
}
/* 겉 = 표 부품 데이터표(ui-table-wrap) · 여기는 최소 폭 · 수 칸 오른쪽만 */
.m02-dfsum__table {
min-width: 360px;
}
.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);
}
@@ -1,179 +0,0 @@
/* =============================================================================
* M02_DF_Summary.ts
* 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄
* · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈)
* 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌.
* 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { el } from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model";
import { showValue } from "./M02_DF_OutputPanel";
import {
defaultTotalRound,
itemRoundNow,
roundBy,
roundText,
showRounded,
totalRoundOf,
} from "./M02_DF_Round";
import { renderVerify, type CfSel } from "./M02_DF_Verify";
import "./M02_DF_Summary.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_table.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)"],
RoundTotal: ["합계 끝수", "Total rounding"],
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);
/** 원가 합계 끝수 — 기본 = 일위대가표 계금 1원 미만 버림(표를 읽어 오면 그 값) */
let totalRound: CfRound = { places: 0, method: "버림" };
void defaultTotalRound().then((r) => (totalRound = r));
/** 출력 상자 값 — 그 출력의 끝수 자리대로(끝수를 안 정했으면 비용은 표준 기본 · 값은 그대로) */
const shown = (b: CfBox, v: unknown, isCost: boolean): string =>
typeof v === "number"
? showRounded(v, b.output?.round ?? (isCost ? itemRoundNow() : undefined))
: showValue(v as never);
/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 · opts.values = 받은 입력 값(검사가 씀) */
export function renderSummary(
host: HTMLElement,
lg: CfLogic,
result: CfEvalResult,
on?: { change(lg: CfLogic): void; select?(sel: CfSel): void },
opts?: { values?: CfValues; logics?: CfEvalOptions["logics"] },
): 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;
/** 합계 끝수 — 비용 출력 상자에 실린 끝수(옮긴 로직의 자신 끝수) · 없으면 기본 1원 미만 버림 */
const sumRound = totalRoundOf(lg, totalRound);
const big = el("p", {
className: "m02-dfsum__big",
children: [
el("span", { text: money ? perText : ts("Total") }),
el("strong", { text: `${formatMoney(roundBy(sumRound, 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(roundBy(sumRound, 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: shown(b, 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: "ui-table-wrap m02-dfsum__scroll",
children: [
el("table", {
className: "ui-table ui-table--data 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)) }),
],
}),
],
}),
],
});
const verifyHost = el("div", { className: "m02-dfsum__verify" });
renderVerify(
verifyHost,
lg,
result,
{ change: (next) => on?.change(next), select: on?.select },
{ values: opts?.values, logics: opts?.logics },
);
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: "ui-text ui-text--muted", text: `${k} · ${showValue(v)}` }),
),
],
}),
]
: []),
el("p", {
className: "ui-text ui-text--muted",
text: `${ts("RoundTotal")}: ${roundText(sumRound)}`,
}),
verifyHost,
);
}
@@ -1,108 +0,0 @@
.m02-dfver__block {
margin: 0 0 var(--spacing-12);
color: var(--color-text-body);
}
/* 끊긴 곳 = 띠 알림 부품(위험) · 여기는 제목 · 목록 쌓기만 */
.m02-dfver__breaks {
flex-direction: column;
}
.m02-dfver__title {
font-size: var(--text-body);
}
.m02-dfver__group {
margin: var(--spacing-8) 0;
}
.m02-dfver__fields {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-16);
margin: var(--spacing-4) 0;
}
.m02-dfver__field {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-dfver__input {
width: 160px;
max-width: 100%;
}
.m02-dfver__item {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-4) 0;
}
.m02-dfver__line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m02-dfver__now {
color: var(--color-text-body);
}
.m02-dfver__price {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.m02-dfver__warn {
color: var(--color-danger);
}
.m02-dfver__panel {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.m02-dfver__search,
.m02-dfver__pager {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
margin: var(--spacing-4) 0;
}
.m02-dfver__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: var(--spacing-4) 0;
padding: 0;
list-style: none;
}
.m02-dfver__list .ui-btn {
max-width: 100%;
text-align: left;
white-space: normal;
}
/* 쉬는 갈래 — 문제가 아니라 회색으로 */
.m02-dfver__rest {
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
}
.m02-dfver__rest .ui-btn {
color: var(--color-text-muted);
}
@@ -1,503 +0,0 @@
/* =============================================================================
* M02_DF_Verify.ts
* 흐름도 검증 칸(오른쪽 아래) — 끊긴 곳 목록(누르면 그 상자 · 선을 고름) + 「설계 때 정할 것」 전부:
* 변수 예시값 · 재료 선택 자리(범위 안 자재 목록에서 고르기 · 찾기 · 쪽) · 재료 조건 자리(지금 잡힌 품목 · 0 · 여럿 까닭)
* · 이어지지 않은 다른 로직의 받는 알약. 바꾸면 on.change(고친 다이어그램) · 줄을 누르면 on.select.
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 4 · 5 · 6 · 8 · 13. 이 폴더째 지워도 됨.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import { breaks, type CfEvalOptions, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material";
import {
designInputs,
elementPort,
type CfBox,
type CfElementRef,
type CfLogic,
type CfVariable,
} from "./M02_DF_Model";
import "./M02_DF_Verify.css";
import "@ui/ui_template_text.css";
import "@ui/ui_template_banner.css";
import "@ui/ui_template_toolbar.css";
const TEXT = {
Breaks: ["끊긴 곳", "Broken spots"],
BreaksHint: ["누르면 그 상자 · 선을 고름", "Click to select the box or line"],
Rest: ["쉬는 갈래", "Resting branches"],
RestHint: [
"조건이 안 맞아 쉬는 곳 — 누르면 그 선을 고름",
"Idle because a condition is not met — click to select the line",
],
Design: ["설계 때 정할 것", "To decide at design time"],
DesignHint: ["여기서 바꾸면 그 상자도 바뀜", "Changing here changes the box too"],
Vars: ["변수 예시값", "Variable samples"],
Slots: ["재료 선택 자리", "Material choice"],
Rules: ["재료 조건 자리", "Material by condition"],
Logics: ["이어지지 않은 다른 로직 값(예시값)", "Unlinked values of other logics (samples)"],
Choose: ["고르기", "Choose"],
Close: ["닫기", "Close"],
Find: ["찾기", "Find"],
Prev: ["이전", "Prev"],
Next: ["다음", "Next"],
Search: ["재료 이름 · 규격", "Material name or size"],
Page: ["쪽 {p} / {n} · 모두 {t}", "Page {p} / {n} · {t} in all"],
NoHit: ["찾은 재료 없음", "No material found"],
NoDefault: ["아직 고르지 않음", "Nothing chosen yet"],
Price: ["단가", "Unit price"],
Won: ["원", "KRW"],
Loading: ["찾는 중", "Searching"],
NeedValue: ["조건 값이 아직 없음", "The condition value is not there yet"],
Caught: ["잡힌 품목", "Matched item"],
Zero: ["맞는 재료 없음(0)", "No matching material (0)"],
Many: ["여럿 맞음({n}) — 첫째를 씀", "{n} match — the first is used"],
Cond: ["{field}에 「{v}」 {op}", "{field} {op} “{v}”"],
Pick: ["선택", "Choose"],
Bad: ["숫자를 넣어야 함", "Enter a number"],
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
Cant: ["지금은 바꿀 수 없음", "Cannot change this now"],
OwnSample: ["비우면 그 로직 예시값", "Empty = the logic's own sample"],
OtherLogic: [
"이어 주지 않아 그 로직의 예시값을 씀",
"Not linked — the logic's own sample is used",
],
} as const;
type TextKey = keyof typeof TEXT;
const tv = (key: TextKey, fill: Record<string, string | number> = {}): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace(/\{(\w+)\}/g, (m, k: string) =>
k in fill ? String(fill[k]) : m,
);
/** 고른 상자 · 알약 · 선 — 다이어그램이 이 모양으로 고름 */
export interface CfSel {
box: string;
port?: string;
link?: string;
}
export interface CfVerifyOn {
change(lg: CfLogic): void;
select?(sel: CfSel): void;
}
const PAGE = 10;
/** 재료 요소 하나 고치기 — 나머지는 그대로 */
function withElement(
lg: CfLogic,
boxId: string,
name: string,
patch: Partial<CfElementRef>,
): CfLogic {
return {
...lg,
boxes: lg.boxes.map((b) =>
b.id === boxId && b.input
? {
...b,
input: {
...b.input,
elements: b.input.elements.map((e) => (e.name === name ? { ...e, ...patch } : e)),
},
}
: b,
),
};
}
const matText = (m: CfMat): string =>
`${m.name}${m.spec ? ` ${m.spec}` : ""}${m.unit ? ` · ${m.unit}` : ""}`;
/** 변수 예시값 칸 */
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: tv("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: "ui-input m02-dfver__input",
attrs: {
"data-size": "32",
type: "text",
inputmode: v.type === "number" ? "decimal" : "text",
"aria-label": v.name,
},
});
if (v.range) box.placeholder = tv("Range", { a: v.range[0], b: 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 = tv("Bad");
else put(n);
});
control = box;
}
return el("label", { className: "m02-dfver__field", children: [label, control] });
}
/** 재료 선택 자리 한 줄 — 지금 품목 · 단가 · 「고르기」(찾기 · 쪽) */
function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTMLElement {
const now = el("span", { className: "m02-dfver__now", text: e.default ? "…" : tv("NoDefault") });
const price = el("span", { className: "m02-dfver__price" });
if (e.default) {
void materialOf(e.default).then((m) => (now.textContent = m ? matText(m) : (e.default ?? "")));
void priceOf(e.default).then((p) => {
price.textContent = p === null ? "" : `${tv("Price")} ${formatMoney(p)} ${tv("Won")}`;
});
}
const panel = el("div", { className: "m02-dfver__panel" });
panel.hidden = true;
const q = el("input", {
className: "ui-input m02-dfver__input",
attrs: {
"data-size": "32",
type: "text",
placeholder: tv("Search"),
"aria-label": tv("Search"),
},
});
q.value = e.scope?.찾는말 ?? "";
const list = el("ul", { className: "m02-dfver__list" });
const pager = el("div", { className: "ui-toolbar m02-dfver__pager" });
let page = 1;
const load = async (): Promise<void> => {
list.replaceChildren(el("li", { className: "ui-text ui-text--muted", text: tv("Loading") }));
const got = await findMaterials({
q: q.value.trim(),
sub: e.scope?.구분,
detail: e.scope?.상세구분,
page,
size: PAGE,
});
const pages = Math.max(1, Math.ceil(got.total / PAGE));
list.replaceChildren(
...(got.items.length
? got.items.map((m) =>
el("li", {
children: [
createButton({
label: matText(m),
variant: "ghost",
size: "sm",
onClick: () => on.change(withElement(lg, box.id, e.name, { default: m.ref })),
}),
],
}),
)
: [el("li", { className: "ui-text ui-text--muted", text: tv("NoHit") })]),
);
pager.replaceChildren(
createButton({
label: tv("Prev"),
size: "sm",
variant: "ghost",
disabled: page <= 1,
onClick: () => {
page--;
void load();
},
}),
el("span", {
className: "ui-text ui-text--muted",
text: tv("Page", { p: got.page, n: pages, t: got.total }),
}),
createButton({
label: tv("Next"),
size: "sm",
variant: "ghost",
disabled: page >= pages,
onClick: () => {
page++;
void load();
},
}),
);
};
q.addEventListener("keydown", (ev) => {
if (ev.key === "Enter") {
page = 1;
void load();
}
});
const toggle = createButton({
label: tv("Choose"),
size: "sm",
onClick: () => {
panel.hidden = !panel.hidden;
toggle.querySelector(".ui-btn__label")!.textContent = panel.hidden
? tv("Choose")
: tv("Close");
if (!panel.hidden) void load();
},
});
panel.append(
el("div", {
className: "ui-toolbar m02-dfver__search",
children: [
q,
createButton({
label: tv("Find"),
size: "sm",
onClick: () => {
page = 1;
void load();
},
}),
],
}),
list,
pager,
);
return el("div", {
className: "m02-dfver__item",
children: [
el("div", {
className: "m02-dfver__line",
children: [el("strong", { text: elementPort(e) }), now, price, toggle],
}),
panel,
],
});
}
/** 재료 조건 자리 한 줄 — 지금 잡힌 품목 · 맞는 것 0 · 여럿이면 까닭 */
function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElement {
const rule = e.rule!;
const raw = result.ports[rule.from.box]?.[rule.from.name];
const value = Array.isArray(raw) || raw === null || raw === undefined ? "" : String(raw);
const state = el("span", {
className: "m02-dfver__now",
text: value ? tv("Loading") : tv("NeedValue"),
});
const note = el("span", { className: "ui-text ui-text--muted" });
if (value) {
void findMaterials({
q: e.scope?.찾는말,
sub: e.scope?.구분,
detail: e.scope?.상세구분,
size: 100,
}).then((got) => {
const hit = got.items.filter((m) => {
const t = rule.field === "규격" ? m.spec : m.name;
return rule.op === "같음" ? t === value : t.includes(value);
});
const first = hit[0];
state.className = `m02-dfver__now${hit.length === 1 ? "" : " m02-dfver__warn"}`;
state.textContent = !first ? tv("Zero") : `${tv("Caught")}: ${matText(first)}`;
note.textContent = hit.length > 1 ? tv("Many", { n: hit.length }) : "";
});
}
const from = lg.boxes.find((b) => b.id === rule.from.box)?.name ?? "";
return el("div", {
className: "m02-dfver__item",
children: [
el("div", {
className: "m02-dfver__line",
children: [el("strong", { text: elementPort(e) }), state, note],
}),
el("span", {
className: "ui-text ui-text--muted",
text: `${from ? `${from} · ` : ""}${tv("Cond", { field: rule.field, v: value || rule.from.name, op: rule.op })}`,
}),
],
});
}
/** 검증 칸 — values = 받은 입력 값(끊긴 곳 검사가 씀) */
export function renderVerify(
host: HTMLElement,
lg: CfLogic,
result: CfEvalResult,
on: CfVerifyOn,
opts: { values?: CfValues; logics?: CfEvalOptions["logics"] } = {},
): void {
const msg = el("p", { className: "ui-text ui-text--error" });
const safe = (make: () => CfLogic): void => {
msg.textContent = "";
try {
on.change(make());
} catch {
msg.textContent = tv("Cant");
}
};
/* 끊긴 곳 */
const spots = breaks(lg, opts.values ?? {}, { logics: opts.logics });
const spotList = (list: typeof spots, hint: TextKey): HTMLElement[] => [
el("ul", {
className: "m02-dfver__list",
children: list.map((s) =>
el("li", {
children: [
createButton({
label: s.text,
variant: "ghost",
size: "sm",
disabled: !s.box || !on.select,
onClick: () => on.select?.({ box: s.box, port: s.port, link: s.link }),
}),
],
}),
),
}),
el("p", { className: "ui-text ui-text--muted", text: tv(hint) }),
];
const problems = spots.filter((s) => s.level !== "쉼");
const resting = spots.filter((s) => s.level === "쉼");
const spotBlock = [
...(problems.length
? [
el("div", {
className: "ui-banner ui-banner--danger m02-dfver__block m02-dfver__breaks",
children: [el("strong", { text: tv("Breaks") }), ...spotList(problems, "BreaksHint")],
}),
]
: []),
...(resting.length
? [
el("div", {
className: "m02-dfver__block m02-dfver__rest",
children: [el("strong", { text: tv("Rest") }), ...spotList(resting, "RestHint")],
}),
]
: []),
];
/* 설계 때 정할 것 — Model `designInputs`(변수 · 재료 자리 · 다른 로직) + 재료 조건 자리 */
const designs = designInputs(lg);
const boxOf = (id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id);
/** 변수 예시값을 그 상자(입력 · 함수)의 변수에 되적음 */
const putExample = (box: string, name: string, value: number | string): CfLogic => ({
...lg,
boxes: lg.boxes.map((x) => {
if (x.id !== box) return x;
const set = (list: CfVariable[]): CfVariable[] =>
list.map((y) => (y.name === name ? { ...y, example: value } : y));
return {
...x,
...(x.input ? { input: { ...x.input, variables: set(x.input.variables) } } : {}),
...(x.func ? { func: { ...x.func, variables: set(x.func.variables) } } : {}),
};
}),
});
const vars = designs.flatMap((d) =>
d.kind === "변수" && d.variable
? [sampleField(d.variable, (value) => safe(() => putExample(d.box, d.port, value)), msg)]
: [],
);
const slots = designs.flatMap((d) => {
const box = boxOf(d.box);
return d.kind === "재료 자리" && d.element && box
? [slotRow(box, d.element, { ...on, change: (n) => safe(() => n) }, lg)]
: [];
});
const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input);
const rules = inputs.flatMap((b) =>
(b.input?.elements ?? [])
.filter((e) => e.mode === "rule" && e.rule)
.map((e) => ruleRow(e, lg, result)),
);
/** 다른 로직의 받는 알약 — 예시값을 적으면 logic.examples · 비우면 그 로직 자기 예시값 */
const putLogicExample = (box: string, port: string, raw: string): CfLogic => ({
...lg,
boxes: lg.boxes.map((x) => {
if (x.id !== box || !x.input?.logic) return x;
const examples = { ...x.input.logic.examples };
const text = raw.trim();
if (text === "") delete examples[port];
else examples[port] = Number.isNaN(Number(text)) ? text : Number(text);
return { ...x, input: { ...x.input, logic: { ...x.input.logic, examples } } };
}),
});
const logics = designs.flatMap((d) => {
if (d.kind !== "다른 로직") return [];
const input = el("input", {
className: "ui-input m02-dfver__input",
attrs: {
"data-size": "32",
type: "text",
placeholder: tv("OwnSample"),
"aria-label": d.port,
},
});
input.value = d.example === null || d.example === undefined ? "" : String(d.example);
input.addEventListener("change", () => safe(() => putLogicExample(d.box, d.port, input.value)));
const unit = d.unit && d.unit !== "-" ? ` (${d.unit})` : "";
return [
el("li", {
className: "m02-dfver__line",
children: [
createButton({
label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${unit}`,
variant: "ghost",
size: "sm",
disabled: !on.select,
onClick: () => on.select?.({ box: d.box, port: d.port }),
}),
input,
],
}),
];
});
const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] =>
body.length
? [
el("div", {
className: "m02-dfver__group",
children: [el("strong", { text: tv(title) }), ...body],
}),
]
: [];
const design = [
...group(
"Vars",
[el("div", { className: "m02-dfver__fields", children: vars })].filter(() => vars.length),
),
...group("Slots", slots),
...group("Rules", rules),
...group(
"Logics",
logics.length ? [el("ul", { className: "m02-dfver__list", children: logics })] : [],
),
];
host.replaceChildren(
...spotBlock,
...(design.length
? [
el("div", {
className: "m02-dfver__block",
children: [
el("strong", { className: "m02-dfver__title", text: tv("Design") }),
...design,
el("p", { className: "ui-text ui-text--muted", text: tv("DesignHint") }),
msg,
],
}),
]
: []),
);
}
-6
View File
@@ -26,12 +26,6 @@ def get_auto(key: str) -> dict:
return m01.get_logic_auto(key)
@router.get("/logic/flow")
def get_flow(key: str, file: str = "") -> dict:
"""흐름 보기(읽기 · 변환해도 파일 안 바뀜)."""
return m01.get_logic_flow(key, file)
@router.get("/elements")
def get_elements(group: str, q: str = "", limit: int = 50) -> dict:
return m01.get_elements(group, q, min(limit, 200))
+2 -3
View File
@@ -192,12 +192,11 @@ NO_QTY_WHY = "수량 없음 — 쓰는 자리에서 연결"
def _defaults(whole: Any, code: str) -> dict:
"""로직 기본값 = M01 견본 입력(`/m01/logic/auto` 와 같은 길 · 흐름 예시값 › 옛 자동값)."""
"""로직 기본값 = M01 견본 입력(`/m01/logic/auto` 와 같은 길)."""
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Flow as flow
try:
got = flow.auto(code) or {"값": m01.mau.values(whole, whole.logic(code))}
got = {"값": m01.mau.values(whole, whole.logic(code))}
except Exception: # noqa: BLE001 — 없는 로직 · 자동값 못 셈 = 기본값 없음
return {}
return got.get("값") or {}