Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-01 16:49:27 +09:00
4 changed files with 65 additions and 31 deletions
+18 -31
View File
@@ -93,29 +93,9 @@ export interface DiagramOpts {
/* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */
/** 알약 — Model 이 아직이면 상자 칸에서 바로 뽑음 */
/** 알약 — Model 의 받는 · 내보내는 알약 */
export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } {
try {
return { ins: portsIn(box), outs: portsOut(box) };
} catch {
if (box.kind === "input") {
const inp = box.input;
const outs = [
...(inp?.elements ?? []).map((e) => ({ name: e.name, unit: e.unit })),
...(inp?.table ? [{ name: "표", unit: "-" }] : []),
...(inp?.variables ?? []).map((v) => ({ name: v.name, unit: v.unit })),
];
return { ins: [], outs };
}
if (box.kind === "function") {
const f = box.func;
return {
ins: (f?.inputs ?? []).map((p) => ({ name: p.port, unit: "-" })),
outs: f ? [{ name: f.output.name, unit: f.output.unit }] : [],
};
}
return { ins: [{ name: "값", unit: box.unit }], outs: [] };
}
return { ins: portsIn(box), outs: portsOut(box) };
}
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
@@ -170,7 +150,10 @@ export function arrange(
return out;
}
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 */
/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */
export const FIT_MIN = 0.9;
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
export function fitView(
rects: { x: number; y: number; w: number; h: number }[],
view: { w: number; h: number },
@@ -181,12 +164,11 @@ export function fitView(
const y0 = Math.min(...rects.map((r) => r.y)) - pad;
const x1 = Math.max(...rects.map((r) => r.x + r.w)) + pad;
const y1 = Math.max(...rects.map((r) => r.y + r.h)) + pad;
const zoom = clampZoom(Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5));
return {
zoom,
panX: (view.w - (x1 - x0) * zoom) / 2 - x0 * zoom,
panY: (view.h - (y1 - y0) * zoom) / 2 - y0 * zoom,
};
const zoom = clampZoom(Math.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
/** 남으면 가운데 · 넘치면 왼쪽(위) 끝부터 */
const place = (size: number, from: number, to: number): number =>
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
}
const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
@@ -208,10 +190,15 @@ interface HostState {
const states = new WeakMap<HTMLElement, HostState>();
const SVG = "http://www.w3.org/2000/svg";
const showValue = (v: unknown): string => {
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
export const showValue = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "";
if (typeof v === "number")
return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4));
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
if (Array.isArray(v)) {
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
}
return String(v).slice(0, 12);
};
@@ -31,6 +31,7 @@ import {
elementOf,
machineFields,
queryOf,
reconcileUnit,
removeElement,
setKind,
valueOf,
@@ -143,6 +144,9 @@ export function renderInputPanel(
host.replaceChildren(el("p", { className: "m02-cfip__muted", text: t("NotInput") }));
return;
}
// 상자 단위가 고른 요소 · 변수 단위와 다르면(견본 등) 맞춰 다시 그림 — 맞으면 바로 지나감
const fixed = reconcileUnit(lg, boxId);
if (fixed !== lg) return on.change(fixed);
const input = box.input ?? { mode: "variable" as const, elements: [], variables: [] };
const kind: CfGroup | "변수" = input.mode === "variable" ? "변수" : (input.group ?? "인력");
const body = el("div", { className: "m02-cfip__body" });
@@ -282,3 +282,13 @@ export function removeVariable(lg: CfLogic, id: string, name: string, pills?: Pi
pills,
);
}
/** 상자 단위를 요소 · 변수 단위에 맞춤 — 이미 맞으면 받은 것 그대로(견본처럼 단위가 안 맞게 저장된 상자를 열 때 바로잡음) */
export function reconcileUnit(lg: CfLogic, id: string): CfLogic {
const box = lg.boxes.find((b) => b.id === id);
if (!box?.input) return lg;
const want = unitOfInput(box.input);
return box.unit === want
? lg
: { ...lg, boxes: lg.boxes.map((b) => (b.id === id ? { ...b, unit: want } : b)) };
}
@@ -219,3 +219,36 @@ def test_환율_유가전력_알약_이름에_날짜가_안_들어감():
"값": 1900,
}
assert call("elementOf", "유가전력", oil)["name"] == "휘발유 서울"
def test_단위가_안_맞게_저장된_상자는_열_때_맞춤():
el1, el2 = call("elementOf", "인력", LABOR), call("elementOf", "인력", LABOR2)
lg = logic()
lg["boxes"][0]["input"] = {
"mode": "group",
"group": "인력",
"elements": [el1, el2],
"variables": [],
}
assert lg["boxes"][0]["unit"] == "-"
fixed = run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]
assert fixed["boxes"][0]["unit"] == "원/일"
again = run([{"op": "fn", "name": "reconcileUnit", "args": [fixed, "b1"]}])[0]
assert again == fixed
oil = {"ref": "X", "name": "경유", "spec": "", "unit": "원/L"}
lg["boxes"][0]["input"]["elements"].append(oil)
lg["boxes"][0]["unit"] = "원/일"
assert (
run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"]
== "-"
)
one = {
"mode": "variable",
"elements": [],
"variables": [{"name": "면적", "type": "number", "unit": "㎡", "example": 1}],
}
lg["boxes"][0]["input"] = one
assert (
run([{"op": "fn", "name": "reconcileUnit", "args": [lg, "b1"]}])[0]["boxes"][0]["unit"]
== "㎡"
)