Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -440,6 +440,9 @@ def _sheet_of(box: dict, arrived: dict, given: dict) -> dict:
|
||||
return sheet
|
||||
|
||||
|
||||
REST = "앞 조건이 안 맞아 쉬는 갈래"
|
||||
|
||||
|
||||
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 ""]}
|
||||
@@ -448,6 +451,7 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N
|
||||
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 과 같은 뜻
|
||||
cost: dict[str, Any] = {**dict.fromkeys(COSTS, Decimal(0)), "값들": {}}
|
||||
|
||||
def through(ln: dict) -> Any:
|
||||
@@ -464,12 +468,16 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N
|
||||
got, why = apply_rule(rule, v, conds)
|
||||
if why:
|
||||
blocked.add(ln["to"].get("box"))
|
||||
if why == "통과 안 됨":
|
||||
resting.add(ln["to"].get("box"))
|
||||
text = f"「{name(src.get('box'))}」 → 「{name(ln['to'].get('box'))}」 {why}"
|
||||
kind = "오류" if why in ("표에 여럿 맞음", "오류") else why
|
||||
to = ln["to"]
|
||||
breaks.append(
|
||||
{
|
||||
"kind": kind,
|
||||
# 무게 — 통과 안 됨 = 쉼 · 나머지 = 문제(Eval CfBreak.level 과 같음)
|
||||
"level": "쉼" if kind == "통과 안 됨" else "문제",
|
||||
"box": to.get("box"),
|
||||
"port": to.get("name"),
|
||||
"link": ln.get("id"),
|
||||
@@ -481,9 +489,32 @@ def run(lg: dict, values: dict, load: Loader, local: list[dict], stack: list | N
|
||||
return None if why else got
|
||||
|
||||
def arrive(box: dict) -> dict:
|
||||
got = {
|
||||
ln["to"].get("name"): through(ln) for ln in links if ln["to"].get("box") == box["id"]
|
||||
}
|
||||
"""받는 값 — 쉬는 갈래 빈 값은 「쉼」 줄만 · 선이 모두 쉬면 상자도 쉼(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
|
||||
|
||||
|
||||
@@ -69,11 +69,15 @@ export function evalSafe(lg: CfLogic, o: DetailOptions = {}): CfEvalResult | nul
|
||||
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" ? String(Number(v.toPrecision(12))) : String(v);
|
||||
return typeof v === "number" ? commas(v) : String(v);
|
||||
}
|
||||
|
||||
export function unitOf(lg: CfLogic, p: CfPort): string {
|
||||
|
||||
@@ -410,3 +410,99 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,13 @@ import {
|
||||
clampZoom,
|
||||
fitView,
|
||||
KIND_COLOR,
|
||||
depEnd,
|
||||
depInTrace,
|
||||
depPath,
|
||||
follow,
|
||||
depLines,
|
||||
isRest,
|
||||
restParts,
|
||||
linkLabel,
|
||||
midOf,
|
||||
pillsOf,
|
||||
@@ -76,7 +83,15 @@ export function renderDiagram(
|
||||
states.set(host, st);
|
||||
const values = opts.values ?? {};
|
||||
const labels = opts.labels ?? {};
|
||||
const breaks = opts.breaks ?? [];
|
||||
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 });
|
||||
@@ -146,6 +161,7 @@ export function renderDiagram(
|
||||
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: {
|
||||
@@ -210,7 +226,8 @@ export function renderDiagram(
|
||||
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))),
|
||||
(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",
|
||||
@@ -400,7 +417,7 @@ export function renderDiagram(
|
||||
}),
|
||||
],
|
||||
}),
|
||||
lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }),
|
||||
view,
|
||||
],
|
||||
});
|
||||
root.addEventListener("keydown", (ev) => {
|
||||
@@ -438,19 +455,27 @@ export function renderDiagram(
|
||||
);
|
||||
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"): { x: number; y: number } | null => {
|
||||
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();
|
||||
return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2);
|
||||
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,
|
||||
@@ -462,6 +487,43 @@ export function renderDiagram(
|
||||
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",
|
||||
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");
|
||||
@@ -474,8 +536,9 @@ export function renderDiagram(
|
||||
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}`,
|
||||
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(","),
|
||||
});
|
||||
@@ -483,33 +546,19 @@ export function renderDiagram(
|
||||
add(g, "path", { d, class: "m02-dfdg__wire" });
|
||||
const mid = midOf(a, b);
|
||||
const rule = labels[l.id] ?? linkLabel(l);
|
||||
if (rule) {
|
||||
const tag = add(g, "g", { class: "m02-dfdg__rule" });
|
||||
const box = add(tag, "rect", { rx: "4", ry: "4" });
|
||||
const text = add(tag, "text", {
|
||||
x: mid.x.toFixed(1),
|
||||
y: mid.y.toFixed(1),
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
text.textContent = rule;
|
||||
const w = (text.getComputedTextLength?.() || rule.length * 7) + 10;
|
||||
Object.entries({ x: mid.x - w / 2, y: mid.y - 9, width: w, height: 18 }).forEach(([k, v]) =>
|
||||
box.setAttribute(k, v.toFixed(1)),
|
||||
);
|
||||
}
|
||||
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 why = broken.map((k) => k.text).join(" · ");
|
||||
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: (mid.y - (rule ? 18 : 0)).toFixed(1),
|
||||
r: "8",
|
||||
});
|
||||
add(badge, "circle", { cx: mid.x.toFixed(1), cy, r: "8" });
|
||||
const t = add(badge, "text", {
|
||||
x: mid.x.toFixed(1),
|
||||
y: (mid.y - (rule ? 18 : 0)).toFixed(1),
|
||||
y: cy,
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
@@ -518,10 +567,48 @@ export function renderDiagram(
|
||||
}
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const why = broken.map((k) => k.text).join(" · ");
|
||||
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();
|
||||
@@ -534,34 +621,6 @@ export function renderDiagram(
|
||||
if (view.isConnected) firstFit();
|
||||
|
||||
/* 끌기 셋 — 상자 · 판 · 선 */
|
||||
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);
|
||||
}
|
||||
|
||||
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
|
||||
ev.preventDefault(); // 끌면서 글자가 잡히지 않게
|
||||
const start = toWorld(ev.clientX, ev.clientY);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Diagram_Parts.ts
|
||||
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
|
||||
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기. 그리기는 `M02_DF_Diagram.ts`.
|
||||
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기 · 기댐 선 · 끌기 손잡이. 그리기는 `M02_DF_Diagram.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
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";
|
||||
@@ -63,6 +65,13 @@ export const TEXT = {
|
||||
],
|
||||
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;
|
||||
|
||||
@@ -231,3 +240,123 @@ export const midOf = (
|
||||
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 };
|
||||
}
|
||||
|
||||
/* ── 끌기 손잡이 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 누른 뒤 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);
|
||||
}
|
||||
|
||||
@@ -254,6 +254,10 @@
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.m02-df__detail-row[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-df__detail-row h4 {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
|
||||
@@ -89,6 +89,8 @@ const TEXT = {
|
||||
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"],
|
||||
@@ -307,6 +309,17 @@ function openDataFlow(): void {
|
||||
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");
|
||||
@@ -315,7 +328,7 @@ function openDataFlow(): void {
|
||||
const linkHost = el("div");
|
||||
const pillHost = el("div");
|
||||
const detail = detailFrame();
|
||||
detail.settings.append(colorRow);
|
||||
detail.settings.append(boxName.root, colorRow);
|
||||
detail.body.append(inHost, funcHost, outHost, linkHost, pillHost);
|
||||
|
||||
const evalNow = (): CfEvalResult => {
|
||||
@@ -341,10 +354,12 @@ function openDataFlow(): void {
|
||||
|
||||
/** 입력 값 다시 받기 — 물음이 바뀐 때만 · 같은 조작의 뒤끝이라 기록을 더 쌓지 않음 */
|
||||
const refreshValues = (): void => {
|
||||
const sig = JSON.stringify(queriesOf(lg).map((a) => a.q));
|
||||
// 조건 찾기 재료는 이어진 알약 값(지금 셈)을 함께 물음 — 값이 바뀌면 다시 받음
|
||||
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).then(
|
||||
void fetchValues(M.api.cfValues, lg, pill, M.model.elementPort).then(
|
||||
(got) => {
|
||||
if (sig !== valueSig) return;
|
||||
values = got.values;
|
||||
@@ -410,6 +425,7 @@ function openDataFlow(): void {
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
value: lg.name,
|
||||
compact: true,
|
||||
onInput: (v) => edit({ ...lg, name: v }),
|
||||
});
|
||||
name.root.classList.add("m02-df__name");
|
||||
@@ -418,6 +434,7 @@ function openDataFlow(): void {
|
||||
label: te("Unit"),
|
||||
options: unitList.map((u) => ({ value: u, text: u.replace(/^원\//, "") })),
|
||||
value: lg.resultUnit,
|
||||
compact: true,
|
||||
onChange: (v) => edit({ ...lg, resultUnit: v }),
|
||||
});
|
||||
|
||||
@@ -457,7 +474,11 @@ function openDataFlow(): void {
|
||||
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")
|
||||
@@ -596,7 +617,7 @@ function openDataFlow(): void {
|
||||
title: te("Standards"),
|
||||
closeLabel: te("Close"),
|
||||
mount: (host: HTMLElement) =>
|
||||
M.std.renderStandards(host, { change: () => redraw() }),
|
||||
M.std.renderStandards(host, { change: () => redraw() }, { title: false }),
|
||||
}),
|
||||
}),
|
||||
undo,
|
||||
@@ -638,6 +659,7 @@ function part(title: string, className: string, children: HTMLElement[]): HTMLEl
|
||||
});
|
||||
}
|
||||
|
||||
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
return createButton({ label, variant: "filled", size: "sm", onClick });
|
||||
/** 보통 크기 단추 — 칸 폭으로 늘어나지 않게 감쌈 */
|
||||
function bigButton(label: string, onClick: () => void): HTMLElement {
|
||||
return el("div", { children: [createButton({ label, variant: "filled", size: "sm", onClick })] });
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 type { CfBreak, CfValues } from "./M02_DF_Eval";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
import type { CfElementRef, CfLogic, CfPort } from "./M02_DF_Model";
|
||||
|
||||
const TEXT = {
|
||||
Detail: ["상세 내용", "Details"],
|
||||
@@ -169,14 +169,32 @@ export async function unitChoices(): Promise<string[]> {
|
||||
return units.length ? units : ["원/㎡"];
|
||||
}
|
||||
|
||||
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
|
||||
export function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
|
||||
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 재료 자리(알약 = 자리 이름 · 조건 찾기는 이어진 알약 값을
|
||||
* pill 로 읽어 넘김) · 표(알약 「표」) · 사용자 요소 · 변수는 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 || inp.mode !== "group" || !inp.group) return [];
|
||||
const group = inp.group;
|
||||
const slots = inp.elements
|
||||
.filter((e) => group === "재료" && e.mode && e.mode !== "fixed")
|
||||
.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 = pill(e.rule.from);
|
||||
return { q, box: b.id, port };
|
||||
});
|
||||
const els = inp.elements
|
||||
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
|
||||
.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];
|
||||
@@ -187,7 +205,7 @@ export function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string
|
||||
const table = inp.table
|
||||
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
|
||||
: [];
|
||||
return [...els, ...table];
|
||||
return [...slots, ...els, ...table];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -195,8 +213,10 @@ export function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string
|
||||
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 asks = queriesOf(lg);
|
||||
const asks = queriesOf(lg, pill, elementPort);
|
||||
if (!asks.length) return { values: {}, errors: [] };
|
||||
const answers = await ask(
|
||||
asks.map((a) => a.q),
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface CfBreak {
|
||||
| "조건 겹침"
|
||||
| "조건 안 맞음"
|
||||
| "오류";
|
||||
/** 무게 — 「쉼」 = 조건이 안 맞아 쉬는 갈래(그 아래로 번진 빈 값 포함) · 「문제」 = 고쳐야 할 끊김 */
|
||||
level: "문제" | "쉼";
|
||||
box: string;
|
||||
port?: string;
|
||||
link?: string;
|
||||
@@ -112,6 +114,11 @@ const TEXT = {
|
||||
"「{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",
|
||||
@@ -251,6 +258,8 @@ export function evalLogic(
|
||||
const passed = new Map<string, boolean>();
|
||||
/** 선 규칙에서 막힌 상자 — 그 함수는 셈하지 않고 빈 값을 넘김(갈래가 끊김) */
|
||||
const blocked = new Set<string>();
|
||||
/** 조건이 안 맞아 쉬는 상자 — 그 아래로 번짐 */
|
||||
const resting = new Set<string>();
|
||||
/** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */
|
||||
const through = (l: CfLink): CfValue => {
|
||||
const v = valueOf(l.from.box, l.from.name);
|
||||
@@ -262,15 +271,17 @@ export function evalLogic(
|
||||
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 === "통과 안 됨")
|
||||
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) {
|
||||
} else if (res.why) {
|
||||
const kind = res.why === "표에 없는 조건" ? "표에 없는 조건" : "오류";
|
||||
const key =
|
||||
res.why === "표에 없는 조건"
|
||||
@@ -279,16 +290,39 @@ export function evalLogic(
|
||||
? "ManyRows"
|
||||
: "RuleError";
|
||||
const text = tl(key, fill);
|
||||
found.push({ kind, box: l.to.box, port: l.to.name, link: l.id, text });
|
||||
found.push({ kind, level: "문제", box: l.to.box, port: l.to.name, link: l.id, text });
|
||||
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 got = Object.fromEntries(
|
||||
lg.links.filter((l) => l.to.box === box.id).map((l) => [l.to.name, through(l)]),
|
||||
);
|
||||
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;
|
||||
};
|
||||
@@ -303,7 +337,13 @@ export function evalLogic(
|
||||
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: "맞는 재료 없음", box: b.id, port: elementPort(e), text });
|
||||
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);
|
||||
@@ -383,6 +423,7 @@ function gates(
|
||||
const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill);
|
||||
found.push({
|
||||
kind: n > 1 ? "조건 겹침" : "조건 안 맞음",
|
||||
level: "문제",
|
||||
box: g.from.box,
|
||||
port: g.from.name,
|
||||
text,
|
||||
@@ -402,6 +443,7 @@ export function breaks(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions =
|
||||
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 }),
|
||||
|
||||
@@ -21,12 +21,10 @@ import {
|
||||
type SubInfo,
|
||||
} from "../../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { cfPlain, cfUserElementAdd, cfUserElements, cfValues } from "./M02_DF_Api";
|
||||
import type { CfBox, CfElementRef, CfGroup, CfLogic } from "./M02_DF_Model";
|
||||
import type { CfBox, CfGroup, CfLogic } from "./M02_DF_Model";
|
||||
import {
|
||||
GROUPS,
|
||||
M01_OF,
|
||||
MACHINE_SEP,
|
||||
USER_PREFIX,
|
||||
addElement,
|
||||
elementOf,
|
||||
machineFields,
|
||||
@@ -243,39 +241,10 @@ function drawChosen(
|
||||
if (node && a.ok && a.value !== null && a.value !== undefined)
|
||||
node.textContent = typeof a.value === "number" ? fmt(a.value) : String(a.value);
|
||||
}),
|
||||
// Api 가 아직 값을 못 주면 M01 줄에서 바로 읽어 보임(값 칸만 — 요소는 그대로)
|
||||
() =>
|
||||
els.forEach(
|
||||
(e) =>
|
||||
void m01Value(group, e).then((v) => {
|
||||
const node = values.get(e.ref);
|
||||
if (node && v !== null) node.textContent = fmt(v);
|
||||
}),
|
||||
),
|
||||
() => undefined, // 값을 못 받아도 요소는 넣을 수 있음 — 값 칸만 「—」
|
||||
);
|
||||
}
|
||||
|
||||
/** 요소 한 개의 값을 M01 줄에서 읽음 — 이름으로 찾아 키가 같은 줄 · 못 찾으면 null */
|
||||
async function m01Value(group: CfGroup, e: CfElementRef): Promise<number | null> {
|
||||
if (e.ref.startsWith(USER_PREFIX)) return null;
|
||||
try {
|
||||
const [key, field] = e.ref.split(MACHINE_SEP);
|
||||
const page = await fetchRows(
|
||||
await fileOf(group),
|
||||
1,
|
||||
30,
|
||||
e.name.split(" · ")[0].split(" ")[0],
|
||||
false,
|
||||
"",
|
||||
"",
|
||||
);
|
||||
const row = (page.rows as M01Row[]).find((r) => r["키"] === key);
|
||||
return row ? valueOf(group, row, field || "가격") : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** M01 줄 찾기 — 구분 · 상세구분 · 이름 · 내가 만든 것도 함께 */
|
||||
async function drawFind(
|
||||
host: HTMLElement,
|
||||
@@ -406,7 +375,6 @@ async function drawFind(
|
||||
list,
|
||||
);
|
||||
void load();
|
||||
void USER_PREFIX;
|
||||
}
|
||||
|
||||
/** 새 요소 — M01 표 「추가」 화면 꼴(이름 · 규격 · 단위 · 값) · 마스터에 안 쓰고 사용자 요소로 */
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Material.ts
|
||||
* 입력 판 — 재료 요소 방식 셋(고정 · 선택 자리 · 조건 찾기) · 자재 찾기(띄어쓰기 AND · 규격까지 · 쪽 넘기기 · 전체 수)
|
||||
* 서버 `/api/m02/df/materials` 를 먼저 부르고 아직 없으면 M01 `/pick?kind=price` 로 같은 모양을 만듦
|
||||
* 서버 `/api/m02/df/materials`(cfMaterials)
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchFiles, fetchSubs, type SubInfo } from "../../M01_MasterData/M01_MasterData_Api_Fetch";
|
||||
import { cfValues } from "./M02_DF_Api";
|
||||
import { cfMaterials, cfValues } from "./M02_DF_Api";
|
||||
import type { CfElementRef, CfLogic, CfMaterialMode, CfMaterialRule } from "./M02_DF_Model";
|
||||
import {
|
||||
catchNote,
|
||||
@@ -101,48 +100,9 @@ export interface MatPage {
|
||||
items: MatItem[];
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/** 서버가 없으면 M01 값 줄에서 같은 모양으로 — 띄어쓰기 AND(이름 + 규격) · 쪽은 앞에서 잘라 줌 */
|
||||
async function fallback(q: MatQuery): Promise<MatPage> {
|
||||
const r = await getJson<{ items: Record<string, unknown>[] }>(
|
||||
`/m01/pick?${new URLSearchParams({ kind: "price", q: q.q, limit: "200" })}`,
|
||||
);
|
||||
const rows = r.items.filter((i) => !q.sub || i["구분"] === q.sub);
|
||||
const items = rows.map((i): MatItem => ({
|
||||
ref: String(i["ref"]),
|
||||
name: String(i["이름"] ?? ""),
|
||||
spec: String(i["규격"] ?? ""),
|
||||
unit: String(i["단위"] ?? ""),
|
||||
price: typeof i["값"] === "number" ? i["값"] : null,
|
||||
구분: String(i["구분"] ?? ""),
|
||||
}));
|
||||
const from = (q.page - 1) * q.size;
|
||||
return {
|
||||
total: items.length,
|
||||
page: q.page,
|
||||
size: q.size,
|
||||
items: items.slice(from, from + q.size),
|
||||
};
|
||||
}
|
||||
|
||||
/** 서버 자재 찾기 — 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기 · 전체 수 */
|
||||
export async function searchMaterials(q: MatQuery): Promise<MatPage> {
|
||||
const params = new URLSearchParams({
|
||||
q: q.q,
|
||||
page: String(q.page),
|
||||
size: String(q.size),
|
||||
sub: q.sub ?? "",
|
||||
detail: q.detail ?? "",
|
||||
});
|
||||
try {
|
||||
return await getJson<MatPage>(`/m02/df/materials?${params}`);
|
||||
} catch {
|
||||
return fallback(q);
|
||||
}
|
||||
return cfMaterials({ q: q.q, sub: q.sub, detail: q.detail, page: q.page, size: q.size });
|
||||
}
|
||||
|
||||
let subsCache: SubInfo[] | null = null;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./M02_DF_Model";
|
||||
import {
|
||||
GATE_OPS,
|
||||
commas,
|
||||
SCALE_OPS,
|
||||
evalSafe,
|
||||
labelOf,
|
||||
@@ -76,6 +77,7 @@ const TEXT = {
|
||||
],
|
||||
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
|
||||
Count: ["선", "Line"],
|
||||
Range: ["{n} (범위)", "{n} (range)"],
|
||||
} as const;
|
||||
|
||||
const tk = (key: keyof typeof TEXT, n?: number | string): string =>
|
||||
@@ -127,6 +129,7 @@ function select(
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 숫자 칸 — 보일 때만 천 단위 쉼표 · 고치는 동안은 쉼표 없이 */
|
||||
function numberBox(
|
||||
ctx: Ctx,
|
||||
value: number | string,
|
||||
@@ -135,10 +138,13 @@ function numberBox(
|
||||
): HTMLElement {
|
||||
const i = el("input", {
|
||||
className: "ui-input ui-input--sm m02-dflink-num",
|
||||
attrs: { type: "text", "aria-label": aria },
|
||||
attrs: { type: "text", inputmode: "decimal", "aria-label": aria },
|
||||
});
|
||||
i.value = String(value);
|
||||
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);
|
||||
@@ -165,17 +171,26 @@ const row = (...children: (HTMLElement | string)[]): HTMLElement =>
|
||||
/* ── 규칙마다 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTMLElement[] {
|
||||
const cols = [
|
||||
...new Set([...ctx.headers, ...rule.conds.map((c) => c.col), rule.col].filter(Boolean)),
|
||||
];
|
||||
// 꺼낼 칸 = 표 머리 칸만
|
||||
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,
|
||||
colOpts,
|
||||
condOpts,
|
||||
c.col,
|
||||
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }),
|
||||
tk("Col"),
|
||||
@@ -206,9 +221,11 @@ function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTML
|
||||
label: tk("AddCond"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !pills.length || !cols.length,
|
||||
disabled: !pills.length || !condOpts.length,
|
||||
onClick: () =>
|
||||
put({ conds: [...rule.conds, { col: cols[0], from: fromKey(pills[0].value) }] }),
|
||||
put({
|
||||
conds: [...rule.conds, { col: condOpts[0].value, from: fromKey(pills[0].value) }],
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -27,12 +27,12 @@ const TEXT = {
|
||||
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"],
|
||||
Calc: ["계산 중이 아님", "Not calculated"],
|
||||
Cant: ["지금은 바꿀 수 없음", "Cannot change this now"],
|
||||
Round: ["끝수", "Rounding"],
|
||||
RoundPlaces: ["자리", "Places"],
|
||||
RoundMethod: ["방법", "Method"],
|
||||
@@ -43,10 +43,7 @@ const TEXT = {
|
||||
Apply: ["적용", "Apply"],
|
||||
RoundReset: ["기본으로", "Back to default"],
|
||||
RoundUser: ["고른 끝수", "Chosen rounding"],
|
||||
PlainHint: [
|
||||
"원가 합계에 안 넣고 값으로만 보여요",
|
||||
"Shown as a value, not added to the cost total",
|
||||
],
|
||||
PlainHint: ["원가 합계에 안 넣고 값으로만 보임", "Shown as a value, not added to the cost total"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
@@ -128,7 +125,7 @@ export function renderOutputPanel(
|
||||
el("dt", { text: to("Value") }),
|
||||
el("dd", {
|
||||
className: "m02-dfout__num",
|
||||
text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`,
|
||||
text: `${showValue(value)}${unit && unit !== "-" ? ` ${unit}` : ""}`,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -47,6 +47,7 @@ const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement =>
|
||||
children: [el("span", { className: "m02-dflink-label", text: label }), ...value],
|
||||
});
|
||||
|
||||
/** 한 줄 = 선 하나면 「보낸 알약 → (규칙 글) → 받는 알약」 · 함수 안 걸음이면 알약 하나 */
|
||||
function steps(
|
||||
lg: CfLogic,
|
||||
list: CfStep[],
|
||||
@@ -55,21 +56,30 @@ function steps(
|
||||
onBox?: (id: string) => void,
|
||||
): HTMLElement {
|
||||
if (!list.length) return el("p", { className: "m02-dflink-note", 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 parts: (HTMLElement | string)[] = [
|
||||
arrow,
|
||||
onBox
|
||||
? createButton({
|
||||
label: portText(lg, { box: s.box, name: s.port }),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => onBox(s.box),
|
||||
})
|
||||
: portText(lg, { box: s.box, name: s.port }),
|
||||
];
|
||||
if (s.rule) parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(s.rule) }));
|
||||
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: "m02-dfpill-rule", 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 });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -88,3 +88,17 @@ export function roundText(r: CfRound): string {
|
||||
const step = r.places > 0 ? Number((10 ** -r.places).toFixed(r.places)) : 1;
|
||||
return `${step}원 미만 ${r.method}`;
|
||||
}
|
||||
|
||||
/** 출력 기본 끝수(표를 읽어 오기 전에는 금액란 0.1원 미만 버림) — 화면이 바로 쓰는 값 */
|
||||
let itemNow: CfRound = { places: 1, method: "버림" };
|
||||
void defaultItemRound().then((r) => (itemNow = r));
|
||||
export const itemRoundNow = (): CfRound => itemNow;
|
||||
|
||||
/** 끝수대로 자른 값을 그 자리 수만큼 보임(3,216.6) — 끝수가 없으면 소수 여섯째 자리까지 */
|
||||
export function showRounded(v: number, r?: CfRound): string {
|
||||
if (!r) return v.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
return roundBy(r, v).toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: Math.max(0, r.places),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
gap: var(--spacing-8, 8px);
|
||||
}
|
||||
|
||||
.m02-df-std__head h3 {
|
||||
/* 첫 칸(제목 · 제목이 없으면 상태 글)이 단추를 오른쪽으로 밂 */
|
||||
.m02-df-std__head > :first-child {
|
||||
margin: 0 auto 0 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침)
|
||||
* 서버 = `M02_DF_Api` cfStandards · cfStandardsSave · cfStandardsReset(`/api/m02/df/standards`) → {unit, money, saved} ·
|
||||
* 고친 것은 `_data`(마스터에 안 씀)
|
||||
* `renderStandards(host, on)` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로)
|
||||
* `renderStandards(host, on, { title })` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로)
|
||||
* `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것)
|
||||
* 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
@@ -185,8 +185,17 @@ function parseCell(key: string, text: string): string | number | null | undefine
|
||||
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 = {}): void {
|
||||
export function renderStandards(
|
||||
host: HTMLElement,
|
||||
on: StandardsOn = {},
|
||||
opts: StandardsOpts = {},
|
||||
): void {
|
||||
let work = clone(current);
|
||||
let dirty = false;
|
||||
const state = el("span", { className: "m02-df-std__state" });
|
||||
@@ -334,7 +343,12 @@ export function renderStandards(host: HTMLElement, on: StandardsOn = {}): void {
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-df-std__head",
|
||||
children: [el("h3", { text: tx("Title") }), state, reset, save],
|
||||
children: [
|
||||
...(opts.title === false ? [] : [el("h3", { text: tx("Title") })]),
|
||||
state,
|
||||
reset,
|
||||
save,
|
||||
],
|
||||
}),
|
||||
el("p", { className: "m02-df-std__hint", text: `${tx("Source")} · ${tx("Hint")}` }),
|
||||
body,
|
||||
|
||||
@@ -12,7 +12,7 @@ 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, roundBy, roundText } from "./M02_DF_Round";
|
||||
import { defaultTotalRound, itemRoundNow, roundBy, roundText, showRounded } from "./M02_DF_Round";
|
||||
import { renderVerify, type CfSel } from "./M02_DF_Verify";
|
||||
import "./M02_DF_Summary.css";
|
||||
|
||||
@@ -49,6 +49,12 @@ const costText = (c: string): string => (c === "값" ? ts("Plain3") : c);
|
||||
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,
|
||||
@@ -89,7 +95,7 @@ export function renderSummary(
|
||||
...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []),
|
||||
el("td", {
|
||||
className: "m02-dfsum__num",
|
||||
text: showValue(result.outputs[b.id], isCost),
|
||||
text: shown(b, result.outputs[b.id], isCost),
|
||||
}),
|
||||
el("td", { text: b.output?.unit ?? b.unit }),
|
||||
],
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* M02_DF_UnitPick.ts
|
||||
* 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음) · 「-」 = 없음
|
||||
* 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원
|
||||
* 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 처음 값 `_standard/단위표준.json`) — 다른 판도 이것을 씀
|
||||
* 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 서버 /standards) — 다른 판도 이것을 씀
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { cfStandards } from "./M02_DF_Api";
|
||||
import "./M02_DF_UnitPick.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -36,9 +37,9 @@ interface StandardRow {
|
||||
"단위수량 소수자리"?: number | null;
|
||||
}
|
||||
|
||||
/** 처음 값 — 번들에 든 표준 표(단위표준.json) */
|
||||
const bundled: UnitSource = async () => {
|
||||
const table = (await import("./_standard/단위표준.json")).default as { 줄: StandardRow[] };
|
||||
/** 서버 표준 표(/standards) — 고친 표가 있으면 그것, 없으면 처음 값 */
|
||||
const fromServer: UnitSource = async () => {
|
||||
const table = (await cfStandards()).unit as { 줄: StandardRow[] };
|
||||
const units = table.줄
|
||||
.flatMap((r) => [r["규격 단위"], r["단위수량 단위"]])
|
||||
.filter((u): u is string => !!u);
|
||||
@@ -52,13 +53,13 @@ const bundled: UnitSource = async () => {
|
||||
const places = Object.fromEntries(
|
||||
Object.entries(count).map(([u, c]) => [
|
||||
u,
|
||||
Number(Object.entries(c).sort((a, b) => b[1] - a[1])[0][0]),
|
||||
Number(Object.entries(c).sort((x, y) => y[1] - x[1])[0][0]),
|
||||
]),
|
||||
);
|
||||
return { units, places };
|
||||
};
|
||||
|
||||
let source: UnitSource = bundled;
|
||||
let source: UnitSource = fromServer;
|
||||
let cache: Promise<UnitStandard> | null = null;
|
||||
|
||||
/** 표준 표 읽는 길 바꾸기(서버에서 고친 표를 읽을 때) — 목록을 다시 받음 */
|
||||
@@ -67,15 +68,20 @@ export function setUnitSource(next: UnitSource): void {
|
||||
cache = null;
|
||||
}
|
||||
|
||||
/** 표준 단위 + 원 — 같은 글은 한 번 · 「없음」 은 뺌 */
|
||||
export function loadStandard(): Promise<UnitStandard> {
|
||||
/** 표준 단위 + 원 — 같은 글은 한 번 · 「없음」 은 뺌 · 서버를 못 읽으면 원만(다음에 다시 읽음) */
|
||||
export async function loadStandard(): Promise<UnitStandard> {
|
||||
cache ??= source().then((std) => {
|
||||
const set = new Set([...std.units, "원"]);
|
||||
set.delete("없음");
|
||||
set.delete(NO_UNIT);
|
||||
return { units: [...set], places: std.places };
|
||||
});
|
||||
return cache;
|
||||
try {
|
||||
return await cache;
|
||||
} catch {
|
||||
cache = null;
|
||||
return { units: ["원"], places: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/** 단위를 고르면 표준 표에서 소수자리 제안 — 없으면 null */
|
||||
|
||||
@@ -116,3 +116,15 @@
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 쉬는 갈래 — 문제가 아니라 회색으로 */
|
||||
.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);
|
||||
}
|
||||
|
||||
@@ -23,9 +23,14 @@ import "./M02_DF_Verify.css";
|
||||
|
||||
const TEXT = {
|
||||
Breaks: ["끊긴 곳", "Broken spots"],
|
||||
BreaksHint: ["누르면 그 상자 · 선을 골라요", "Click to select the box or line"],
|
||||
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"],
|
||||
DesignHint: ["여기서 바꾸면 그 상자도 바뀜", "Changing here changes the box too"],
|
||||
Vars: ["변수 예시값", "Variable samples"],
|
||||
Slots: ["재료 선택 자리", "Material choice"],
|
||||
Rules: ["재료 조건 자리", "Material by condition"],
|
||||
@@ -37,23 +42,23 @@ const TEXT = {
|
||||
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"],
|
||||
NoHit: ["찾은 재료 없음", "No material found"],
|
||||
NoDefault: ["아직 고르지 않음", "Nothing chosen yet"],
|
||||
Price: ["단가", "Unit price"],
|
||||
Won: ["원", "KRW"],
|
||||
Loading: ["찾는 중", "Searching"],
|
||||
NeedValue: ["조건 값이 아직 없어요", "The condition value is not there yet"],
|
||||
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"],
|
||||
Pick: ["선택", "Choose"],
|
||||
Bad: ["숫자를 넣어야 함", "Enter a number"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
Cant: ["지금은 바꿀 수 없음", "Cannot change this now"],
|
||||
OwnSample: ["비우면 그 로직 예시값", "Empty = the logic's own sample"],
|
||||
OtherLogic: [
|
||||
"이어 주지 않아서 그 로직의 예시값을 써요",
|
||||
"이어 주지 않아 그 로직의 예시값을 씀",
|
||||
"Not linked — the logic's own sample is used",
|
||||
],
|
||||
} as const;
|
||||
@@ -333,33 +338,45 @@ export function renderVerify(
|
||||
|
||||
/* 끊긴 곳 */
|
||||
const spots = breaks(lg, opts.values ?? {}, { logics: opts.logics });
|
||||
const spotBlock = spots.length
|
||||
? [
|
||||
el("div", {
|
||||
className: "m02-dfver__block m02-dfver__breaks",
|
||||
const spotList = (list: typeof spots, hint: TextKey): HTMLElement[] => [
|
||||
el("ul", {
|
||||
className: "m02-dfver__list",
|
||||
children: list.map((s) =>
|
||||
el("li", {
|
||||
children: [
|
||||
el("strong", { text: tv("Breaks") }),
|
||||
el("ul", {
|
||||
className: "m02-dfver__list",
|
||||
children: spots.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 }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
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: "m02-dfver__muted", text: tv("BreaksHint") }),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
),
|
||||
}),
|
||||
el("p", { className: "m02-dfver__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: "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);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "item3",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "면적산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"면적번": "SA-000003"
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"판": 1,
|
||||
"줄": [
|
||||
{
|
||||
"로직": "GC000297",
|
||||
"입력": "형식",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GC000297",
|
||||
"입력": "두께",
|
||||
"자리": "면적",
|
||||
"알약": "입력|GC000297|두께"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "구분",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "믹서",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "지역",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "재료배합",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "골재최대치수",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "수중",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "모래상태",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "자갈상태",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "모래단위중량",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "자갈단위중량",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000156",
|
||||
"입력": "자재지역",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000162",
|
||||
"입력": "포장두께",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000162",
|
||||
"입력": "운반",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000162",
|
||||
"입력": "운반감률",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000162",
|
||||
"입력": "공급감률",
|
||||
"자리": "선택"
|
||||
},
|
||||
{
|
||||
"로직": "GF000162",
|
||||
"입력": "종단경사",
|
||||
"자리": "선택"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"종류": "통합문서",
|
||||
"판": 1,
|
||||
"열": "item3",
|
||||
"서식": [
|
||||
{}
|
||||
],
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"이름": "재료산출",
|
||||
"칸": {}
|
||||
}
|
||||
],
|
||||
"활성": "s1",
|
||||
"재료번": "SM-000003"
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"양식": "구조물도면",
|
||||
"종류": "structure",
|
||||
"판": 1,
|
||||
"열": "item3",
|
||||
"도면": {
|
||||
"format": 6,
|
||||
"entities": [],
|
||||
"layers": [
|
||||
{
|
||||
"id": "기본",
|
||||
"name": "기본",
|
||||
"isVisible": true,
|
||||
"isLocked": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"도번": "SD-000003"
|
||||
}
|
||||
@@ -53,7 +53,7 @@
|
||||
"id": "item1",
|
||||
"단위": "m",
|
||||
"꼴": "수",
|
||||
"일위대가": null,
|
||||
"일위대가": "UA000006",
|
||||
"자재조합": null,
|
||||
"경로": [
|
||||
"기슭막이",
|
||||
@@ -95,6 +95,25 @@
|
||||
"ss"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "item3",
|
||||
"단위": null,
|
||||
"꼴": "수",
|
||||
"일위대가": null,
|
||||
"자재조합": null,
|
||||
"경로": [
|
||||
"기슭막이",
|
||||
"ㄴㄴ"
|
||||
],
|
||||
"머리": [
|
||||
"기슭막이",
|
||||
"ㄴㄴ",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "memo",
|
||||
"머리": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"그룹": "구조물집계표조합",
|
||||
"판": 1,
|
||||
"비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키(마스터 SS · 회사 SC{회사}- · 개인 SP{사용자}- + 6자리)는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 옛키 = 옮기기 전 키 · 경로 = 폴더 이름 배열",
|
||||
"다음": 54,
|
||||
"다음": 55,
|
||||
"지운번호": [
|
||||
"01010100",
|
||||
"01010101",
|
||||
@@ -74,7 +74,7 @@
|
||||
"분류2": "ss",
|
||||
"분류3": "aaa",
|
||||
"단위": "m",
|
||||
"일위대가": null,
|
||||
"일위대가": "UA000006",
|
||||
"자재조합": null,
|
||||
"도번": "SD-000001",
|
||||
"면적번": "SA-000001",
|
||||
@@ -106,6 +106,27 @@
|
||||
"재료번": "SM-000002",
|
||||
"바인딩": null,
|
||||
"펼침": null
|
||||
},
|
||||
{
|
||||
"키": "SS000054",
|
||||
"열": "item3",
|
||||
"경로": [
|
||||
"기슭막이",
|
||||
"ㄴㄴ"
|
||||
],
|
||||
"이름": "기슭막이 ㄴㄴ",
|
||||
"종류": "기슭막이",
|
||||
"분류1": "ㄴㄴ",
|
||||
"분류2": null,
|
||||
"분류3": null,
|
||||
"단위": null,
|
||||
"일위대가": null,
|
||||
"자재조합": null,
|
||||
"도번": "SD-000003",
|
||||
"면적번": "SA-000003",
|
||||
"재료번": "SM-000003",
|
||||
"바인딩": null,
|
||||
"펼침": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
"""단가산출 흐름도 다이어그램 순수 부분(`M02_DF_Diagram_Parts.ts`) — 기댐 선 · 흐름 길 포함 · 쉼 가르기 · 맞춤.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(Model · Rule …)는 이어 트랜스파일 ·
|
||||
화면 부품(@ui)은 빈 흉내. 견본 로직은 이 시험 안에 둠.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_Diagram_Parts.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, file] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) });
|
||||
const cache = {};
|
||||
function load(f) {
|
||||
if (cache[f]) return cache[f].exports;
|
||||
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = (cache[f] = { exports: {} });
|
||||
const req = (id) => {
|
||||
if (id.startsWith(".")) {
|
||||
const p = path.resolve(path.dirname(f), id);
|
||||
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
|
||||
}
|
||||
return stub;
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
return box.exports;
|
||||
}
|
||||
const m = load(file);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "deps") return m.depLines(job.lg).map((d) => ({ id: d.id, from: d.from, to: d.to }));
|
||||
if (job.op === "inTrace") return m.depLines(job.lg).map((d) => m.depInTrace(d, job.steps));
|
||||
if (job.op === "rest") return job.breaks.map((b) => m.isRest(b));
|
||||
if (job.op === "fit") return m.fitView(job.rects, job.view);
|
||||
if (job.op === "value") return job.values.map((v) => m.showValue(v));
|
||||
if (job.op === "end") return m.depEnd(job.a, job.tag);
|
||||
if (job.op === "restParts") {
|
||||
const r = m.restParts(job.lg, job.rests, job.values ?? {});
|
||||
return { links: [...r.links].sort(), pills: [...r.pills].sort(), boxes: [...r.boxes].sort() };
|
||||
}
|
||||
if (job.op === "path") return m.depPath(job.a, job.b, job.side);
|
||||
return null;
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps(jobs, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def port(box, name):
|
||||
return {"box": box, "name": name}
|
||||
|
||||
|
||||
# 견본 — 관경 → 흄관 재료(조건 찾기) · 지반 → 조건 선 · 뒷길이 → 표 조건
|
||||
LOGIC = {
|
||||
"key": None,
|
||||
"name": "견본",
|
||||
"resultUnit": "원/m",
|
||||
"userElements": [],
|
||||
"boxes": [
|
||||
{
|
||||
"id": "v",
|
||||
"kind": "input",
|
||||
"name": "설계 값",
|
||||
"color": "",
|
||||
"unit": "-",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{"name": "관경", "type": "choice", "unit": "㎜", "example": 600},
|
||||
{"name": "지반", "type": "choice", "unit": "-", "example": "암"},
|
||||
{"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "m",
|
||||
"kind": "input",
|
||||
"name": "재료",
|
||||
"color": "",
|
||||
"unit": "-",
|
||||
"x": 0,
|
||||
"y": 200,
|
||||
"input": {
|
||||
"mode": "group",
|
||||
"group": "재료",
|
||||
"variables": [],
|
||||
"elements": [
|
||||
{
|
||||
"ref": "",
|
||||
"name": "흄관",
|
||||
"spec": "",
|
||||
"unit": "본",
|
||||
"mode": "rule",
|
||||
"slot": "흄관",
|
||||
"rule": {"field": "규격", "op": "포함", "from": port("v", "관경")},
|
||||
},
|
||||
{"ref": "MT1", "name": "모래", "spec": "", "unit": "㎥", "mode": "fixed"},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "t",
|
||||
"kind": "input",
|
||||
"name": "품 표",
|
||||
"color": "",
|
||||
"unit": "-",
|
||||
"x": 0,
|
||||
"y": 400,
|
||||
"input": {
|
||||
"mode": "group",
|
||||
"group": "소요량",
|
||||
"elements": [],
|
||||
"variables": [],
|
||||
"table": {"key": "QF1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "f",
|
||||
"kind": "function",
|
||||
"name": "셈",
|
||||
"color": "",
|
||||
"unit": "-",
|
||||
"x": 300,
|
||||
"y": 100,
|
||||
"func": {
|
||||
"sheet": {},
|
||||
"inputs": [{"port": "품", "cell": "B1"}, {"port": "흄관", "cell": "B2"}],
|
||||
"variables": [],
|
||||
"output": {"cell": "B9", "name": "금액", "unit": "원"},
|
||||
},
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "l1",
|
||||
"from": port("t", "표"),
|
||||
"to": port("f", "품"),
|
||||
"rule": {
|
||||
"kind": "lookup",
|
||||
"conds": [{"col": "뒷길이", "from": port("v", "뒷길이")}],
|
||||
"col": "석공",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "l2",
|
||||
"from": port("m", "흄관"),
|
||||
"to": port("f", "흄관"),
|
||||
"rule": {"kind": "gate", "from": port("v", "지반"), "op": "=", "value": "암"},
|
||||
},
|
||||
{"id": "l3", "from": port("v", "관경"), "to": port("f", "흄관")},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_기댐_선_셋():
|
||||
[deps] = run([{"op": "deps", "lg": LOGIC}])
|
||||
got = sorted((d["from"]["name"], json.dumps(d["to"], ensure_ascii=False)) for d in deps)
|
||||
assert got == sorted(
|
||||
[
|
||||
("뒷길이", json.dumps({"link": "l1"})),
|
||||
("지반", json.dumps({"link": "l2"})),
|
||||
("관경", json.dumps({"box": "m", "port": "흄관"}, ensure_ascii=False)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_기댐_선이_흐름_길에_듦():
|
||||
steps = [
|
||||
{"box": "v", "port": "지반", "dir": "out"},
|
||||
{"box": "f", "port": "흄관", "dir": "in", "link": "l2"},
|
||||
{"box": "m", "port": "흄관", "dir": "out"},
|
||||
]
|
||||
[marks] = run([{"op": "inTrace", "lg": LOGIC, "steps": steps}])
|
||||
# 차례: l1 표 조건(뒷길이) · l2 조건(지반) · 재료 조건 찾기(관경)
|
||||
assert marks == [False, True, False]
|
||||
|
||||
|
||||
def test_쉼_가르기():
|
||||
[rest] = run(
|
||||
[
|
||||
{
|
||||
"op": "rest",
|
||||
"breaks": [
|
||||
{
|
||||
"kind": "통과 안 됨",
|
||||
"level": "쉼",
|
||||
"box": "f",
|
||||
"link": "l2",
|
||||
"text": "지반이 암이 아님",
|
||||
},
|
||||
{
|
||||
"kind": "빈 받는 자리",
|
||||
"level": "문제",
|
||||
"box": "f",
|
||||
"port": "품",
|
||||
"text": "비어 있음",
|
||||
},
|
||||
{"kind": "통과 안 됨", "level": "문제", "box": "f", "text": "level 이 정함"},
|
||||
{"kind": "조건 겹침", "level": "쉼", "box": "f", "text": "level 이 정함"},
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
assert rest == [True, False, False, True]
|
||||
|
||||
|
||||
def test_한눈에_90_아래로_안_내림과_알약_값():
|
||||
fit, values = run(
|
||||
[
|
||||
{
|
||||
"op": "fit",
|
||||
"rects": [{"x": 0, "y": 0, "w": 3000, "h": 200}],
|
||||
"view": {"w": 1000, "h": 600},
|
||||
},
|
||||
{
|
||||
"op": "value",
|
||||
"values": [
|
||||
97882.512,
|
||||
268908,
|
||||
0.3640,
|
||||
"깬돌",
|
||||
[["뒷길이", "석공"], [25, 0.15], [30, 0.22]],
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
assert fit["zoom"] == 0.9
|
||||
assert values == ["97,882.512", "268,908", "0.364", "깬돌", "· 2줄"]
|
||||
|
||||
|
||||
def test_기댐_선_끝이_알약과_이름표에_붙음():
|
||||
left, right, path_left, path_right = run(
|
||||
[
|
||||
{"op": "end", "a": {"x": 100, "y": 50}, "tag": {"x": 400, "y": 120, "w": 80}},
|
||||
{"op": "end", "a": {"x": 600, "y": 50}, "tag": {"x": 400, "y": 120, "w": 80}},
|
||||
{"op": "path", "a": {"x": 100, "y": 50}, "b": {"x": 358, "y": 120}, "side": "left"},
|
||||
{"op": "path", "a": {"x": 100, "y": 50}, "b": {"x": 130, "y": 300}, "side": "right"},
|
||||
]
|
||||
)
|
||||
# 시작이 왼쪽이면 이름표 왼쪽 가장자리(400 − 40 − 2) · 오른쪽이면 오른쪽 가장자리
|
||||
assert left == {"x": 358, "y": 120, "side": "left"}
|
||||
assert right == {"x": 442, "y": 120, "side": "right"}
|
||||
# 길은 시작 알약에서 나가 끝에서 끝남 · 재료 알약은 오른쪽에서 들어감(끝 손잡이가 끝보다 오른쪽)
|
||||
assert path_left.startswith("M100,50 ") and path_left.endswith(" 358,120")
|
||||
assert path_right.startswith("M100,50 ") and path_right.endswith(" 130,300")
|
||||
handle = float(path_right.split(" ")[2].split(",")[0])
|
||||
assert handle > 130
|
||||
|
||||
|
||||
def test_쉼은_일부면_선과_받는_알약만_모두면_상자도():
|
||||
two_in = {
|
||||
**LOGIC,
|
||||
"links": [
|
||||
{"id": "a", "from": port("v", "지반"), "to": port("f", "품")},
|
||||
{"id": "b", "from": port("m", "흄관"), "to": port("f", "흄관")},
|
||||
],
|
||||
}
|
||||
one_rest = [
|
||||
{"kind": "통과 안 됨", "level": "쉼", "box": "f", "port": "흄관", "link": "b", "text": "쉼"}
|
||||
]
|
||||
all_rest = one_rest + [
|
||||
{"kind": "통과 안 됨", "level": "쉼", "box": "f", "port": "품", "link": "a", "text": "쉼"}
|
||||
]
|
||||
box_rest = [{"kind": "통과 안 됨", "level": "쉼", "box": "m", "text": "쉼"}]
|
||||
part, every, filled, empty = run(
|
||||
[
|
||||
{"op": "restParts", "lg": two_in, "rests": one_rest, "values": {"f": {"금액": 2000}}},
|
||||
{"op": "restParts", "lg": two_in, "rests": all_rest, "values": {"f": {"금액": None}}},
|
||||
{"op": "restParts", "lg": two_in, "rests": box_rest, "values": {"m": {"흄관": 52000}}},
|
||||
{"op": "restParts", "lg": two_in, "rests": box_rest, "values": {"m": {"흄관": None}}},
|
||||
]
|
||||
)
|
||||
# 하나만 쉼 — 셈하는 상자는 안 흐림 · 그 선과 받는 알약만
|
||||
assert part == {"links": ["b"], "pills": ["f|흄관"], "boxes": []}
|
||||
# 들어오는 선이 모두 쉼 — 상자도 흐림
|
||||
assert every["boxes"] == ["f"]
|
||||
# 상자에 걸린 쉼 — 내보내는 값이 있으면 안 흐림 · 모두 빈 값이면 흐림
|
||||
assert filled["boxes"] == [] and empty["boxes"] == ["m"]
|
||||
|
||||
|
||||
def test_지반_토사_쉼_흐림은_브레이커로_깨기_하나():
|
||||
def fn(id_, name, ins, out):
|
||||
return {
|
||||
"id": id_,
|
||||
"kind": "function",
|
||||
"name": name,
|
||||
"color": "",
|
||||
"unit": "-",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"func": {
|
||||
"sheet": {},
|
||||
"inputs": [{"port": p, "cell": f"B{i + 1}"} for i, p in enumerate(ins)],
|
||||
"variables": [],
|
||||
"output": {"cell": "B9", "name": out, "unit": "원"},
|
||||
},
|
||||
}
|
||||
|
||||
gate = {"kind": "gate", "from": port("ig", "지반"), "op": "=", "value": "암"}
|
||||
ground = {
|
||||
"key": None,
|
||||
"name": "지반",
|
||||
"resultUnit": "원/㎥",
|
||||
"userElements": [],
|
||||
"boxes": [
|
||||
{
|
||||
"id": "ig",
|
||||
"kind": "input",
|
||||
"name": "지반 · 굴착량",
|
||||
"color": "",
|
||||
"unit": "-",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"input": {
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{"name": "지반", "type": "choice", "unit": "-", "example": "토사"},
|
||||
{"name": "굴착량", "type": "number", "unit": "㎥", "example": 1},
|
||||
],
|
||||
},
|
||||
},
|
||||
fn("exc", "굴삭기로 파기", ["굴착량", "시간당 경비"], "굴삭기 경비"),
|
||||
fn("brk", "브레이커로 깨기", ["굴착량", "시간당 경비"], "브레이커 경비"),
|
||||
fn("sum", "1㎥ 경비", ["굴삭기 경비", "브레이커 경비", "굴착량"], "1㎥ 경비"),
|
||||
{
|
||||
"id": "out",
|
||||
"kind": "output",
|
||||
"name": "경비",
|
||||
"color": "",
|
||||
"unit": "원",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"output": {"cost": "경비", "unit": "원"},
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": "l1",
|
||||
"from": port("ig", "굴착량"),
|
||||
"to": port("exc", "굴착량"),
|
||||
"rule": {**gate, "op": "≠"},
|
||||
},
|
||||
{"id": "l2", "from": port("ig", "굴착량"), "to": port("brk", "굴착량"), "rule": gate},
|
||||
{"id": "l5", "from": port("exc", "굴삭기 경비"), "to": port("sum", "굴삭기 경비")},
|
||||
{"id": "l6", "from": port("brk", "브레이커 경비"), "to": port("sum", "브레이커 경비")},
|
||||
{"id": "l7", "from": port("ig", "굴착량"), "to": port("sum", "굴착량")},
|
||||
{"id": "l8", "from": port("sum", "1㎥ 경비"), "to": port("out", "값")},
|
||||
],
|
||||
}
|
||||
# Eval 쉼 줄은 link 가 늘 있음 — 조건 선 l2(받는 상자 브레이커) · 빈 값이 흘러간 l6(받는 상자 1㎥ 경비)
|
||||
rests = [
|
||||
{
|
||||
"kind": "통과 안 됨",
|
||||
"level": "쉼",
|
||||
"box": "brk",
|
||||
"port": "굴착량",
|
||||
"link": "l2",
|
||||
"text": "암이 아님",
|
||||
},
|
||||
{
|
||||
"kind": "통과 안 됨",
|
||||
"level": "쉼",
|
||||
"box": "sum",
|
||||
"port": "브레이커 경비",
|
||||
"link": "l6",
|
||||
"text": "쉼",
|
||||
},
|
||||
]
|
||||
values = {
|
||||
"brk": {"시간당 경비": 80000, "굴착량": None, "브레이커 경비": None},
|
||||
"sum": {"굴삭기 경비": 2000, "브레이커 경비": None, "굴착량": 1, "1㎥ 경비": 2000},
|
||||
"out": {"값": 2000},
|
||||
}
|
||||
[got] = run([{"op": "restParts", "lg": ground, "rests": rests, "values": values}])
|
||||
assert got["boxes"] == ["brk"]
|
||||
assert got["links"] == ["l2", "l6"]
|
||||
assert got["pills"] == ["brk|굴착량", "sum|브레이커 경비"]
|
||||
@@ -450,16 +450,17 @@ def test_나_조건_통과로_둘로_가르기_표에서_찾기() -> None:
|
||||
a_out, a_breaks, b_out, b_breaks, far = got
|
||||
# 토사: 0.5 × 100000 × 1.1 · 암 줄은 통과 안 됨 · 높이 4.5 → 증가율 40
|
||||
assert a_out == {"b6": 55000, "b7": None, "b8": 40}
|
||||
assert a_breaks == [["통과 안 됨", "l2"]]
|
||||
assert a_breaks == [["통과 안 됨", "l2"], ["통과 안 됨", "l5"]] # 암 갈래 + 그 아래로 번진 쉼
|
||||
assert b_out == {
|
||||
"b6": None,
|
||||
"b7": 50000,
|
||||
"b8": 30,
|
||||
} # 암 50,000 → 천 원 버림 그대로 · 높이 3.5 → 30
|
||||
assert b_breaks == ["통과 안 됨"]
|
||||
assert b_breaks == ["통과 안 됨", "통과 안 됨"] # 토사 갈래 + 번진 쉼
|
||||
assert far == [
|
||||
["통과 안 됨", "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음"],
|
||||
["표에 없는 조건", "「입력 2」 → 「함수 3」 선 — 표에 맞는 줄이 없음"],
|
||||
["통과 안 됨", "「함수 2」 → 「출력 2」 — 앞 조건이 안 맞아 쉬는 갈래"],
|
||||
]
|
||||
|
||||
|
||||
@@ -471,7 +472,7 @@ def test_나_조건_선_겹침_안맞음() -> None:
|
||||
)
|
||||
overlap, none = got
|
||||
assert overlap == [["조건 겹침", "「입력」 의 「지반」 에서 나간 조건 선이 둘 이상 통과함"]]
|
||||
assert none == ["통과 안 됨", "통과 안 됨", "조건 안 맞음"]
|
||||
assert none == ["통과 안 됨", "통과 안 됨", "통과 안 됨", "통과 안 됨", "조건 안 맞음"]
|
||||
|
||||
|
||||
def test_나_규칙_글과_엑셀_수식() -> None:
|
||||
@@ -620,3 +621,62 @@ def test_표_범위칸_숫자하나는_같음() -> None:
|
||||
)
|
||||
# 4 = 「4」 줄만(같음) · 4 는 「4~6」(초과) 에 안 듦 · 빈 아래 끝은 열림
|
||||
assert got == [[0.5, None], [0.6, None], [0.4, None], [None, "표에 없는 조건"], [0.6, None]]
|
||||
|
||||
|
||||
def test_쉬는_갈래와_문제_무게() -> None:
|
||||
got = _run(
|
||||
SPLIT + "const a = E.breaks(lg, V('토사'));\n"
|
||||
"const b = E.breaks(lg, V('자갈', 9));\n"
|
||||
"out([a.map(x => [x.kind, x.level, x.box, x.text]), b.filter(x => x.level === '문제').map(x => x.kind),"
|
||||
" E.evalLogic(lg, V('토사')).outputs]);"
|
||||
)
|
||||
a, b_problems, outs = got
|
||||
assert a == [
|
||||
[
|
||||
"통과 안 됨",
|
||||
"쉼",
|
||||
"b4",
|
||||
"「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음",
|
||||
],
|
||||
["통과 안 됨", "쉼", "b7", "「함수 2」 → 「출력 2」 — 앞 조건이 안 맞아 쉬는 갈래"],
|
||||
]
|
||||
# 하나도 안 맞음 · 표에 없는 조건만 문제 — 쉬는 갈래 넷은 문제로 안 셈
|
||||
assert b_problems == ["표에 없는 조건", "조건 안 맞음"]
|
||||
assert outs["b7"] is None # 쉬는 갈래 끝 출력은 빈 값(0 아님)
|
||||
|
||||
|
||||
def _ground() -> dict:
|
||||
return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _server(lg: dict, values: dict) -> dict:
|
||||
from M02_MasterTemplete.DataFlow import M02_DF_Compute as cp
|
||||
|
||||
return cp.run(lg, values, lambda key: {}, [])
|
||||
|
||||
|
||||
def test_만나는_상자는_살아_있는_선이_있으면_셈_서버와_같음() -> None:
|
||||
lg = _ground()
|
||||
soil = {"in-ground": {"지반": "토사"}}
|
||||
rock = {"in-ground": {"지반": "암"}}
|
||||
got = _run(
|
||||
f"const lg = {json.dumps(lg, ensure_ascii=False)};\n"
|
||||
f"const s = E.evalLogic(lg, {json.dumps(soil, ensure_ascii=False)});\n"
|
||||
f"const r = E.evalLogic(lg, {json.dumps(rock, ensure_ascii=False)});\n"
|
||||
"out([s.outputs, s.breaks.map(b => [b.kind, b.level, b.link]), r.outputs, r.breaks.map(b => [b.kind, b.level, b.link]),"
|
||||
" s.ports['fn-sum'], r.ports['fn-sum']]);"
|
||||
)
|
||||
s_out, s_breaks, r_out, r_breaks, s_sum, r_sum = got
|
||||
assert s_out == {"out-exp": 2000} # 굴삭기 갈래만 · 브레이커 갈래는 쉼(빈 칸)
|
||||
assert s_breaks == [["통과 안 됨", "쉼", "l2"], ["통과 안 됨", "쉼", "l6"]]
|
||||
assert [b for b in s_breaks if b[1] == "문제"] == []
|
||||
assert r_breaks == [["통과 안 됨", "쉼", "l1"], ["통과 안 됨", "쉼", "l5"]]
|
||||
assert r_out["out-exp"] == r_sum["1㎥ 경비"] and r_out["out-exp"] != 2000 # 브레이커 갈래 값
|
||||
assert s_sum == {"1㎥ 경비": 2000}
|
||||
# 서버(Compute) = 화면(Eval)
|
||||
for values, screen_out, screen_breaks in ((soil, s_out, s_breaks), (rock, r_out, r_breaks)):
|
||||
srv = _server(lg, values)
|
||||
assert {k: float(v) for k, v in srv["outputs"].items()} == {
|
||||
k: float(v) for k, v in screen_out.items()
|
||||
}
|
||||
assert [[b["kind"], b["level"], b["link"]] for b in srv["breaks"]] == screen_breaks
|
||||
|
||||
Reference in New Issue
Block a user