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

This commit is contained in:
2026-10-02 08:55:45 +09:00
19 changed files with 529 additions and 1247 deletions
@@ -47,6 +47,8 @@ export interface CfAnswer {
name?: string;
unit?: string;
reason?: string;
/** 빈 단가 · 맞는 재료 없음 — 옛 셈처럼 그 값만 비우고 셈은 계속(흐름은 「#N/A」) */
blank?: boolean;
/** 재료 조건 찾기가 여럿 맞을 때 후보 다섯 */
candidates?: CfMaterial[];
}
+20 -96
View File
@@ -28,6 +28,15 @@ from M01_MasterData import (
) # _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 = "표", "값"
@@ -156,7 +165,7 @@ def _element_value(
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 None
return NA_VALUE # 그 값을 쓰는 식은 빈 값(옛 엔진) — 비목 합은 0
if not got["ok"]:
errors.append(f"「{box.get('name')} · {el.get('name')}」 {got.get('reason')}")
return None
@@ -305,32 +314,6 @@ def design_inputs(lg: dict) -> list[dict]:
# ── 선 규칙(`M02_DF_Rule.applyRule` 와 같은 뜻 · 값 기준) ────────────────────
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 _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 _compare(a: Any, op: str, b: Any) -> bool:
if op in ("=", "≠"):
return _same(a, b) == (op == "=")
@@ -359,67 +342,6 @@ def _rounded(value: Any, r: dict) -> Any:
)
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 apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]:
"""(값, 까닭) — 까닭 = 통과 안 됨 · 표에 없는 조건 · 표에 여럿 맞음 · 오류 · None."""
kind = rule.get("kind")
@@ -465,12 +387,6 @@ def _gate_conds(rule: dict) -> tuple[list[dict], str]:
return rows, rule.get("join") or "그리고"
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"))
# ── 계산(`M02_DF_Eval.evalLogic` 와 같은 차례) ─────────────────────────────
def _depends(lg: dict) -> list[tuple[str, str]]:
"""상자 사이 기댐 — 선 + 선 규칙 조건 알약 + 재료 조건 찾기 알약(Model `depends`)."""
@@ -516,7 +432,7 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict:
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 v == NA_VALUE else v)
_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")))
@@ -524,7 +440,6 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict:
REST = "앞 조건이 안 맞아 쉬는 갈래"
RANGE_RULE = "·범위규칙" # 표 블록 줄마다 범위 경계 규칙 칸(Values._table · Rule RANGE_RULE)
NA_VALUE = "#N/A" # 함수 칸으로 가는 표 찾기 실패 — 칸에 NA() · 결과 오류일 때만 끊긴 곳(Eval 같음)
@@ -545,6 +460,15 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N
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"):
+10 -4
View File
@@ -284,6 +284,12 @@ class _Flow:
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"
@@ -294,8 +300,8 @@ class _Flow:
raise Unsupported(f"모르는 식 마디 「{kind}」")
def name(self, name: str, take, scope: dict) -> str:
if name in scope: # 덧줄의 비목 합 · 줄 금액
return self.excel(("@sum", scope[name]), take, scope)
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:
@@ -382,7 +388,7 @@ def _body(f: _Flow, row: dict) -> None:
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} 금액", ("bin", "*", qty, price))
part = f.func(f"{name} 금액", ("@line", qty, price))
parts[item["비목"]].append(part)
lines.setdefault(f"줄.{name}", []).append(part)
for extra in row.get("덧줄") or []:
@@ -392,7 +398,7 @@ def _body(f: _Flow, row: dict) -> None:
parts[extra["비목"]].append(part)
for c in COSTS:
if parts[c]:
_output(f, c, c, f.func(f"{c} 합", ("@sum", parts[c])), rule, "계")
_output(f, c, c, f.func(f"{c} 합", ("@sum0", parts[c])), rule, "계")
# ── 자동 배치 ────────────────────────────────────────────────────────
@@ -1,287 +0,0 @@
/* 단가산출 다이어그램 — 화면 전체를 덮는 시험판 페이지 · 페이지 하나만 세로로 밀림(안쪽 스크롤 없음) */
.m02-df {
position: fixed;
inset: 0;
z-index: 1000; /* 공용 모달과 같은 층 — 뒤에 여는 물음 · 고르기 창은 그 위(--z-confirm 1050) */
overflow-x: hidden;
overflow-y: auto;
background: var(--color-canvas);
color: var(--color-text);
font-size: var(--text-body);
}
.m02-df:focus {
outline: none;
}
.m02-df__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
/* 위 띠 — 밀어도 위에 붙어 있음 */
.m02-df__head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
padding: var(--spacing-12) var(--spacing-24);
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-raised);
}
.m02-df__title {
margin: 0;
font-size: var(--text-subheading);
}
.m02-df__trial {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.m02-df__strip {
display: flex;
flex: 1 1 480px;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-12);
}
.m02-df__name {
flex: 1 1 260px;
}
.m02-df__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
.m02-df__body {
padding: var(--spacing-16) var(--spacing-24) var(--spacing-24);
}
/* 시작 화면 */
.m02-df__start {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-24);
}
.m02-df__start-col {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-df__start-col h3 {
margin: var(--spacing-8) 0 0;
}
/* 고치는 화면 — 위 다이어그램(크게) · 아래 고른 상자 판(크게) + 원가 */
.m02-df__layout {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
.m02-df__part {
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.m02-df__part h3 {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m02-df__part-body {
padding: var(--spacing-12);
}
.m02-df__lower {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(280px, 1fr);
gap: var(--spacing-16);
align-items: start;
}
.m02-df__box .m02-df__part-body {
min-height: 50vh;
}
.m02-df__box-slots {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--spacing-12);
}
.m02-df__slot {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m02-df__slot h4 {
margin: 0;
}
@media (max-width: 1100px) {
.m02-df__lower,
.m02-df__start,
.m02-df__box-slots {
grid-template-columns: minmax(0, 1fr);
}
}
.m02-df__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m02-df__item {
display: flex;
align-items: center;
gap: var(--spacing-8);
width: 100%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-df__item:hover {
border-color: var(--color-accent);
}
.m02-df__item-name {
flex: 1;
min-width: 0;
}
.m02-df__item-unit {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
white-space: nowrap;
}
.m02-df__note {
margin: 0 0 var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-line;
}
.m02-df__note:empty {
display: none;
}
.m02-df__color {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
margin-bottom: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m02-df__color[hidden] {
display: none;
}
.m02-df__color-input {
width: 44px;
height: 32px;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: none;
cursor: pointer;
}
/* 상세 내용 — 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약 이름) · 설정 · 본문 · 경고 */
.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);
}
.m02-df__detail-dl {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--spacing-4) var(--spacing-12);
margin: 0;
}
.m02-df__detail-dl dd {
margin: 0;
}
.m02-df__detail-trace {
margin: 0;
padding-left: var(--spacing-16);
}
-644
View File
@@ -1,644 +0,0 @@
/* =============================================================================
* M02_DF_Entry.ts
* 단가산출 흐름도(시험) — 들어가는 단추 · 화면 전체를 덮는 시험판 페이지 · 시작 화면 · 고치는 화면 · 잇기
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md`(1판 약속 = 원가다이어그램_정함.md) · 이 폴더째 지워도 됨
* (폴더 밖 참조는 M01 LogicLab.ts 두 줄 + 서버 등록 한 줄). DataFlow 의 다른 파일은 import() 로 늦게.
* 잇기: 열기 → 입력 값(cfValues) → renderDiagram · 「상세 내용」(상자 · 알약 · 선 고름마다 판을 바꿔 끼움 —
* M02_DF_Entry_Detail) · renderSummary → 바뀔 때마다 기록(객체 그대로) · evalLogic → 저장(서버 대조) · 지우기 ·
* 실행 취소 · Ctrl+Z · Ctrl+S. 단추 · 칸은 공용 작은 크기. 바깥 페이지 하나만 세로로 밀림.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
import type { CfListItem } from "./M02_DF_Api";
import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model";
import type { CfSel } from "./M02_DF_Verify";
import type { DfPillSel } from "./M02_DF_Diagram_Parts";
import {
detailFrame,
drawHead,
drawWarnings,
fetchValues,
loadLogics,
queriesOf,
unitChoices,
type DfPick,
} from "./M02_DF_Entry_Detail";
import "./M02_DF_Entry.css";
/** DataFlow 의 다른 파일 — 페이지를 열 때 한 번에 불러옴(엔진이 M01 첫 묶음에 안 들어가게) */
const loadMods = () =>
Promise.all([
import("./M02_DF_Model"),
import("./M02_DF_Eval"),
import("./M02_DF_Api"),
import("./M02_DF_Diagram"),
import("./M02_DF_FuncPanel"),
import("./M02_DF_OutputPanel"),
import("./M02_DF_Summary"),
import("./M02_DF_InputPanel"),
import("./M02_DF_PillPanel"),
import("./M02_DF_LinkPanel"),
import("./M02_DF_Standards"),
]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({
model,
ev,
api,
diagram,
func,
output,
summary,
input,
pill,
line,
std,
}));
type Mods = Awaited<ReturnType<typeof loadMods>>;
const TEXT = {
Open: ["단가산출 흐름도(시험)", "Cost flow (trial)"],
Title: ["단가산출 흐름도", "Cost flow"],
Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
OpenFailed: ["열지 못함 — {v}", "Could not open — {v}"],
Start_New: ["새로 만들기", "New"],
Start_Mine: ["내 로직", "My logics"],
Start_MineNone: ["아직 없음", "None yet"],
Name: ["로직 이름", "Logic name"],
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
Unit: ["결과 단위", "Result unit"],
Undo: ["실행 취소", "Undo"],
Save: ["저장", "Save"],
Delete: ["지우기", "Delete"],
Unsaved: ["저장 안 함", "Not saved"],
Diagram: ["다이어그램", "Diagram"],
Summary: ["원가", "Cost"],
Color: ["상자 색", "Box colour"],
BoxName: ["상자 이름", "Box name"],
NameTaken: ["「{v}」 — 같은 이름의 상자가 있음", "“{v}” — another box has this name"],
Standards: ["표준 표", "Standards"],
Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"],
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
SaveAnyway: [
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
"There are issues — {v} · Save anyway?",
],
Saving: ["저장 중", "Saving"],
Saved: ["저장했음", "Saved"],
SaveFailed: ["저장 못 함 — {v}", "Not saved — {v}"],
Differs: [
"저장 때 서버가 다시 셈한 원가 {s} 원 — 화면 값 {c} 원과 다름",
"Recalculated on save: {s} — differs from the screen value {c}",
],
Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"],
Deleted: ["지웠음", "Deleted"],
Values: ["입력 값을 못 받음 — {v}", "Couldn't get input values — {v}"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
],
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
} as const;
type TextKey = keyof typeof TEXT;
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
Object.entries(vars).reduce(
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
);
const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text });
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
export function createDataFlowButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow });
}
function openDataFlow(): void {
/** 고치는 화면이 열려 있을 때만 */
let paper: { dirty(): boolean; save(): Promise<void>; undo(): void } | null = null;
let units: string[] | null = null;
const mods = loadMods();
const mayLeave = async (): Promise<boolean> =>
!paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
const body = el("main", { className: "m02-df__body" });
const strip = el("div", { className: "m02-df__strip" });
const close = createButton({
label: te("Close"),
variant: "ghost",
size: "sm",
onClick: () =>
void (async () => {
if (await mayLeave()) paper ? void showStart() : shut();
})(),
});
const page = el("div", {
className: "m02-df",
attrs: { role: "dialog", "aria-modal": "true", "aria-label": te("Title") },
children: [
el("header", {
className: "m02-df__head",
children: [
el("h2", { className: "m02-df__title", text: te("Title") }),
el("span", { className: "m02-df__trial", text: te("Trial") }),
strip,
close,
],
}),
body,
],
});
const prevOverflow = document.body.style.overflow;
const shut = (): void => {
page.remove();
document.body.style.overflow = prevOverflow;
};
page.addEventListener("keydown", (ev) => {
if (ev.defaultPrevented) return;
if (ev.key === "Escape") {
void (async () => {
if (await mayLeave()) shut();
})();
return;
}
if (!paper || !(ev.ctrlKey || ev.metaKey)) return;
const key = ev.key.toLowerCase();
if (key === "z" && !ev.shiftKey) {
ev.preventDefault();
paper.undo();
} else if (key === "s") {
ev.preventDefault();
void paper.save();
}
});
const run = (job: (M: Mods) => Promise<void>): void => {
body.replaceChildren(muted(te("Loading")));
void mods.then(job).catch((e: unknown) => {
showToast(te("OpenFailed", { v: e instanceof Error ? e.message : String(e) }), "error");
void showStart();
});
};
/* ---------- 시작 화면 ---------- */
const listRow = (name: string, unit: string, onClick: () => void, title = ""): HTMLElement => {
const row = el("button", {
className: "m02-df__item",
attrs: { type: "button", title },
children: [
el("span", { className: "m02-df__item-name", text: name }),
el("span", { className: "m02-df__item-unit", text: unit }),
],
});
row.addEventListener("click", onClick);
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
strip.replaceChildren();
const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] });
body.replaceChildren(
el("div", {
className: "m02-df__start",
children: [
el("section", {
className: "m02-df__start-col",
children: [
bigButton(te("Start_New"), () =>
run(async (M) => showPaper(M, M.model.newLogic("", (await units_())[0]), false)),
),
el("h3", { text: te("Start_Mine") }),
mineHost,
],
}),
],
}),
);
const M = await mods;
try {
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
mineHost.replaceChildren(
...(mine.length
? mine.map((x) =>
listRow(
x.name,
x.resultUnit,
() => run(async (M2) => showPaper(M2, await M2.api.cfGet(x.key), true)),
x.saved,
),
)
: [muted(te("Start_MineNone"))]),
);
} catch (e) {
mineHost.replaceChildren(muted(M.api.cfPlain(e)));
}
};
const units_ = async (): Promise<string[]> => (units ??= await unitChoices());
/* ---------- 고치는 화면 ---------- */
const showPaper = async (M: Mods, first: CfLogic, saved: boolean): Promise<void> => {
const choices = await units_();
let lg = first;
let savedLg: CfLogic | null = saved ? first : null;
let stack: CfLogic[] = [];
let pick: DfPick = null;
const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null);
let values: CfValues = {};
let valueSig = "";
let logics: NonNullable<CfEvalOptions["logics"]> = {};
let logicSig = "";
let logicNotes: string[] = [];
let broken: CfBreak[] = [];
let notes: string[] = [];
let result: CfEvalResult;
let busy = false;
const unsaved = createTag(te("Unsaved"), "warning");
const note = el("p", { className: "m02-df__note", attrs: { role: "status" } });
const diagramHost = el("div");
// 상자마다 색 바꾸기(정함 점검 9) — 고른 상자 판 머리에 하나
const colorBox = el("input", {
className: "m02-df__color-input",
attrs: { type: "color", "aria-label": te("Color") },
});
colorBox.addEventListener("change", () => {
const id = pickedBox();
if (id) edit(M.model.setColor(lg, id, colorBox.value));
});
const colorRow = el("label", {
className: "m02-df__color",
children: [el("span", { text: te("Color") }), colorBox],
});
// 상자 이름 — 입력 · 함수 상자(출력 상자는 출력 판의 이름 칸) · 빈 이름 · 같은 이름은 안 받음
const boxName = createInputField({ label: te("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 = te("NameTaken", { v: next });
if (!next || taken || next === box.name) return void (boxName.input.value = box.name);
edit(M.model.updateBox(lg, box.id, { name: next }));
});
// 판마다 제 상태를 쥠 — 종류마다 따로 두고 숨기기만(갈아끼우면 편집기가 있다고 여김)
const funcHost = el("div");
const outHost = el("div");
const inHost = el("div");
const summaryHost = el("div");
const linkHost = el("div");
const pillHost = el("div");
const detail = detailFrame();
detail.settings.append(boxName.root, colorRow);
detail.body.append(inHost, funcHost, outHost, linkHost, pillHost);
const evalNow = (): CfEvalResult => {
try {
return M.ev.evalLogic(lg, values, { logics });
} catch (e) {
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
}
};
const edit = (next: CfLogic): void => {
if (next === lg) return;
stack = [...stack, lg];
lg = next;
redraw();
};
/** 다이어그램이 부른 바꿈 — 다이어그램이 제 옛 그림으로 한 번 더 그려도 다음 틱에 새로 */
const editFromDiagram = (next: CfLogic): void => {
edit(next);
setTimeout(redraw);
};
/** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */
const refreshValues = (): void => {
// 조건 찾기 재료는 이어진 알약 값(지금 셈)을 함께 물음 — 값이 바뀌면 다시 받음
const pill = (p: CfPort): unknown => result?.ports[p.box]?.[p.name] ?? null;
const sig = JSON.stringify(queriesOf(lg, pill, M.model.elementPort).map((a) => a.q));
if (sig === valueSig) return;
valueSig = sig;
void fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then(
(got) => {
if (sig !== valueSig) return;
values = got.values;
notes = got.errors.length ? [te("Values", { v: got.errors.join(" · ") })] : [];
redraw();
},
(e: unknown) => {
notes = [te("Values", { v: M.api.cfPlain(e) })];
redraw();
},
);
};
/** 다른 로직 입력이 부르는 로직들 다시 읽기 — 부르는 열쇠가 바뀐 때만 */
const refreshLogics = (): void => {
const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
if (sig === logicSig) return;
logicSig = sig;
const get = async (key: string): Promise<CfLogic | null> =>
key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null);
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;
logicNotes = got.errors.length ? [te("Logics", { v: 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: M.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: M.model.traceBack(lg, p), forward: M.model.traceForward(lg, p) };
}
const id = pick?.kind === "link" ? pick.id : "";
const link = lg.links.find((l) => l.id === id);
if (link)
return {
back: M.model.traceBack(lg, link.from),
forward: M.model.traceForward(lg, link.to),
};
} catch {
/* 따라갈 수 없으면 칠하지 않음 */
}
return null;
};
const name = createInputField({
label: te("Name"),
placeholder: te("NamePlaceholder"),
value: lg.name,
compact: true,
onInput: (v) => edit({ ...lg, name: v }),
});
name.root.classList.add("m02-df__name");
const unitList = choices.includes(lg.resultUnit) ? choices : [lg.resultUnit, ...choices];
const unit = createSelectField({
label: te("Unit"),
options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
value: lg.resultUnit,
compact: true,
onChange: (v) => edit({ ...lg, resultUnit: v }),
});
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(M.model.moveBox(lg, id, x, y)),
link: (from: CfPort, to: CfPort): string | null => {
const got = M.model.link(lg, from, to);
if (got.why) return got.why;
editFromDiagram(got.lg);
return null;
},
unlink: (id: string): void => editFromDiagram(M.model.unlink(lg, id)),
add: (kind: CfKind, x: number, y: number): void => {
const got = M.model.addBox(lg, kind, x, y);
pick = { kind: "box", id: got.box.id };
editFromDiagram(got.lg);
},
remove: (id: string): void => {
if (pickedBox() === id) pick = null;
editFromDiagram(M.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;
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")
M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics });
if (pick?.kind === "pill")
M.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") M.input.renderInputPanel(inHost, lg, box.id, { change: edit });
if (box?.kind === "function")
M.func.renderFuncPanel(funcHost, lg, box.id, { change: edit }, { values, logics });
if (box?.kind === "output")
M.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 = M.ev.breaks(lg, values, { logics });
} catch {
broken = result.breaks ?? [];
}
unsaved.hidden = lg === savedLg;
undo.disabled = !stack.length;
deleteButton.hidden = !lg.key;
M.diagram.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,
});
drawPanel();
M.summary.renderSummary(
summaryHost,
lg,
result,
{ change: edit, select: choose },
{ values },
);
note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n");
};
const save = async (): Promise<void> => {
if (busy) return;
if (!lg.name.trim()) {
note.textContent = te("NoName");
return void name.input.focus();
}
let problems: string[] = [];
try {
problems = M.ev.checkLogic(lg, values, { logics });
} catch {
problems = [];
}
const ask = te("SaveAnyway", { v: problems.slice(0, 3).join(" · ") });
if (problems.length && !(await showConfirmDialog(ask, te("Save")))) return;
busy = true;
note.textContent = te("Saving");
const screen = result.cost.계;
try {
const got = await M.api.cfSave(lg);
lg = got.logic;
savedLg = lg;
stack = [];
redraw();
const server = got.values.cost.계;
if (Math.abs(server - screen) > 0.5)
notes = [te("Differs", { s: formatMoney(server), c: formatMoney(screen) })];
note.textContent = [...notes, ...result.errors].join("\n");
showToast(te("Saved"), "success");
} catch (e) {
note.textContent = te("SaveFailed", { v: M.api.cfPlain(e) });
} finally {
busy = false;
}
};
const doUndo = (): void => {
const back = stack[stack.length - 1];
if (!back) return;
stack = stack.slice(0, -1);
lg = back;
name.input.value = lg.name;
unit.select.value = lg.resultUnit;
redraw();
};
const doDelete = async (): Promise<void> => {
if (!lg.key || busy) return;
if (!(await showConfirmDialog(te("Delete_Ask", { v: lg.name }), te("Delete")))) return;
try {
await M.api.cfDelete(lg.key);
savedLg = lg;
showToast(te("Deleted"), "success");
void showStart();
} catch (e) {
note.textContent = M.api.cfPlain(e);
}
};
const undo = createButton({ label: te("Undo"), variant: "ghost", size: "sm", onClick: doUndo });
const deleteButton = createButton({
label: te("Delete"),
variant: "danger",
size: "sm",
onClick: () => void doDelete(),
});
strip.replaceChildren(
name.root,
unit.root,
unsaved,
el("div", {
className: "m02-df__actions",
children: [
createButton({
label: te("Standards"),
variant: "ghost",
size: "sm",
onClick: () =>
openModal({
title: te("Standards"),
closeLabel: te("Close"),
mount: (host: HTMLElement) =>
M.std.renderStandards(host, { change: () => redraw() }, { title: false }),
}),
}),
undo,
createButton({ label: te("Save"), size: "sm", onClick: () => void save() }),
deleteButton,
],
}),
);
body.replaceChildren(
note,
el("div", {
className: "m02-df__layout",
children: [
part(te("Diagram"), "m02-df__diagram", [diagramHost]),
el("div", {
className: "m02-df__lower",
children: [detail.root, part(te("Summary"), "m02-df__summary", [summaryHost])],
}),
],
}),
);
paper = { dirty: () => lg !== savedLg, save, undo: doUndo };
redraw();
if (!lg.name) name.input.focus();
};
void showStart();
document.body.style.overflow = "hidden"; // 뒤 페이지는 안 밀리고 시험판 페이지만 밀림
document.body.append(page);
page.tabIndex = -1;
page.focus();
}
/** 고치는 화면의 큰 자리 — 제목 + 몸(안쪽 스크롤 없음 · 내용만큼 늘어남) */
function part(title: string, className: string, children: HTMLElement[]): HTMLElement {
return el("section", {
className: `m02-df__part ${className}`,
children: [el("h3", { text: title }), el("div", { className: "m02-df__part-body", children })],
});
}
/** 보통 크기 단추 — 칸 폭으로 늘어나지 않게 감쌈 */
function bigButton(label: string, onClick: () => void): HTMLElement {
return el("div", { children: [createButton({ label, variant: "filled", size: "sm", onClick })] });
}
@@ -272,6 +272,7 @@ export async function fetchValues(
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;
}
+20 -1
View File
@@ -46,6 +46,7 @@ export interface CfBreak {
| "통과 안 됨"
| "표에 없는 조건"
| "맞는 재료 없음"
| "단가 없는 품목"
| "조건 겹침"
| "조건 안 맞음"
| "오류";
@@ -112,6 +113,10 @@ const TEXT = {
"「{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",
@@ -234,7 +239,8 @@ function runFunc(
const sheet: Sheet = structuredClone(func.sheet);
for (const i of func.inputs) {
const v = arrive[i.port] ?? given[i.port] ?? null;
if (v === NA_VALUE) sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" };
if (isErrorValue(v))
sheet.칸[i.cell.toUpperCase()] = { 식: "NA()" }; // 오류 값 · 빈 단가
else put(sheet.칸, i.cell, v);
}
for (const v of func.variables)
@@ -275,6 +281,19 @@ export function evalLogic(
/** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */
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(
@@ -7,20 +7,14 @@
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createSelectField,
el,
showToast,
} from "@ui/ui_template_elements";
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, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Api";
import { cfPlain, cfValues } from "./M02_DF_Api";
import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model";
import {
GROUPS,
@@ -40,7 +34,6 @@ import {
} from "./M02_DF_InputPanel_Build";
import { renderLogicInput } from "./M02_DF_InputPanel_Logic";
import { renderGroupRule } from "./M02_DF_InputPanel_Rule";
import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick";
import { renderMaterial } from "./M02_DF_InputPanel_Material";
import { renderTablePick } from "./M02_DF_InputPanel_Table";
import { renderVariables } from "./M02_DF_InputPanel_Vars";
@@ -52,17 +45,7 @@ interface Answer {
ok: boolean;
value?: number | string | null;
}
interface UserEl {
id: string;
group: string;
name: string;
spec: string;
unit: string;
value: number | null;
}
const apiValues = cfValues as unknown as (items: object[], users?: object[]) => Promise<Answer[]>;
const apiUsers = cfUserElements as unknown as () => Promise<UserEl[]>;
const apiAdd = cfUserElementAdd as unknown as (e: Omit<UserEl, "id">) => Promise<UserEl>;
const TEXT = {
Kind: ["무엇을 넣을까요?", "What goes in?"],
@@ -94,15 +77,6 @@ const TEXT = {
Loading: ["찾는 중…", "Searching…"],
NoRow: ["찾는 요소 없음", "Nothing found"],
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
Mine: ["내가 만든 것", "Mine"],
NewHead: ["새 요소 — 마스터에는 안 쓰고 내 것으로만 저장", "New element — saved as mine only"],
NewName: ["이름", "Name"],
NewSpec: ["규격", "Spec"],
NewUnit: ["단위", "Unit"],
NewValue: ["값(숫자)", "Value (number)"],
Make: ["만들어 넣기", "Create and add"],
NeedName: ["이름을 적어 주세요", "Enter a name"],
NeedValue: ["값은 숫자로", "Value must be a number"],
BoxUnit: ["상자 단위", "Box unit"],
Field: ["값 칸", "Value"],
NoValue: ["—", "—"],
@@ -208,7 +182,7 @@ function renderGroup(
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, newForm(lg, box, group, on));
body.replaceChildren(find);
void drawFind(find, lg, box, group, on);
}
};
@@ -306,7 +280,7 @@ function drawChosen(
.filter((e) => e.mode !== "rule")
.map((e) => ({ id: e.ref, ...queryOf(group, e.ref) }));
if (!items.length) return;
void apiValues(items, lg.userElements).then(
void apiValues(items).then(
(answers) =>
answers.forEach((a) => {
const node = values.get(a.id);
@@ -360,26 +334,10 @@ async function drawFind(
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
try {
const word = q.input.value.trim();
const [page, users] = await Promise.all([
fetchRows(file, 1, 40, word, false, sub, detail),
apiUsers().catch((): UserEl[] => []),
]);
const page = await fetchRows(file, 1, 40, word, false, sub, detail);
if (mine !== seq) return;
const known = new Map<string, UserEl>();
for (const u of [...lg.userElements, ...users])
if (u.group === group) known.set(u.id, u as UserEl);
const mineRows: M01Row[] = [...known.values()]
.filter((u) => !word || `${u.name} ${u.spec}`.includes(word))
.map((u) => ({
키: u.id,
이름: u.name,
규격: u.spec,
단위: u.unit,
값: u.value,
__mine: true,
}));
const rows = [...mineRows, ...(page.rows as M01Row[])];
count.textContent = t("Count", { n: page.total + mineRows.length, m: rows.length });
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))
@@ -411,7 +369,6 @@ async function drawFind(
children: [
el("strong", { text: String(r["이름"] ?? "") }),
el("span", { className: "m02-dfip__muted", text: ` ${r["규격"] ?? ""}` }),
...(r["__mine"] ? [el("span", { className: "m02-dfip__tag", text: t("Mine") })] : []),
],
}),
el("span", {
@@ -448,66 +405,3 @@ async function drawFind(
);
void load();
}
/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */
function newForm(
lg: CfLogic,
box: CfBox,
group: CfGroup,
on: { change(lg: CfLogic): void },
): HTMLElement {
const name = createInputField({ type: "text", label: t("NewName"), compact: true });
const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true });
let unitValue = "";
const unit = el("div", { className: "m02-dfip__unitfield" });
const drawUnit = (): void =>
unit.replaceChildren(
el("span", { className: "m02-dfip__muted", text: t("NewUnit") }),
createUnitPick({
value: unitValue || NO_UNIT,
ariaLabel: t("NewUnit"),
onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()),
}),
);
drawUnit();
const value = createInputField({ type: "text", label: t("NewValue"), compact: true });
const go = createButton({
label: t("Make"),
onClick: () => {
const n =
value.input.value.trim() === "" ? null : Number(value.input.value.replace(/,/g, ""));
if (!name.input.value.trim()) return name.setError(t("NeedName"));
if (n !== null && !Number.isFinite(n)) return value.setError(t("NeedValue"));
name.setError(undefined);
value.setError(undefined);
apiAdd({
group,
name: name.input.value.trim(),
spec: spec.input.value.trim(),
unit: unitValue,
value: n,
}).then(
(made) => {
const el1 = elementOf(group, {
키: made.id,
이름: made.name,
규격: made.spec,
단위: made.unit,
값: made.value,
});
const withUser: CfLogic = {
...lg,
userElements: [...lg.userElements.filter((u) => u.id !== made.id), { ...made, group }],
};
on.change(addElement(withUser, box.id, el1));
},
(e) => showToast(plain(e), "error"),
);
},
});
const d = el("details", {
className: "m02-dfip__new",
children: [el("summary", { text: t("NewHead") }), name.root, spec.root, unit, value.root, go],
});
return d;
}
@@ -45,9 +45,6 @@ export const M01_OF: Record<CfGroup, { group: string; part: string }> = {
/** M01 줄 한 개 — 파일 그대로(한글 칸) */
export type M01Row = Record<string, unknown>;
/** 사용자 요소 키 머리 — 마스터가 아니라 시험 폴더 `_data` 에서 옴 */
export const USER_PREFIX = "UE-";
/** 기계 요소 한 칸의 값 이름(가격 · 손료계수 …) — ref 는 「EQ000001#가격」 */
export const MACHINE_SEP = "#";
export const machineFields = (row: M01Row): string[] =>
@@ -85,13 +82,6 @@ export function valueOf(group: CfGroup, row: M01Row, field = "가격"): number |
export function elementOf(group: CfGroup, row: M01Row, field = "가격"): CfElementRef {
const key = text(row["키"]);
const spec = text(row["규격"]);
if (key.startsWith(USER_PREFIX))
return {
ref: key,
name: [text(row["이름"]), spec].filter(Boolean).join(" "),
spec,
unit: text(row["단위"]) || "-",
};
// 환율 · 유가전력의 규격은 날짜라 알약 이름에 안 넣음(날짜가 바뀌어도 이름이 같게) — 유가전력은 상세구분(지역)으로 가름
const tail = group === "환율" ? "" : group === "유가전력" ? text(row["상세구분"]) : spec;
const named = [text(row["이름"]), tail].filter(Boolean).join(" "); // 알약 이름 = 이름 + 규격(규격만 다른 요소가 부딪히지 않게)
@@ -123,7 +113,7 @@ export function pillNames(box: CfBox): string[] {
/** 입력 값 물음 — Api `cfValues` 한 줄(id 는 부르는 쪽이 정함) */
export function queryOf(group: CfGroup, ref: string): Record<string, unknown> {
if (group === "기계" && !ref.startsWith(USER_PREFIX)) {
if (group === "기계") {
const [key, field] = ref.split(MACHINE_SEP);
return { group, ref: key, field: field || "가격" };
}
@@ -1,6 +1,6 @@
/* =============================================================================
* M02_DF_InputPanel_Logic.ts
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽) 가운데 하나를 고르면
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
* 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
@@ -19,7 +19,7 @@ import {
fetchLogic,
fetchLogics,
} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
import { cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
import {
filterLogics,
@@ -37,7 +37,7 @@ import {
const TEXT = {
Help: [
"마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
"마스터 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
"Use any master logic or a logic of mine as an input",
],
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
@@ -49,7 +49,6 @@ const TEXT = {
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
Prev: ["이전", "Prev"],
Next: ["다음", "Next"],
Mine: ["내가 만든 것", "Mine"],
Blocked: ["읽을 수 없음", "Unreadable"],
Chosen: ["고른 로직", "Chosen logic"],
Converted: [
@@ -97,38 +96,20 @@ const plain = (e: unknown): string => {
};
const PAGE = 12;
const MINE = "흐름도";
/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */
/** 마스터 로직 목록 — 한 번 받아 둠(M01 `fetchLogics`) */
let cache: Promise<MasterLogicBrief[]> | null = null;
const loadAll = (): Promise<MasterLogicBrief[]> =>
(cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])])
.then(([master, mine]) => [
...mine
.filter((i) => !i.sample)
.map((i): MasterLogicBrief => ({
file: MINE,
구분: MINE,
상세구분: t("Mine"),
키: i.key,
원문번호: "",
이름: i.name,
결과단위: i.resultUnit,
})),
...master,
])
.catch((e: unknown) => ((cache = null), Promise.reject(e))));
(cache ??= fetchLogics().catch((e: unknown) => ((cache = null), Promise.reject(e))));
/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */
async function flowOf(
/** 마스터 로직 하나를 읽어 흐름도로 — 흐름 받기 길(흐름이 없으면 옛 로직을 바꿔 읽음) */
const flowOf = (
item: Pick<MasterLogicBrief, "file" | "키">,
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] };
return cfLogicFlow(item.file, item.키);
}
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> =>
cfLogicFlow(item.file, item.키);
/** 다른 로직 입력 판 */
export function renderLogicInput(
@@ -214,7 +195,7 @@ export function renderLogicInput(
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
el("span", {
className: "m02-dfip__tag",
text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분,
text: i.blocked ? t("Blocked") : i.상세구분,
}),
],
}),
@@ -0,0 +1,112 @@
"""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"))
+34 -13
View File
@@ -57,43 +57,64 @@
padding: var(--spacing-4) 0;
}
/* 왼쪽 가장자리 끌기 */
/* 왼쪽 가장자리 끌기 — 늘 보이는 손잡이(세로 막대 + 가운데 잡이) */
.m02-dfp__grip {
position: absolute;
top: 0;
bottom: 0;
left: -4px;
left: -5px;
z-index: 2;
width: 8px;
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) 35%, transparent);
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-12) 0;
border: 0;
padding: var(--spacing-8) 0;
background: var(--color-surface);
color: var(--color-text-secondary);
font: inherit;
writing-mode: vertical-rl;
cursor: pointer;
}
.m02-dfp__strip:hover,
.m02-dfp__strip:focus-visible {
/* 공용 작은 단추 크기는 그대로 — 글 방향만 세로 */
.m02-dfp__strip .m02-dfp__toggle {
writing-mode: vertical-rl;
}
.m02-dfp__toggle[aria-expanded="true"] {
color: var(--color-accent);
}
+47 -25
View File
@@ -10,15 +10,18 @@
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";
const TEXT = {
Big: ["크게 보기", "Full screen"],
Fold: ["접기", "Collapse"],
Strip: ["흐름도 ▸", "Flow ▸"],
StripTitle: ["흐름도 펴기", "Open the flow panel"],
Standards: ["표준 표", "Standards"],
Toggle: ["흐름도", "Flow"],
ToggleOpen: ["흐름도 펴기", "Open the flow panel"],
ToggleClose: ["흐름도 접기", "Collapse the flow panel"],
Close: ["닫기", "Close"],
Detail: ["상세 내용", "Details"],
Cost: ["원가 · 검증", "Cost · check"],
@@ -108,6 +111,38 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
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: () => 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",
@@ -117,31 +152,12 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
const tools = el("div", { className: "m02-dfp__tools", children: [undo] });
const open = el("div", {
className: "m02-dfp__open",
children: [
el("header", {
className: "m02-dfp__head",
children: [
el("strong", { className: "m02-dfp__title", text: opts.title }),
sm(tf("Big"), () => expand()),
sm(tf("Fold"), () => setFolded(true)),
],
}),
tools,
note,
diagram,
detail,
costBox,
],
children: [head, tools, note, diagram, detail, costBox],
});
costBox.append(summary);
/* 접힌 띠 */
const strip = el("button", {
className: "m02-dfp__strip",
text: tf("Strip"),
attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") },
});
strip.addEventListener("click", () => setFolded(false));
/* 접힌 띠 — 같은 「흐름도」 단추만 */
const strip = el("div", { className: "m02-dfp__strip" });
/* 왼쪽 가장자리 — 끌기 · 화살표 키 */
const grip = el("div", {
@@ -195,12 +211,18 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
});
const setFolded = (on: boolean): void => {
// 접기 전 너비를 기억 — 끈 적이 없어도 펴면 그 너비로
if (on && !folded && width === null && root.isConnected)
width = Math.round(root.getBoundingClientRect().width);
folded = on;
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();
};
@@ -0,0 +1,113 @@
/* 흐름도 고치는 핵심(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);
}
+4 -5
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_DF_Paper.ts
* 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소
* Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀.
* (옛 시험판 고치는 화면을 옮겨 적은) 공용 조각 — 오른쪽 패널(M02_DF_Panel) · 「크게 보기」 가 함께 씀.
* 저장 · 지우기 · 이름 · 결과 단위는 없음(부르는 쪽 페이지 몫) · 바뀔 때마다 onChange(객체 그대로).
* readOnly = 끌기 · 고치기 막고 보기 · 흐름 따라가기 · 검증 칸 예시값만(onChange 안 부름).
* 판 DOM 은 한 번 만들고 자리만 옮겨 끼움(패널 ↔ 크게 보기) — 고친 것 · 고른 것 그대로.
@@ -31,7 +31,7 @@ import {
queriesOf,
type DfPick,
} from "./M02_DF_Entry_Detail";
import "./M02_DF_Entry.css";
import "./M02_DF_Paper.css";
const TEXT = {
Undo: ["실행 취소", "Undo"],
@@ -51,7 +51,7 @@ export interface PaperOpts {
readOnly?: boolean;
/** 고칠 때마다 — 객체 그대로(부르는 쪽 캐시에 쌓음) */
onChange(lg: CfLogic): void;
/** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */
/** 다른 로직 입력이 부르는 로직 — 없으면 못 부름(빈 값) */
logics?(key: string): Promise<CfLogic | null>;
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
fitMin?(): number;
@@ -186,8 +186,7 @@ export function createPaper(opts: PaperOpts): Paper {
const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
if (sig === logicSig) return;
logicSig = sig;
const get =
opts.logics ?? (async (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : null));
const get = opts.logics ?? (async () => null);
void loadLogics(lg, get).then((got) => {
if (sig !== logicSig) return;
logics = got.logics;