Merge remote-tracking branches 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_3

This commit is contained in:
2026-10-01 23:27:15 +09:00
8 changed files with 678 additions and 75 deletions
+30 -11
View File
@@ -323,7 +323,7 @@ def _lookup(rule: dict, block: Any, conds: dict) -> tuple[Any, str | None]:
return None, "오류"
tests = []
for c in rule.get("conds") or []:
col, want = str(c.get("col")), conds.get((c.get("from") or {}).get("name"))
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: _same(row[i], want))
@@ -362,12 +362,13 @@ def apply_rule(rule: dict, value: Any, conds: dict) -> tuple[Any, str | None]:
if kind == "lookup":
return _lookup(rule, value, conds)
if kind == "gate":
src = (rule.get("from") or {}).get("name")
return (
(value, None)
if _compare(conds.get(src), rule.get("op"), rule.get("value"))
else (None, "통과 안 됨")
)
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, "오류")
@@ -387,7 +388,23 @@ def _sources(rule: dict | None) -> list[dict]:
return []
if rule.get("kind") == "lookup":
return [c.get("from") or {} for c in rule.get("conds") or []]
return [rule.get("from") or {}] if rule.get("kind") == "gate" else []
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 "그리고"
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` 와 같은 차례) ─────────────────────────────
@@ -462,9 +479,11 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N
return v
if v is None and rule.get("kind") in ("scale", "round"):
return None
conds = {
p.get("name"): ports.get(p.get("box"), {}).get(p.get("name")) for p in _sources(rule)
}
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:
blocked.add(ln["to"].get("box"))
+30 -48
View File
@@ -23,6 +23,9 @@ import {
restParts,
linkLabel,
midOf,
pillInfo,
svgAdd as add,
svgLabel as label,
pillsOf,
resolveDrop,
showValue,
@@ -46,6 +49,7 @@ interface HostState {
panX: number;
panY: number;
fitted: boolean;
fitKey?: number;
watch?: ResizeObserver;
}
@@ -79,6 +83,9 @@ export function renderDiagram(
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 ?? {};
@@ -243,7 +250,7 @@ export function renderDiagram(
className: "m02-dfdg__ins",
children: [
...ins.map((p) => pillEl(b, p, "in")),
...(b.kind === "function" ? [plusSlot(b)] : []),
...(b.kind === "function" && !ro ? [plusSlot(b)] : []),
],
}),
el("div", {
@@ -282,13 +289,13 @@ export function renderDiagram(
ArrowUp: [0, -step],
ArrowDown: [0, step],
}[ev.key];
if (d && st.selected === b.id) {
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) dragBox(ev, b, node);
if (ev.button === 0 && !spaceDown && !ro) dragBox(ev, b, node);
});
return node;
};
@@ -350,15 +357,8 @@ export function renderDiagram(
);
} else if (st.pill) {
const p = st.pill;
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) => endName(p.dir === "in" ? l.from : l.to))
.join(", ");
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`;
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") }));
if (p.dir === "out")
tools.push(el("span", { text: pillInfo(lg, p, values) }));
if (p.dir === "out" && !ro)
tools.push(
sm(
"Connect",
@@ -371,9 +371,9 @@ export function renderDiagram(
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
tools.push(
el("span", { text: rule ? `${line} · ${rule}` : line }),
sm("Disconnect", () => on.unlink(selLink.id), "danger"),
...(ro ? [] : [sm("Disconnect", () => on.unlink(selLink.id), "danger")]),
);
} else if (selBox) {
} else if (selBox && !ro) {
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
}
const legend =
@@ -388,18 +388,26 @@ export function renderDiagram(
className: "m02-dfdg__bar",
children: [
el("strong", { className: "m02-dfdg__title", text: tt("Title") }),
addBtn("input", "AddInput"),
addBtn("function", "AddFunc"),
addBtn("output", "AddOutput"),
...(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),
sm("Arrange", () => {
for (const [id, p] of Object.entries(arrange(lg))) on.move(id, p.x, p.y);
}),
...(ro
? []
: [
sm("Arrange", () =>
Object.entries(arrange(lg)).forEach(([id, q]) => on.move(id, q.x, q.y)),
),
]),
],
});
const root = el("div", {
@@ -426,7 +434,7 @@ export function renderDiagram(
st.pill = null;
redraw();
}
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
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);
});
@@ -477,32 +485,6 @@ export function renderDiagram(
const right = (dir === "out") === (side === "near");
return toWorld(right ? r.right : r.left, r.top + r.height / 2);
};
const add = <K extends keyof SVGElementTagNameMap>(
parent: Element,
tag: K,
attrs: Record<string, string>,
) => {
const n = document.createElementNS(SVG, tag);
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
parent.append(n);
return n;
};
const label = (parent: Element, x: number, y: number, text: string, cls: string): number => {
const tag = add(parent, "g", { class: cls });
const box = add(tag, "rect", { rx: "4", ry: "4" });
const t = add(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;
};
const defs = add(svg, "defs", {});
const arrow = add(defs, "marker", {
id: "m02-dfdg-arrow",
@@ -657,7 +639,7 @@ export function renderDiagram(
}
function dragLink(ev: PointerEvent, from: CfPort): void {
if (ev.button !== 0 || spaceDown) return;
if (ev.button !== 0 || spaceDown || ro) return;
ev.stopPropagation();
ev.preventDefault();
const pill = ev.currentTarget as HTMLElement;
@@ -1,7 +1,7 @@
/* =============================================================================
* M02_DF_Diagram_Parts.ts
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`.
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이 · SVG 도우미. 그리기는 `M02_DF_Diagram.ts`.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
@@ -118,6 +118,10 @@ export interface DiagramOpts {
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
/** 끊긴 곳 — Eval breaks */
breaks?: CfBreak[];
/** 보기만 — 끌기 · 잇기 · 더하기 · 지우기 막음 */
readOnly?: boolean;
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
fitKey?: number;
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
@@ -330,6 +334,25 @@ export function restParts(
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 한 번만 삼킴 */
@@ -360,3 +383,37 @@ export function follow(
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;
}
+16 -6
View File
@@ -16,10 +16,12 @@ import {
depends,
designInputs,
elementPort,
gateConds,
portsIn,
portsOut,
ruleSources,
type CfBox,
type CfGateRule,
type CfLink,
type CfLogic,
} from "./M02_DF_Model";
@@ -266,7 +268,10 @@ export function evalLogic(
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).map((p) => [p.name, valueOf(p.box, p.name)]),
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);
@@ -411,21 +416,26 @@ function gates(
const groups = new Map<string, CfLink[]>();
for (const l of lg.links)
if (l.rule?.kind === "gate") {
const k = `${l.rule.from.box}|${l.rule.from.name}|${l.from.box}|${l.from.name}`;
// 같은 알약에서 나가 같은 조건 알약들로 가르는 선끼리 — 여러 조건이면 조건 알약 묶음이 같을 때
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 = list[0].rule as Extract<CfLink["rule"], { kind: "gate" }>;
const fill = { box: name(g.from.box), pill: g.from.name };
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.from.box,
port: g.from.name,
box: g.box,
port: g.name,
text,
});
errors.push(text);
+75 -2
View File
@@ -126,9 +126,27 @@ 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 }
| { kind: "gate"; from: CfPort; op: CfGateOp; value: number | string }
| CfGateRule
| { kind: "scale"; op: "×" | "÷" | "+%" | "-%"; value: number }
| ({ kind: "round" } & CfRound);
@@ -357,10 +375,65 @@ export function portDir(box: CfBox, name: string): "in" | "out" {
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 [rule.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 }[] = [];
+341
View File
@@ -0,0 +1,341 @@
/* =============================================================================
* M02_DF_Paper.ts
* 흐름도 고치는 핵심 — 다이어그램 · 상세 내용(상자 · 알약 · 선 판) · 원가 · 검증 칸 · 실행 취소
* Entry 시험판의 고치는 화면(showPaper)을 옮겨 적은 공용 조각 — 오른쪽 패널(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_Entry.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;
/** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */
logics?(key: string): Promise<CfLogic | null>;
}
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: "m02-df__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 (key: string) => (key.startsWith("DF-") ? api.cfGet(key) : 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 });
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,
});
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,
};
}
+24 -7
View File
@@ -12,7 +12,7 @@ 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 type { CfGateOp, CfRound, CfRule } from "./M02_DF_Model";
import { gateConds, type CfGateOp, type CfRound, type CfRule } from "./M02_DF_Model";
type Scalar = number | string | boolean | null;
@@ -27,6 +27,8 @@ export interface CfRuleResult {
const TEXT = {
Lookup: ["표에서 찾기", "Look up in table"],
Gate: ["일 때만", "only when"],
And: ["그리고", "and"],
Or: ["또는", "or"],
Places: ["소수 {n}자리", "{n} decimals"],
Round: ["반올림", "round"],
Up: ["올림", "round up"],
@@ -57,8 +59,11 @@ export function ruleLabel(rule: CfRule): string {
const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · ");
return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`;
}
case "gate":
return `${rule.from.name} ${rule.op} ${rule.value} ${tl("Gate")}`;
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}%`
@@ -109,6 +114,10 @@ const lit = (v: number | string): string =>
/* ── 규칙 ───────────────────────────────────────────────────────────────── */
/** 조건 알약 값 — 열쇠 「상자|알약」 이 먼저 · 없으면 알약 이름 */
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,
@@ -121,9 +130,17 @@ export function applyRule(
if (a1) 칸.A1 = a1;
let formula: string;
if (rule.kind === "gate") {
const c1 = cellOf(conds[rule.from.name]);
if (c1) 칸.C1 = c1;
formula = `IF(C1${OP[rule.op]}${lit(rule.value)},A1,NA())`;
// 조건 값 = 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 =
@@ -169,7 +186,7 @@ function lookup(
let bad = !out || rows < 1;
rule.conds.forEach((c, i) => {
const ref = `$${colName(W + 1 + i)}$1`;
const cv = cellOf(conds[c.from.name]);
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}·아래`);
+104
View File
@@ -680,3 +680,107 @@ def test_만나는_상자는_살아_있는_선이_있으면_셈_서버와_같음
k: float(v) for k, v in screen_out.items()
}
assert [[b["kind"], b["level"], b["link"]] for b in srv["breaks"]] == screen_breaks
# ── 36-0b 선 조건 여러 줄(그리고 · 또는) · 값 목록 ─────────────────────────────
def test_조건_여러_줄_그리고_또는_글_식_서버같음() -> None:
lg = _ground()
two = {
"kind": "gate",
"conds": [
{"from": {"box": "in-ground", "name": "지반"}, "op": "=", "value": "토사"},
{"from": {"box": "in-ground", "name": "굴착량"}, "op": "≥", "value": 50},
],
"join": "그리고",
"from": {"box": "in-ground", "name": "지반"},
"op": "=",
"value": "토사",
}
either = {**two, "join": "또는"}
cases = [
(two, {"in-ground": {"지반": "토사", "굴착량": 100}}),
(two, {"in-ground": {"지반": "토사", "굴착량": 10}}),
(either, {"in-ground": {"지반": "암", "굴착량": 100}}),
(either, {"in-ground": {"지반": "암", "굴착량": 10}}),
]
got = _run(
RULE
+ f"const base = {json.dumps(lg, ensure_ascii=False)};\n"
+ f"const cases = {json.dumps(cases, ensure_ascii=False)};\n"
"out(cases.map(([rule, vals]) => {\n"
" const lg = { ...base, links: base.links.map(l => l.id === 'l1' ? { ...l, rule } : l) };\n"
" const ev = E.evalLogic(lg, vals);\n"
" return [R.ruleLabel(rule), R.applyRule(rule, 1, { 지반: vals['in-ground'].지반, 굴착량: vals['in-ground'].굴착량 }).formula,"
" ev.ports['fn-exc'], ev.breaks.filter(b => b.link === 'l1').map(b => b.kind)];\n"
"}));"
)
labels = [g[0] for g in got]
assert labels[0] == "지반 = 토사 그리고 굴착량 ≥ 50 일 때만"
assert labels[2] == "지반 = 토사 또는 굴착량 ≥ 50 일 때만"
assert got[0][1] == 'IF(AND(C1="토사",D1>=50),A1,NA())'
assert got[2][1] == 'IF(OR(C1="토사",D1>=50),A1,NA())'
passed = [g[3] == [] for g in got]
assert passed == [True, False, True, False]
# 서버(Compute) = 화면(Eval)
for (rule, values), (_, _, ports, breaks) in zip(cases, got):
srv_lg = {
**lg,
"links": [{**ln, "rule": rule} if ln["id"] == "l1" else ln for ln in lg["links"]],
}
srv = _server(srv_lg, values)
exc = srv["ports"].get("fn-exc") or {}
assert {k: (None if v is None else float(v)) for k, v in exc.items()} == {
k: (None if v is None else float(v)) for k, v in ports.items()
}
assert [b["kind"] for b in srv["breaks"] if b.get("link") == "l1"] == breaks
def test_옛_한_줄_꼴과_makeGate() -> None:
got = _run(
RULE
+ "const old = { kind: 'gate', from: { box: 'b1', name: '지반' }, op: '=', value: '토사' };\n"
"const made = M.makeGate([{ from: { box: 'b1', name: '지반' }, op: '≠', value: '암' }, { from: { box: 'b1', name: '높이' }, op: '<', value: 3 }], '또는');\n"
"out([M.gateConds(old), R.ruleLabel(old), made.from, made.op, made.value, M.ruleSources(made).map(p => p.name),"
" R.applyRule(made, 7, { 지반: '암', 높이: 2 }).value, R.applyRule(made, 7, { 지반: '암', 높이: 5 }).why]);"
)
conds, label, first_from, first_op, first_value, sources, ok, blocked = got
assert conds == {
"conds": [{"from": {"box": "b1", "name": "지반"}, "op": "=", "value": "토사"}],
"join": "그리고",
}
assert label == "지반 = 토사 일 때만"
assert (first_from, first_op, first_value) == ({"box": "b1", "name": "지반"}, "≠", "암")
assert sources == ["지반", "높이"]
assert (ok, blocked) == (7, "통과 안 됨")
def test_값_목록() -> None:
got = _run(
PIPE
+ "lg = M.updateBox(lg, dia.id, { input: { ...lg.boxes[0].input, variables: [...lg.boxes[0].input.variables,"
" { name: '지반', type: 'choice', unit: '-', choices: ['토사', '암'], example: '토사' }] } });\n"
"lg = M.updateBox(lg, mat.id, { input: { ...lg.boxes[1].input, elements: lg.boxes[1].input.elements.map((e, i) => i === 1 ? { ...e, spec: '12mm' } : e) } });\n"
"r = add(lg, 'input'); lg = r.lg; const tb = r.box;\n"
"lg = M.updateBox(lg, tb.id, { input: { mode: 'group', group: '소요량', elements: [], variables: [], table: { key: 'QF1' } } });\n"
"const vals = { [tb.id]: { 표: [['돌', '석공'], ['깬돌', 0.2], ['견치돌', 0.3], ['깬돌', 0.25]] } };\n"
"const v = (box, name, opts) => M.valueChoices(lg, { box, name }, opts);\n"
"out([v(dia.id, '지반'), v(dia.id, '관경'), v(mat.id, '거푸집 자리'), v(mat.id, '흄관 단가'),"
" v(tb.id, '표', { col: '돌', values: vals }), v(tb.id, '표', { col: '없음', values: vals }), v(tb.id, '표'),"
" v(f.id, '재료값'), v('없는 상자', '지반')]);"
)
assert got == [["토사", "암"], None, ["12mm"], None, ["깬돌", "견치돌"], None, None, None, None]
def test_조건_여러_줄_겹침_안맞음() -> None:
got = _run(
SPLIT
+ "const both = (op1, v1) => M.makeGate([{ from: { box: v.id, name: '지반' }, op: op1, value: v1 }, { from: { box: v.id, name: '높이' }, op: '≥', value: 3 }]);\n"
"let g = M.setRule(lg, lg.links[0].id, both('=', '토사')).lg;\n"
"g = M.setRule(g, lg.links[1].id, both('≠', '암')).lg;\n"
"const over = E.breaks(g, V('토사')).filter(b => b.level === '문제').map(b => b.kind);\n"
"const none = E.breaks(g, V('암', 2)).filter(b => b.level === '문제').map(b => b.kind);\n"
"out([over, none]);"
)
over, none = got
assert "조건 겹침" in over
assert "조건 안 맞음" in none