Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_4

This commit is contained in:
2026-10-01 23:41:45 +09:00
6 changed files with 57 additions and 42 deletions
@@ -1,7 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_FlowView.ts
* 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다
* 식 두 줄 — sub2 `formulaLines` 가 있으면 그것 · 없으면 함수 이름 · 결과 값만). 기본정보 · 시험 계산은 Detail 이 둠.
* 식 두 줄 — sub2 `formulaLines`). 기본정보 · 시험 계산은 Detail 이 둠.
* 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름.
* 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름.
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8.
@@ -15,7 +15,12 @@ import {
type CfEvalResult,
type CfValues,
} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
import type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import {
cellValuesOf,
formulaLines,
type FormulaLine,
} from "../M02_MasterTemplete/DataFlow/M02_DF_FormulaView";
import type { CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round";
import {
flowRows,
@@ -51,22 +56,6 @@ const GROUP_TITLE: Record<FlowGroupKey, TextKey> = {
plain: "Plain",
};
/** sub2 의 식 보기(`M02_DF_FormulaView.ts` · 정함 8) — 아직 없으면 비어 있음 */
interface FormulaLine {
name: string;
named: string;
valued: string;
branches?: { when: string; then: string; now: boolean }[];
}
type FormulaFn = (box: CfBox, values: CfValues) => FormulaLine[];
const FORMULA = import.meta.glob<{ formulaLines?: FormulaFn }>(
"../M02_MasterTemplete/DataFlow/M02_DF_FormulaView.ts",
);
const loadFormula = async (): Promise<FormulaFn | null> => {
const load = Object.values(FORMULA)[0];
return load ? ((await load()).formulaLines ?? null) : null;
};
const section = (title: string, body: HTMLElement[]): HTMLElement =>
el("section", {
className: "m01-logic__section m01lab__box",
@@ -182,26 +171,20 @@ export function buildFlowView(
el("p", { className: "m01-logic__muted", text: tf("TotalNote") }),
...rows.errors.slice(0, 3).map((t) => el("p", { className: "m01flow__warn", text: t })),
]);
const bodies = rows.funcs.map((f) => funcBlock(f, null));
/** 함수 상자마다 식 두 줄(sub2 식 보기 · 받는 알약 값을 칸에 넣고 셈) — 못 그리면 이름 = 값만 */
const linesOf = (id: string): FormulaLine[] | null => {
const box = lg.boxes.find((b) => b.id === id);
if (!box?.func) return null;
try {
return formulaLines(box, cellValuesOf(box, ev.arrived?.[id]));
} catch {
return null;
}
};
const bodies = rows.funcs.map((f) => funcBlock(f, linesOf(f.box)));
const text = section(
tf("Formula"),
bodies.length ? bodies : [el("p", { className: "m01-logic__muted", text: tf("NoFunc") })],
);
host.replaceChildren(detail, text);
// sub2 식 보기가 올라와 있으면 함수 상자마다 식 두 줄을 더함
void loadFormula().then((fn) => {
if (!fn || !bodies.length) return;
const boxes = lg.boxes.filter((b) => b.kind === "function" && b.func);
const merged = { ...ev.ports, ...ev.arrived } as CfValues;
text.replaceChildren(
text.firstElementChild!,
...rows.funcs.map((f, i) => {
try {
return funcBlock(f, fn(boxes[i], merged));
} catch {
return bodies[i];
}
}),
);
});
}
+12 -3
View File
@@ -314,13 +314,22 @@ export function renderDiagram(
applyView();
drawLines();
};
const fit = (): void => {
/** 맞추기 — 저절로(처음 · 다시 맞춤)는 fitMin · 「한눈에」 단추는 fitAllMin(전체가 보이게) */
const fit = (button = false): void => {
const rects = lg.boxes.map((b) => {
const n = world.querySelector<HTMLElement>(`.m02-dfdg__box[data-box="${CSS.escape(b.id)}"]`);
return { x: b.x, y: b.y, w: n?.offsetWidth ?? 180, h: n?.offsetHeight ?? 80 };
});
const r = view.getBoundingClientRect();
Object.assign(st, fitView(rects, { w: r.width, h: r.height }, 40, opts.fitMin));
Object.assign(
st,
fitView(
rects,
{ w: r.width, h: r.height },
40,
button ? (opts.fitAllMin ?? opts.fitMin) : opts.fitMin,
),
);
applyView();
drawLines();
};
@@ -400,7 +409,7 @@ export function renderDiagram(
sm("ZoomOut", () => setZoom(st.zoom / 1.2)),
zoomLabel,
sm("ZoomIn", () => setZoom(st.zoom * 1.2)),
sm("Fit", fit),
sm("Fit", () => fit(true)),
...(ro
? []
: [
@@ -124,6 +124,8 @@ export interface DiagramOpts {
fitKey?: number;
/** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */
fitMin?: number;
/** 「한눈에」 단추로 맞출 때 가장 작은 확대 — 없으면 fitMin */
fitAllMin?: number;
}
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
+6 -3
View File
@@ -52,8 +52,10 @@ export interface FlowPanel {
export const PANEL_MIN = 320;
export const STRIP_WIDTH = 40;
/** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */
export const PANEL_FIT_MIN = 0.25;
/** 패널에서 저절로 맞출 때 가장 작은 확대 — 글이 읽히는 최소 */
export const PANEL_FIT_MIN = 0.6;
/** 패널 「한눈에」 단추 — 전체가 보이게(가장 작은 확대) */
export const PANEL_FIT_ALL_MIN = 0.25;
const WIDTH_KEY = "m02.dfpanel.width";
const FOLD_KEY = "m02.dfpanel.folded";
@@ -94,8 +96,9 @@ export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPane
readOnly: opts.readOnly,
onChange: opts.onChange,
logics: opts.logics,
// 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림
// 패널 처음 배율 = 글이 읽히는 최소(넘치면 왼쪽 위 입력 상자부터) · 「한눈에」 단추는 전체 · 크게 보기는 90%
fitMin: () => (full ? 0.9 : PANEL_FIT_MIN),
fitAllMin: () => (full ? 0.9 : PANEL_FIT_ALL_MIN),
});
const { diagram, detail, summary, note, undo } = paper.parts;
let width = storedWidth(readStore(WIDTH_KEY));
@@ -55,6 +55,8 @@ export interface PaperOpts {
logics?(key: string): Promise<CfLogic | null>;
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
fitMin?(): number;
/** 「한눈에」 단추의 가장 작은 확대 — 없으면 fitMin */
fitAllMin?(): number;
}
export interface Paper {
@@ -307,6 +309,7 @@ export function createPaper(opts: PaperOpts): Paper {
readOnly,
fitKey,
fitMin: opts.fitMin?.(),
fitAllMin: opts.fitAllMin?.(),
});
drawPanel();
renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values });
+16 -1
View File
@@ -43,7 +43,7 @@ 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 === "fit") return m.fitView(job.rects, job.view, 40, job.min);
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") {
@@ -404,3 +404,18 @@ def test_지반_토사_쉼_흐림은_브레이커로_깨기_하나():
assert got["boxes"] == ["brk"]
assert got["links"] == ["l2", "l6"]
assert got["pills"] == ["brk|굴착량", "sum|브레이커 경비"]
def test_패널_처음_배율은_읽히는_최소_넘치면_왼쪽_위부터():
rects = [{"x": 40, "y": 40, "w": 200, "h": 120}, {"x": 1500, "y": 600, "w": 200, "h": 120}]
auto, whole = run(
[
{"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.6},
{"op": "fit", "rects": rects, "view": {"w": 500, "h": 400}, "min": 0.25},
]
)
# 처음 — 0.6 아래로 안 내림 · 넘치면 왼쪽 위(첫 상자 - 여백)가 판 왼쪽 위
assert auto["zoom"] == 0.6
assert auto["panX"] == 0 and auto["panY"] == 0
# 한눈에 — 전체가 들어가게 더 작게
assert whole["zoom"] < 0.6