Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -22,6 +22,7 @@ import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import type { ConditionOptions } from "./spreadsheet_condition";
|
||||
import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
@@ -62,6 +63,8 @@ export interface SpreadsheetOptions {
|
||||
/** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 ·
|
||||
* 알약 막대만. 다른 화면 모양은 그대로 */
|
||||
preview?: boolean;
|
||||
/** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */
|
||||
conditions?: ConditionOptions;
|
||||
}
|
||||
|
||||
export interface SpreadsheetHandle {
|
||||
@@ -165,6 +168,10 @@ export function createSpreadsheet(
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
loadLinks();
|
||||
if (opts.conditions && !readOnly)
|
||||
void import("./spreadsheet_condition").then((m) => {
|
||||
if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!));
|
||||
});
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
.ss-cond-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-cond {
|
||||
min-width: min(46rem, 96vw);
|
||||
}
|
||||
|
||||
.ss-cond-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.ss-cond-label {
|
||||
min-width: 5em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.ss-cond-pick {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-cond-cell,
|
||||
.ss-cond-value {
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.ss-cond .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ss-cond-note {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.ss-cond-formula {
|
||||
flex: 1 1 20em;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-cond-foot {
|
||||
margin-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condition.ts
|
||||
* 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF)
|
||||
* 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내
|
||||
* 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로
|
||||
* 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { toA1 } from "./spreadsheet_address";
|
||||
import {
|
||||
COND_OPS,
|
||||
CELL,
|
||||
draftToFormula,
|
||||
formulaToDraft,
|
||||
type CondBranch,
|
||||
type CondDraft,
|
||||
type CondOp,
|
||||
type CondOperand,
|
||||
} from "./spreadsheet_condition_text";
|
||||
import "./spreadsheet_condition.css";
|
||||
|
||||
/** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */
|
||||
export interface ConditionOptions {
|
||||
valuesFor(a1: string): (string | number)[] | null;
|
||||
pills?(): { name: string; cell: string }[];
|
||||
}
|
||||
|
||||
export { COND_OPS, draftToFormula, formulaToDraft };
|
||||
export type { CondBranch, CondDraft, CondOp, CondOperand };
|
||||
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const TEXT = {
|
||||
Menu: ["조건 만들기", "Make a condition"],
|
||||
Title: ["조건 만들기", "Make a condition"],
|
||||
If: ["만약", "If"],
|
||||
ElseIf: ["아니면 만약", "Else if"],
|
||||
Then: ["→", "→"],
|
||||
Otherwise: ["아니면 →", "Otherwise →"],
|
||||
AddElseIf: ["+ 아니면 만약", "+ Else if"],
|
||||
Remove: ["지움", "Remove"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Close: ["닫기", "Close"],
|
||||
CellPick: ["칸 직접", "A cell"],
|
||||
ValuePick: ["값", "A value"],
|
||||
Cell: ["칸 주소", "Cell address"],
|
||||
Value: ["값", "Value"],
|
||||
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
|
||||
Target: ["식이 들어갈 칸", "Formula goes into"],
|
||||
AsIs: [
|
||||
"이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침",
|
||||
"This formula can't be shown as lines — edit the formula as it is",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 줄 판 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const OWN = "\u0001cell";
|
||||
const OWN_VALUE = "\u0001value";
|
||||
|
||||
function select(
|
||||
options: { value: string; text: string }[],
|
||||
value: string,
|
||||
onPick: (v: string) => void,
|
||||
aria: string,
|
||||
): HTMLSelectElement {
|
||||
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
|
||||
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
|
||||
s.value = value;
|
||||
s.addEventListener("change", () => onPick(s.value));
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */
|
||||
function cellPick(
|
||||
opts: ConditionOptions,
|
||||
value: string,
|
||||
onChange: (cell: string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const pills = opts.pills?.() ?? [];
|
||||
const hit = pills.find((p) => p.cell === value);
|
||||
const options = [
|
||||
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
|
||||
{ value: OWN, text: tc("CellPick") },
|
||||
];
|
||||
const box = el("span", { className: "ss-cond-pick" });
|
||||
const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria);
|
||||
box.append(sel);
|
||||
if (!hit) {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-cell",
|
||||
attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" },
|
||||
});
|
||||
input.value = value;
|
||||
input.addEventListener("change", () => {
|
||||
const m = CELL.exec(input.value.trim().toUpperCase());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
input.title = m ? "" : tc("BadCell");
|
||||
if (m) onChange(`${m[1]}${m[2]}`);
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */
|
||||
function operandPick(
|
||||
opts: ConditionOptions,
|
||||
o: CondOperand,
|
||||
hint: string | null,
|
||||
onChange: (o: CondOperand) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const box = el("span", { className: "ss-cond-pick" });
|
||||
const pills = opts.pills?.() ?? [];
|
||||
const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell);
|
||||
const options = [
|
||||
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
|
||||
{ value: OWN, text: tc("CellPick") },
|
||||
{ value: OWN_VALUE, text: tc("ValuePick") },
|
||||
];
|
||||
const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN;
|
||||
box.append(
|
||||
select(
|
||||
options,
|
||||
mode,
|
||||
(v) => {
|
||||
if (v === OWN_VALUE) onChange({ kind: "value", value: 0 });
|
||||
else if (v === OWN) onChange({ kind: "cell", cell: "B2" });
|
||||
else onChange({ kind: "cell", cell: v });
|
||||
},
|
||||
aria,
|
||||
),
|
||||
);
|
||||
if (o.kind === "cell" && !isPill) {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-cell",
|
||||
attrs: { type: "text", "aria-label": tc("Cell") },
|
||||
});
|
||||
input.value = o.cell;
|
||||
input.addEventListener("change", () => {
|
||||
const m = CELL.exec(input.value.trim().toUpperCase());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` });
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
if (o.kind === "value") {
|
||||
const list = hint ? opts.valuesFor(hint) : null;
|
||||
if (list && list.length) {
|
||||
const key = (v: string | number): string => `${typeof v}:${v}`;
|
||||
box.append(
|
||||
select(
|
||||
list.map((v) => ({ value: key(v), text: String(v) })),
|
||||
key(o.value),
|
||||
(k) => {
|
||||
const hitv = list.find((v) => key(v) === k);
|
||||
if (hitv !== undefined) onChange({ kind: "value", value: hitv });
|
||||
},
|
||||
tc("Value"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-value",
|
||||
attrs: { type: "text", "aria-label": tc("Value") },
|
||||
});
|
||||
input.value = String(o.value);
|
||||
input.addEventListener("change", () => {
|
||||
const t = input.value.trim();
|
||||
onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t });
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function openJob(
|
||||
ctx: SpreadsheetContext,
|
||||
opts: ConditionOptions,
|
||||
a1: string,
|
||||
draft: CondDraft | null,
|
||||
existing: string | null,
|
||||
): void {
|
||||
openModal({
|
||||
title: `${tc("Title")} — ${a1}`,
|
||||
closeLabel: tc("Close"),
|
||||
dialogClass: "ss-cond",
|
||||
mount: (body, close) => {
|
||||
let d: CondDraft = draft ?? {
|
||||
branches: [
|
||||
{
|
||||
left: "B1",
|
||||
op: "=",
|
||||
right: { kind: "value", value: "" },
|
||||
then: { kind: "value", value: 0 },
|
||||
},
|
||||
],
|
||||
otherwise: { kind: "value", value: 0 },
|
||||
};
|
||||
if (existing !== null && !draft) {
|
||||
body.append(
|
||||
el("p", { className: "ss-cond-note", text: tc("AsIs") }),
|
||||
el("code", { className: "ss-cond-formula", text: `=${existing}` }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const draw = (): void => {
|
||||
const set = (next: CondDraft): void => ((d = next), draw());
|
||||
const rows = d.branches.map((b, i) => {
|
||||
const edit = (patch: Partial<CondBranch>): void =>
|
||||
set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
|
||||
return el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }),
|
||||
cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")),
|
||||
select(
|
||||
COND_OPS.map((o) => ({ value: o, text: o })),
|
||||
b.op,
|
||||
(v) => edit({ op: v as CondOp }),
|
||||
"op",
|
||||
),
|
||||
operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")),
|
||||
tc("Then"),
|
||||
operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")),
|
||||
d.branches.length > 1
|
||||
? createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }),
|
||||
})
|
||||
: "",
|
||||
],
|
||||
});
|
||||
});
|
||||
const last = d.branches[d.branches.length - 1];
|
||||
body.replaceChildren(
|
||||
...rows,
|
||||
el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("AddElseIf"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
set({
|
||||
...d,
|
||||
branches: [
|
||||
...d.branches,
|
||||
{
|
||||
left: last.left,
|
||||
op: "=",
|
||||
right: { kind: "value", value: "" },
|
||||
then: { kind: "value", value: 0 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
el("span", { className: "ss-cond-label", text: tc("Otherwise") }),
|
||||
operandPick(
|
||||
opts,
|
||||
d.otherwise,
|
||||
null,
|
||||
(otherwise) => set({ ...d, otherwise }),
|
||||
tc("Otherwise"),
|
||||
),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-cond-row ss-cond-foot",
|
||||
children: [
|
||||
el("code", {
|
||||
className: "ss-cond-formula",
|
||||
text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`,
|
||||
}),
|
||||
createButton({
|
||||
label: tc("Make"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const sheet = ctx.sheet();
|
||||
const { 값: _v, ...keep } = sheet.칸[a1] ?? {};
|
||||
ctx.dispatch({
|
||||
종류: "칸",
|
||||
시트: sheet.id,
|
||||
칸: { [a1]: { ...keep, 식: draftToFormula(d) } },
|
||||
});
|
||||
close();
|
||||
},
|
||||
}),
|
||||
createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
draw();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 칸 우클릭에 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음 */
|
||||
export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle {
|
||||
let menu: HTMLElement | null = null;
|
||||
const close = (): void => {
|
||||
menu?.remove();
|
||||
menu = null;
|
||||
document.removeEventListener("pointerdown", away, true);
|
||||
};
|
||||
const away = (e: Event): void => {
|
||||
if (menu && !menu.contains(e.target as Node)) close();
|
||||
};
|
||||
const onContext = (e: MouseEvent): void => {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit || hit.kind !== "cell" || ctx.readOnly) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
const a1 = toA1(hit.r, hit.c);
|
||||
const sheet = ctx.sheet();
|
||||
const cell = sheet.칸[a1];
|
||||
menu = el("div", { className: "ss-cond-menu" });
|
||||
menu.style.left = `${e.clientX}px`;
|
||||
menu.style.top = `${e.clientY}px`;
|
||||
menu.append(
|
||||
createButton({
|
||||
label: tc("Menu"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
const existing = cell?.식 ?? null;
|
||||
const draft = existing !== null ? formulaToDraft(existing) : null;
|
||||
openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null);
|
||||
},
|
||||
}),
|
||||
);
|
||||
document.body.append(menu);
|
||||
document.addEventListener("pointerdown", away, true);
|
||||
};
|
||||
ctx.root.addEventListener("contextmenu", onContext, true);
|
||||
return {
|
||||
root: null,
|
||||
refresh() {},
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("contextmenu", onContext, true);
|
||||
close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condition_text.ts
|
||||
* 「조건 만들기」 의 글 쪽(DOM 없음) — 줄들 ↔ IF 식 글 · 바깥 쉼표 가름 · 시험이 이 파일만 떼어 Node 로 돌림
|
||||
* ========================================================================== */
|
||||
|
||||
export type CondOp = "=" | "≠" | "≥" | "≤" | ">" | "<";
|
||||
export type CondOperand =
|
||||
{ kind: "cell"; cell: string } | { kind: "value"; value: number | string };
|
||||
export interface CondBranch {
|
||||
/** 비교하는 칸 */
|
||||
left: string;
|
||||
op: CondOp;
|
||||
right: CondOperand;
|
||||
then: CondOperand;
|
||||
}
|
||||
export interface CondDraft {
|
||||
branches: CondBranch[];
|
||||
otherwise: CondOperand;
|
||||
}
|
||||
|
||||
export const XL_OP: Record<CondOp, string> = {
|
||||
"=": "=",
|
||||
"≠": "<>",
|
||||
"≥": ">=",
|
||||
"≤": "<=",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
export const FROM_XL: Record<string, CondOp> = {
|
||||
"=": "=",
|
||||
"<>": "≠",
|
||||
">=": "≥",
|
||||
"<=": "≤",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
export const COND_OPS: CondOp[] = ["=", "≠", "≥", "≤", ">", "<"];
|
||||
|
||||
export const CELL = /^\$?([A-Z]{1,3})\$?(\d+)$/;
|
||||
|
||||
/* ── 글 ↔ 줄 (DOM 없음) ───────────────────────────────────────────────── */
|
||||
|
||||
export const operandText = (o: CondOperand): string =>
|
||||
o.kind === "cell"
|
||||
? o.cell
|
||||
: typeof o.value === "number"
|
||||
? String(o.value)
|
||||
: `"${o.value.replace(/"/g, '""')}"`;
|
||||
|
||||
/** 줄들 → 식 글(앞 = 뺌) — 아니면 만약 은 안쪽 IF */
|
||||
export function draftToFormula(d: CondDraft): string {
|
||||
return d.branches.reduceRight(
|
||||
(rest, b) =>
|
||||
`IF(${b.left}${XL_OP[b.op]}${operandText(b.right)},${operandText(b.then)},${rest})`,
|
||||
operandText(d.otherwise),
|
||||
);
|
||||
}
|
||||
|
||||
/** 바깥 쉼표로 가름 — 따옴표 · 괄호 안은 건너뜀 */
|
||||
export function splitArgs(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote = false;
|
||||
let from = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') quote = !quote;
|
||||
else if (quote) continue;
|
||||
else if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
out.push(s.slice(from, i));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(s.slice(from));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseOperand(raw: string): CondOperand | null {
|
||||
const t = raw.trim();
|
||||
const cell = CELL.exec(t);
|
||||
if (cell) return { kind: "cell", cell: `${cell[1]}${cell[2]}` };
|
||||
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: "value", value: Number(t) };
|
||||
const q = /^"((?:[^"]|"")*)"$/.exec(t);
|
||||
return q ? { kind: "value", value: q[1].replace(/""/g, '"') } : null;
|
||||
}
|
||||
|
||||
/** 바깥 괄호 하나로 감싼 IF( … ) 의 안쪽 글 — 아니면 null */
|
||||
export function ifBody(f: string): string | null {
|
||||
const t = f.trim();
|
||||
if (!/^IF\(/i.test(t) || !t.endsWith(")")) return null;
|
||||
let depth = 0;
|
||||
let quote = false;
|
||||
for (let i = 2; i < t.length; i++) {
|
||||
if (t[i] === '"') quote = !quote;
|
||||
else if (quote) continue;
|
||||
else if (t[i] === "(") depth++;
|
||||
else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null;
|
||||
}
|
||||
return t.slice(3, -1);
|
||||
}
|
||||
|
||||
/** 식 글(앞 = 뺌) → 줄들 — 이 판이 만드는 꼴(칸 비교 · 단순 값 · 아니면 쪽 중첩)만 읽고 나머지는 null */
|
||||
export function formulaToDraft(formula: string): CondDraft | null {
|
||||
const body = ifBody(formula);
|
||||
if (body === null) return null;
|
||||
const args = splitArgs(body);
|
||||
if (args.length !== 3) return null;
|
||||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(args[0]);
|
||||
if (!m) return null;
|
||||
const left = parseOperand(m[1]);
|
||||
const right = parseOperand(m[3]);
|
||||
const then = parseOperand(args[1]);
|
||||
if (!left || left.kind !== "cell" || !right || !then) return null;
|
||||
const branch: CondBranch = { left: left.cell, op: FROM_XL[m[2]], right, then };
|
||||
const plain = parseOperand(args[2]);
|
||||
if (plain) return { branches: [branch], otherwise: plain };
|
||||
const rest = formulaToDraft(args[2]);
|
||||
return rest ? { branches: [branch, ...rest.branches], otherwise: rest.otherwise } : null;
|
||||
}
|
||||
@@ -72,6 +72,9 @@ export interface DetailContext {
|
||||
onChange: () => void;
|
||||
/** 시험 계산 칸(`buildCalc` 가 채움) — 넷째 컨테이너 안에 놓음 */
|
||||
calcHost: HTMLElement;
|
||||
/** 흐름이 있는 로직 — 이 칸에 왼쪽 「지금 내용」(단가산출 상세 · 텍스트 수식 = `buildFlowView`)을 그림 ·
|
||||
* 있으면 호표 · 읽는 식 두 묶음 대신 이 칸을 놓음(기본정보 · 시험 계산은 그대로) */
|
||||
flowHost?: HTMLElement;
|
||||
}
|
||||
|
||||
type Group = "labor" | "material" | "cost" | "other";
|
||||
@@ -482,8 +485,7 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void {
|
||||
: null;
|
||||
host.replaceChildren(
|
||||
infoBox(ctx),
|
||||
hoBox(ctx),
|
||||
section(tl("Formula_Title"), [formula]),
|
||||
...(ctx.flowHost ? [ctx.flowHost] : [hoBox(ctx), section(tl("Formula_Title"), [formula])]),
|
||||
section(tl("Calc_Title"), [ctx.calcHost]),
|
||||
);
|
||||
const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_FlowRows.ts
|
||||
* 흐름 로직의 왼쪽 「단가산출 상세」 줄 만들기(DOM 없음 · 순수) — 흐름도 출력 상자를 비용 종류별로 묶어
|
||||
* 줄(이름 · 단위 · 값) · 소계 · 계 · 함수 상자별 결과 값. 값은 DataFlow 계산 결과(`CfEvalResult`)를 읽기만 함.
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7. 시험 = resources/tester/test_m01_logic_flowview.py
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CfEvalResult } from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
|
||||
import type { CfBox, CfCost, CfLogic, CfRound } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
import { roundBy, showRounded } from "../M02_MasterTemplete/DataFlow/M02_DF_Round";
|
||||
|
||||
export type FlowGroupKey = "labor" | "material" | "cost" | "plain";
|
||||
|
||||
/** 비용 종류 → 묶음(인력 = 노무비 · 재료 = 재료비 · 경비) · 값(비용 아님)은 따로 */
|
||||
export const GROUP_OF: Record<CfCost, FlowGroupKey> = {
|
||||
노무비: "labor",
|
||||
재료비: "material",
|
||||
경비: "cost",
|
||||
값: "plain",
|
||||
};
|
||||
export const GROUP_ORDER: FlowGroupKey[] = ["labor", "material", "cost", "plain"];
|
||||
const COST_OF: Record<Exclude<FlowGroupKey, "plain">, "노무비" | "재료비" | "경비"> = {
|
||||
labor: "노무비",
|
||||
material: "재료비",
|
||||
cost: "경비",
|
||||
};
|
||||
|
||||
export interface FlowLine {
|
||||
box: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
value: number | string | boolean | null;
|
||||
/** 보이는 글 — 숫자는 그 출력의 끝수 자리대로 */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FlowGroup {
|
||||
key: FlowGroupKey;
|
||||
lines: FlowLine[];
|
||||
/** 비용 묶음만 — 합계 끝수를 적용한 소계 */
|
||||
sub: number | null;
|
||||
subText: string;
|
||||
}
|
||||
|
||||
export interface FlowFunc {
|
||||
box: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
value: unknown;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface FlowRows {
|
||||
groups: FlowGroup[];
|
||||
/** 합계 끝수(기본 1원 미만 버림)를 적용한 계 */
|
||||
total: number;
|
||||
totalText: string;
|
||||
funcs: FlowFunc[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const plain = (v: unknown): string =>
|
||||
v === null || v === undefined || Array.isArray(v)
|
||||
? "—"
|
||||
: typeof v === "number"
|
||||
? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 })
|
||||
: String(v);
|
||||
|
||||
/** 줄 모으기 — round.item = 출력 기본 끝수 · round.total = 합계 기본 끝수 */
|
||||
export function flowRows(
|
||||
lg: CfLogic,
|
||||
ev: CfEvalResult,
|
||||
round: { item: CfRound; total: CfRound },
|
||||
): FlowRows {
|
||||
const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output");
|
||||
const groups = GROUP_ORDER.map((key): FlowGroup => {
|
||||
const mine = outs.filter((b) => GROUP_OF[b.output?.cost ?? "값"] === key);
|
||||
const lines = mine.map((b): FlowLine => {
|
||||
const v = ev.outputs[b.id] ?? null;
|
||||
const r = b.output?.round ?? (key === "plain" ? undefined : round.item);
|
||||
return {
|
||||
box: b.id,
|
||||
name: b.name,
|
||||
unit: b.output?.unit ?? b.unit,
|
||||
value: v,
|
||||
text: typeof v === "number" ? showRounded(v, r) : plain(v),
|
||||
};
|
||||
});
|
||||
const raw = key === "plain" ? null : ev.cost[COST_OF[key]];
|
||||
const sub = raw === null ? null : roundBy(round.total, raw);
|
||||
return {
|
||||
key,
|
||||
lines,
|
||||
sub,
|
||||
subText: sub === null ? "" : showRounded(sub, round.total),
|
||||
};
|
||||
}).filter((g) => g.lines.length);
|
||||
const total = roundBy(round.total, ev.cost.계);
|
||||
const funcs = lg.boxes
|
||||
.filter((b) => b.kind === "function" && b.func)
|
||||
.map((b): FlowFunc => {
|
||||
const out = b.func!.output;
|
||||
const v = ev.ports[b.id]?.[out.name];
|
||||
return {
|
||||
box: b.id,
|
||||
name: b.name,
|
||||
unit: out.unit || b.unit,
|
||||
value: v,
|
||||
text:
|
||||
typeof v === "number"
|
||||
? v.toLocaleString("ko-KR", { maximumFractionDigits: 6 })
|
||||
: plain(v),
|
||||
};
|
||||
});
|
||||
return {
|
||||
groups,
|
||||
total,
|
||||
totalText: showRounded(total, round.total),
|
||||
funcs,
|
||||
errors: ev.errors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.m01flow__group {
|
||||
margin: 0 0 var(--spacing-12);
|
||||
}
|
||||
|
||||
.m01flow__block {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01flow__func {
|
||||
margin: 0 0 var(--spacing-4);
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01flow__branch {
|
||||
margin: 0 0 0 var(--spacing-16);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01flow__branch--now {
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01flow__warn {
|
||||
margin: var(--spacing-4) 0 0;
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_FlowView.ts
|
||||
* 흐름 로직의 왼쪽 「지금 내용」 — ② 단가산출 상세(출력 상자를 비용 종류별 줄 · 소계 · 계) · ③ 텍스트 수식(함수 상자마다
|
||||
* 식 두 줄 — sub2 `formulaLines` 가 있으면 그것 · 없으면 함수 이름 · 결과 값만). 기본정보 · 시험 계산은 Detail 이 둠.
|
||||
* 값은 DataFlow 계산(Eval · 같은 코드)으로 바로 — 오른쪽 패널이 고치면 LogicLab 이 이 함수를 다시 부름.
|
||||
* 화면은 `buildFlowView(host, lg, values)` 하나. 이 파일은 DataFlow 를 정적으로 가져오므로 LogicLab 은 import() 로 늦게 부름.
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_적용_정함.md 7 · 8.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
evalLogic,
|
||||
type CfEvalOptions,
|
||||
type CfEvalResult,
|
||||
type CfValues,
|
||||
} from "../M02_MasterTemplete/DataFlow/M02_DF_Eval";
|
||||
import type { CfBox, CfLogic } from "../M02_MasterTemplete/DataFlow/M02_DF_Model";
|
||||
import { itemRoundNow, totalRoundNow } from "../M02_MasterTemplete/DataFlow/M02_DF_Round";
|
||||
import {
|
||||
flowRows,
|
||||
type FlowFunc,
|
||||
type FlowGroup,
|
||||
type FlowGroupKey,
|
||||
} from "./M01_MasterData_UI_LogicLab_FlowRows";
|
||||
import "./M01_MasterData_UI_LogicLab_FlowView.css";
|
||||
|
||||
const TEXT = {
|
||||
Detail: ["단가산출 상세", "Unit-price detail"],
|
||||
Formula: ["텍스트 수식", "Text formulas"],
|
||||
Labor: ["인력 · 노무비", "Labor · labor cost"],
|
||||
Material: ["재료 · 재료비", "Material · material cost"],
|
||||
Cost: ["경비", "Expense"],
|
||||
Plain: ["값(비용 아님)", "Values (not costs)"],
|
||||
HName: ["이름", "Name"],
|
||||
HUnit: ["단위", "Unit"],
|
||||
HValue: ["값", "Value"],
|
||||
Sub: ["소계", "Subtotal"],
|
||||
Total: ["계", "Total"],
|
||||
TotalNote: ["합계 끝수 기본 = 1원 미만 버림", "Total rounding default: floor to 1 won"],
|
||||
None: ["출력 상자가 없음", "No output box"],
|
||||
NoFunc: ["함수 상자가 없음", "No function box"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
const tf = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
const GROUP_TITLE: Record<FlowGroupKey, TextKey> = {
|
||||
labor: "Labor",
|
||||
material: "Material",
|
||||
cost: "Cost",
|
||||
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",
|
||||
children: [
|
||||
el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title })] }),
|
||||
...body,
|
||||
],
|
||||
});
|
||||
|
||||
const groupTable = (g: FlowGroup): HTMLElement => {
|
||||
const sub =
|
||||
g.sub === null
|
||||
? []
|
||||
: [
|
||||
el("tr", {
|
||||
className: "m01lab__sub",
|
||||
children: [
|
||||
el("td", { text: tf("Sub"), attrs: { colspan: "2" } }),
|
||||
el("td", { className: "m01-logic__money", text: g.subText }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
return el("div", {
|
||||
className: "m01flow__group",
|
||||
children: [
|
||||
el("h4", { text: tf(GROUP_TITLE[g.key]) }),
|
||||
el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "m01-logic__grid",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [tf("HName"), tf("HUnit"), tf("HValue")].map((h) =>
|
||||
el("th", { text: h }),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("tbody", {
|
||||
children: [
|
||||
...g.lines.map((l) =>
|
||||
el("tr", {
|
||||
children: [
|
||||
el("td", { text: l.name }),
|
||||
el("td", { text: l.unit }),
|
||||
el("td", { className: "m01-logic__money", text: l.text }),
|
||||
],
|
||||
}),
|
||||
),
|
||||
...sub,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/** 함수 상자 하나의 글 줄 — 식 보기가 있으면 두 줄(+ 갈래) · 없으면 이름 = 값 */
|
||||
function funcBlock(f: FlowFunc, lines: FormulaLine[] | null): HTMLElement {
|
||||
const unit = f.unit && f.unit !== "-" ? ` ${f.unit}` : "";
|
||||
const head = el("p", { className: "m01flow__func", text: `${f.name} = ${f.text}${unit}` });
|
||||
if (!lines?.length) return el("div", { className: "m01flow__block", children: [head] });
|
||||
return el("div", {
|
||||
className: "m01flow__block",
|
||||
children: [
|
||||
head,
|
||||
...lines.flatMap((l) => [
|
||||
el("p", { className: "m01lab__expr", text: `${l.name} = ${l.named}` }),
|
||||
el("p", { className: "m01lab__expr", text: `${l.name} = ${l.valued}` }),
|
||||
...(l.branches ?? []).map((b) =>
|
||||
el("p", {
|
||||
className: `m01flow__branch${b.now ? " m01flow__branch--now" : ""}`,
|
||||
text: `${b.now ? "▶ " : ""}${b.when} → ${b.then}`,
|
||||
}),
|
||||
),
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 왼쪽 「지금 내용」 — 단가산출 상세 + 텍스트 수식을 `host` 에 그림(`values` = 입력 값 · 다시 부르면 다시 그림) */
|
||||
export function buildFlowView(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
values: CfValues,
|
||||
opts: { logics?: CfEvalOptions["logics"]; result?: CfEvalResult } = {},
|
||||
): void {
|
||||
let ev: CfEvalResult;
|
||||
try {
|
||||
ev = opts.result ?? evalLogic(lg, values, { logics: opts.logics });
|
||||
} catch (e) {
|
||||
host.replaceChildren(
|
||||
section(tf("Detail"), [el("p", { className: "m01-logic__muted", text: String(e) })]),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const rows = flowRows(lg, ev, { item: itemRoundNow(), total: totalRoundNow() });
|
||||
const detail = section(tf("Detail"), [
|
||||
...(rows.groups.length
|
||||
? rows.groups.map(groupTable)
|
||||
: [el("p", { className: "m01-logic__muted", text: tf("None") })]),
|
||||
el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": String(rows.total) },
|
||||
text: `${tf("Total")} ${rows.totalText}`,
|
||||
}),
|
||||
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));
|
||||
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];
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -206,6 +206,25 @@ export async function cfLogicValue(
|
||||
return send("/logic-value", { key, designs });
|
||||
}
|
||||
|
||||
/** 마스터 로직의 흐름 — 줄에 「흐름」 이 있으면 그대로(converted false) · 없으면 옛 로직을 흐름도로 바꿔 읽음(converted true ·
|
||||
* notes = 못 옮긴 곳 쉬운 말 · 파일은 안 바뀜) — `GET /api/m01/logic/flow` */
|
||||
export async function cfLogicFlow(
|
||||
file: string,
|
||||
key: string,
|
||||
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}/m01/logic/flow?${new URLSearchParams({ file, key })}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
throw new CfError(0, "");
|
||||
}
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
|
||||
if (!response.ok) throw new CfError(response.status, data.detail ?? response.statusText);
|
||||
return data as { flow: CfLogic; converted: boolean; notes: string[] };
|
||||
}
|
||||
|
||||
/** 설계 때 정할 것 — 변수 · 재료 자리 · 다른 로직 받는 알약(프로젝트 일위대가가 쓸 꼴) */
|
||||
export async function cfDesignInputs(key: string): Promise<CfDesign[]> {
|
||||
const qs = new URLSearchParams({ key }).toString();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_CellTable.ts
|
||||
* 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기
|
||||
* 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfBox, CfLogic } from "./M02_DF_Model";
|
||||
import { portText } from "./M02_DF_Detail";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["칸 연결 표", "Cell connections"],
|
||||
Cell: ["칸", "Cell"],
|
||||
Pill: ["연결 알약", "Pill"],
|
||||
Kind: ["종류", "Kind"],
|
||||
Line: ["이어진 선", "Line"],
|
||||
In: ["입력", "Input"],
|
||||
Var: ["변수", "Variable"],
|
||||
Out: ["결과", "Result"],
|
||||
None: ["아직 연결된 칸이 없음", "No cell is connected yet"],
|
||||
Unplaced: ["칸 없음", "no cell"],
|
||||
NoLine: ["-", "-"],
|
||||
Release: ["풀기", "Release"],
|
||||
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
|
||||
} as const;
|
||||
|
||||
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
export type CellKind = "in" | "var" | "out";
|
||||
|
||||
export interface CellTableHandlers {
|
||||
/** 표에서 칸을 바꿈 — key = 알약 이름 */
|
||||
move(kind: CellKind, key: string, cell: string): void;
|
||||
/** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */
|
||||
release(kind: CellKind, key: string): void;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
kind: CellKind;
|
||||
key: string;
|
||||
cell: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
function rowsOf(lg: CfLogic, box: CfBox): Row[] {
|
||||
const f = box.func;
|
||||
if (!f) return [];
|
||||
const into = (port: string): string => {
|
||||
const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port);
|
||||
return l ? portText(lg, l.from) : tt("NoLine");
|
||||
};
|
||||
const out = (): string => {
|
||||
const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name);
|
||||
return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine");
|
||||
};
|
||||
return [
|
||||
...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })),
|
||||
...f.variables.map((v): Row => ({
|
||||
kind: "var",
|
||||
key: v.name,
|
||||
cell: v.cell ?? "",
|
||||
line: tt("NoLine"),
|
||||
})),
|
||||
{ kind: "out", key: f.output.name, cell: f.output.cell, line: out() },
|
||||
];
|
||||
}
|
||||
|
||||
/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */
|
||||
export function renderCellTable(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
box: CfBox,
|
||||
readOnly: boolean,
|
||||
handlers: CellTableHandlers,
|
||||
): void {
|
||||
host.classList.add("m02-dfcells");
|
||||
const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key);
|
||||
const head = el("tr", {
|
||||
children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) =>
|
||||
el("th", { text: t }),
|
||||
),
|
||||
});
|
||||
const body = rows.map((r) => {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm m02-dfcells__cell",
|
||||
attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") },
|
||||
});
|
||||
input.value = r.cell;
|
||||
input.readOnly = readOnly;
|
||||
input.addEventListener("change", () => {
|
||||
const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
input.title = m ? "" : tt("BadCell");
|
||||
if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`);
|
||||
});
|
||||
return el("tr", {
|
||||
className: `m02-dfcells__row is-${r.kind}`,
|
||||
children: [
|
||||
el("td", { children: [input] }),
|
||||
el("td", { text: r.key }),
|
||||
el("td", {
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfcells__kind",
|
||||
text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("td", { text: r.line }),
|
||||
el("td", {
|
||||
children:
|
||||
readOnly || (r.kind !== "var" && !r.cell)
|
||||
? []
|
||||
: [
|
||||
createButton({
|
||||
label: tt("Release"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => handlers.release(r.kind, r.key),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-dfcells__title", text: tt("Title") }),
|
||||
el("table", {
|
||||
className: "m02-dfcells__table",
|
||||
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -320,7 +320,7 @@ export function renderDiagram(
|
||||
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 }));
|
||||
Object.assign(st, fitView(rects, { w: r.width, h: r.height }, 40, opts.fitMin));
|
||||
applyView();
|
||||
drawLines();
|
||||
};
|
||||
|
||||
@@ -122,6 +122,8 @@ export interface DiagramOpts {
|
||||
readOnly?: boolean;
|
||||
/** 바뀌면 다시 맞춤(자리를 옮겨 끼운 뒤 · 새 로직) */
|
||||
fitKey?: number;
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 없으면 FIT_MIN(좁은 패널은 더 작게) */
|
||||
fitMin?: number;
|
||||
}
|
||||
|
||||
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
|
||||
@@ -193,13 +195,14 @@ export function fitView(
|
||||
rects: { x: number; y: number; w: number; h: number }[],
|
||||
view: { w: number; h: number },
|
||||
pad = 40,
|
||||
min = FIT_MIN,
|
||||
): { zoom: number; panX: number; panY: number } {
|
||||
if (!rects.length) return { zoom: 1, panX: 0, panY: 0 };
|
||||
const x0 = Math.min(...rects.map((r) => r.x)) - pad;
|
||||
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.max(FIT_MIN, Math.min(view.w / (x1 - x0), view.h / (y1 - y0), 1.5)));
|
||||
const zoom = clampZoom(Math.max(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;
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
/* 시작 화면 */
|
||||
.m02-df__start {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,11 @@ const loadMods = () =>
|
||||
import("./M02_DF_FuncPanel"),
|
||||
import("./M02_DF_OutputPanel"),
|
||||
import("./M02_DF_Summary"),
|
||||
import("./M02_DF_Sample"),
|
||||
import("./M02_DF_InputPanel"),
|
||||
import("./M02_DF_PillPanel"),
|
||||
import("./M02_DF_LinkPanel"),
|
||||
import("./M02_DF_Standards"),
|
||||
]).then(([model, ev, api, diagram, func, output, summary, sample, input, pill, line, std]) => ({
|
||||
]).then(([model, ev, api, diagram, func, output, summary, input, pill, line, std]) => ({
|
||||
model,
|
||||
ev,
|
||||
api,
|
||||
@@ -60,7 +59,6 @@ const loadMods = () =>
|
||||
func,
|
||||
output,
|
||||
summary,
|
||||
sample,
|
||||
input,
|
||||
pill,
|
||||
line,
|
||||
@@ -78,7 +76,6 @@ const TEXT = {
|
||||
Start_New: ["새로 만들기", "New"],
|
||||
Start_Mine: ["내 로직", "My logics"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Samples: ["견본 — 복사해서 열기", "Samples — open a copy"],
|
||||
Name: ["로직 이름", "Logic name"],
|
||||
NamePlaceholder: ["예: 메쌓기 내 방식", "e.g. my dry stone wall"],
|
||||
Unit: ["결과 단위", "Result unit"],
|
||||
@@ -217,7 +214,6 @@ function openDataFlow(): void {
|
||||
paper = null;
|
||||
strip.replaceChildren();
|
||||
const mineHost = el("div", { className: "m02-df__list", children: [muted(te("Loading"))] });
|
||||
const sampleHost = el("div", { className: "m02-df__list" });
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-df__start",
|
||||
@@ -232,28 +228,10 @@ function openDataFlow(): void {
|
||||
mineHost,
|
||||
],
|
||||
}),
|
||||
el("section", {
|
||||
className: "m02-df__start-col",
|
||||
children: [el("h3", { text: te("Start_Samples") }), sampleHost],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
const M = await mods;
|
||||
sampleHost.replaceChildren(
|
||||
...M.sample.sampleList().map((s) =>
|
||||
listRow(
|
||||
s.name,
|
||||
s.resultUnit,
|
||||
() =>
|
||||
run(async (M2) => {
|
||||
const lg = M2.sample.sampleLoad(s.id);
|
||||
if (lg) await showPaper(M2, lg, false);
|
||||
}),
|
||||
s.note,
|
||||
),
|
||||
),
|
||||
);
|
||||
try {
|
||||
const mine = (await M.api.cfList()).filter((x: CfListItem) => !x.sample);
|
||||
mineHost.replaceChildren(
|
||||
@@ -379,7 +357,7 @@ function openDataFlow(): void {
|
||||
if (sig === logicSig) return;
|
||||
logicSig = sig;
|
||||
const get = async (key: string): Promise<CfLogic | null> =>
|
||||
key.startsWith("DF-") ? M.api.cfGet(key) : M.sample.sampleLoad(key);
|
||||
key.startsWith("DF-") ? M.api.cfGet(key) : Promise.resolve(null);
|
||||
void loadLogics(lg, get).then((got) => {
|
||||
if (sig !== logicSig) return;
|
||||
logics = got.logics;
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FormulaView.ts
|
||||
* 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 ·
|
||||
* IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text";
|
||||
import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { CfBox } from "./M02_DF_Model";
|
||||
|
||||
export type CellScalar = number | string | boolean | null;
|
||||
|
||||
export interface FormulaBranch {
|
||||
when: string;
|
||||
then: string;
|
||||
/** 지금 타는 갈래 */
|
||||
now: boolean;
|
||||
}
|
||||
|
||||
export interface FormulaLine {
|
||||
/** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */
|
||||
name: string;
|
||||
/** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */
|
||||
named: string;
|
||||
/** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */
|
||||
valued: string;
|
||||
/** IF 면 만약 / 아니면 줄 */
|
||||
branches?: FormulaBranch[];
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Title: ["식 보기", "Formulas"],
|
||||
Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"],
|
||||
Else: ["아니면", "Otherwise"],
|
||||
If: ["만약", "If"],
|
||||
Now: ["지금", "now"],
|
||||
} as const;
|
||||
|
||||
const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const REF = /(?<![A-Za-z0-9_.'!])\$?([A-Z]{1,3})\$?(\d+)(?![A-Za-z0-9_(])/g;
|
||||
|
||||
/** 따옴표 안 글은 그대로 두고 바깥 글만 바꿈 */
|
||||
function outside(text: string, fn: (part: string) => string): string {
|
||||
return text
|
||||
.split(/("(?:[^"]|"")*")/)
|
||||
.map((p, i) => (i % 2 ? p : fn(p)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, "");
|
||||
|
||||
/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */
|
||||
export function cellNames(box: CfBox): Record<string, string> {
|
||||
const f = box.func;
|
||||
const out: Record<string, string> = {};
|
||||
if (!f) return out;
|
||||
for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port;
|
||||
for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name;
|
||||
if (f.output.cell) out[upper(f.output.cell)] = f.output.name;
|
||||
return out;
|
||||
}
|
||||
|
||||
const refsIn = (formula: string): string[] => {
|
||||
const found: string[] = [];
|
||||
outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m)));
|
||||
return found;
|
||||
};
|
||||
|
||||
const OPS: [RegExp, string][] = [
|
||||
[/\*/g, " × "],
|
||||
[/\//g, " ÷ "],
|
||||
[/<>/g, " ≠ "],
|
||||
[/>=/g, " ≥ "],
|
||||
[/<=/g, " ≤ "],
|
||||
[/(?<![<>≠≥≤])=/g, " = "],
|
||||
[/\+/g, " + "],
|
||||
[/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "],
|
||||
];
|
||||
|
||||
/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */
|
||||
function pretty(text: string): string {
|
||||
return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p))
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const fmt = (n: number): string =>
|
||||
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
function showScalar(v: CellScalar | undefined): string {
|
||||
if (v === undefined || v === null) return "0";
|
||||
if (typeof v === "number") return fmt(v);
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return `"${v}"`;
|
||||
}
|
||||
|
||||
/** 이름으로 쓴 식 */
|
||||
export function namedText(formula: string, names: Record<string, string>): string {
|
||||
return pretty(
|
||||
outside(formula, (p) =>
|
||||
p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 값 넣은 식(값이 문자면 따옴표) */
|
||||
export function valuedText(formula: string, values: Record<string, CellScalar>): string {
|
||||
return pretty(
|
||||
outside(formula, (p) =>
|
||||
p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 갈래 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const num = (v: CellScalar | undefined): number | null => {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */
|
||||
function holds(cond: string, values: Record<string, CellScalar>): boolean | null {
|
||||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond);
|
||||
if (!m) return null;
|
||||
const read = (t: string): CellScalar | undefined => {
|
||||
const x = t.trim();
|
||||
const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x);
|
||||
if (cell) return values[`${cell[1]}${cell[2]}`];
|
||||
const q = /^"((?:[^"]|"")*)"$/.exec(x);
|
||||
if (q) return q[1].replace(/""/g, '"');
|
||||
return Number.isFinite(Number(x)) ? Number(x) : undefined;
|
||||
};
|
||||
const a = read(m[1]);
|
||||
const b = read(m[3]);
|
||||
if (a === undefined || b === undefined) return null;
|
||||
const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)];
|
||||
switch (m[2]) {
|
||||
case "=":
|
||||
return l === r;
|
||||
case "<>":
|
||||
return l !== r;
|
||||
case ">=":
|
||||
return l >= r;
|
||||
case "<=":
|
||||
return l <= r;
|
||||
case ">":
|
||||
return l > r;
|
||||
default:
|
||||
return l < r;
|
||||
}
|
||||
}
|
||||
|
||||
/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */
|
||||
function branchesOf(
|
||||
formula: string,
|
||||
names: Record<string, string>,
|
||||
values: Record<string, CellScalar>,
|
||||
): FormulaBranch[] | undefined {
|
||||
const out: FormulaBranch[] = [];
|
||||
let rest = formula;
|
||||
let taken = false;
|
||||
for (;;) {
|
||||
const body = ifBody(rest);
|
||||
if (body === null) break;
|
||||
const a = splitArgs(body);
|
||||
if (a.length !== 3) return undefined;
|
||||
const ok = holds(a[0], values) === true && !taken;
|
||||
if (ok) taken = true;
|
||||
out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok });
|
||||
rest = a[2];
|
||||
}
|
||||
if (!out.length) return undefined;
|
||||
out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 줄 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */
|
||||
export function formulaLines(box: CfBox, values: Record<string, CellScalar>): FormulaLine[] {
|
||||
const f = box.func;
|
||||
if (!f?.output.cell) return [];
|
||||
const names = cellNames(box);
|
||||
const cells = f.sheet.칸;
|
||||
const order: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (a1: string): void => {
|
||||
const key = upper(a1);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const formula = cells[key]?.식;
|
||||
if (formula === undefined) return;
|
||||
for (const r of refsIn(formula)) visit(r);
|
||||
order.push(key);
|
||||
};
|
||||
visit(f.output.cell);
|
||||
return order.map((key) => {
|
||||
const formula = cells[key].식 as string;
|
||||
const result = showScalar(values[key]);
|
||||
const branches = branchesOf(formula, names, values);
|
||||
return {
|
||||
name: names[key] ?? key,
|
||||
named: namedText(formula, names),
|
||||
valued: `${valuedText(formula, values)} = ${result}`,
|
||||
...(branches ? { branches } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */
|
||||
export function cellValuesOf(
|
||||
box: CfBox,
|
||||
arrived: Record<string, unknown> | undefined,
|
||||
): Record<string, CellScalar> {
|
||||
const f = box.func;
|
||||
if (!f) return {};
|
||||
const sheet = structuredClone(f.sheet);
|
||||
const scalar = (v: unknown): number | string | boolean | undefined =>
|
||||
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined;
|
||||
const put = (cell: string, v: number | string | boolean | undefined): void => {
|
||||
if (!parseA1(cell) || v === undefined) return;
|
||||
sheet.칸[upper(cell)] = { 값: v };
|
||||
};
|
||||
for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port]));
|
||||
for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example);
|
||||
const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
|
||||
const out: Record<string, CellScalar> = {};
|
||||
for (const [key, cell] of Object.entries(sheet.칸)) {
|
||||
const c: CalcValue | undefined = calc[sheet.id]?.[key];
|
||||
out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromCalc(v: CalcValue): CellScalar {
|
||||
if ("수" in v) return Number(v.수);
|
||||
if ("글" in v) return v.글;
|
||||
if ("참" in v) return v.참;
|
||||
return v.오류;
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */
|
||||
export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void {
|
||||
host.classList.add("m02-df-formula");
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-df-formula__title", text: tv("Title") }),
|
||||
...(lines.length
|
||||
? lines.map((l) =>
|
||||
el("div", {
|
||||
className: "m02-df-formula__line",
|
||||
children: [
|
||||
el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }),
|
||||
el("div", { className: "m02-df-formula__valued", text: l.valued }),
|
||||
...(l.branches
|
||||
? [
|
||||
el("ul", {
|
||||
className: "m02-df-formula__branches",
|
||||
children: l.branches.map((b, i) =>
|
||||
el("li", {
|
||||
className: b.now ? "is-now" : "",
|
||||
text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
)
|
||||
: [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FuncExtra.ts
|
||||
* 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것
|
||||
* 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition";
|
||||
import { renderCellTable } from "./M02_DF_CellTable";
|
||||
import { evalSafe, type DetailOptions } from "./M02_DF_Detail";
|
||||
import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView";
|
||||
import { TEXT } from "./M02_DF_FuncText";
|
||||
import { valueChoices, type CfFunc, type CfLogic } from "./M02_DF_Model";
|
||||
|
||||
export interface ExtraContext {
|
||||
lg: CfLogic;
|
||||
boxId: string;
|
||||
detail: DetailOptions;
|
||||
readOnly: boolean;
|
||||
place(port: string, cell: string): void;
|
||||
setOutput(cell: string): void;
|
||||
patch(make: (f: CfFunc) => CfFunc): void;
|
||||
used(f: CfFunc): Map<string, string>;
|
||||
}
|
||||
|
||||
const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0];
|
||||
const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId);
|
||||
|
||||
/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */
|
||||
export function drawFormula(host: HTMLElement, x: ExtraContext): void {
|
||||
const box = boxOf(x);
|
||||
if (!box?.func) return;
|
||||
const ev = evalSafe(x.lg, x.detail);
|
||||
renderFormulaView(host, formulaLines(box, cellValuesOf(box, ev?.arrived?.[x.boxId])));
|
||||
}
|
||||
|
||||
/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */
|
||||
export function drawCells(host: HTMLElement, x: ExtraContext): void {
|
||||
const box = boxOf(x);
|
||||
if (!box?.func) return;
|
||||
renderCellTable(host, x.lg, box, x.readOnly, {
|
||||
move: (kind, key, cell) => {
|
||||
if (kind === "in") return x.place(key, cell);
|
||||
if (kind === "out") return x.setOutput(cell);
|
||||
if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error");
|
||||
x.patch((f) => ({
|
||||
...f,
|
||||
variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)),
|
||||
}));
|
||||
},
|
||||
release: (kind, key) =>
|
||||
x.patch((f) =>
|
||||
kind === "in"
|
||||
? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) }
|
||||
: kind === "var"
|
||||
? { ...f, variables: f.variables.filter((v) => v.name !== key) }
|
||||
: { ...f, output: { ...f.output, cell: "" } },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸 */
|
||||
export function conditionsOf(x: ExtraContext): ConditionOptions {
|
||||
return {
|
||||
valuesFor: (a1) => {
|
||||
const f = boxOf(x)?.func;
|
||||
const at = a1.toUpperCase();
|
||||
const v = f?.variables.find((y) => y.cell?.toUpperCase() === at);
|
||||
if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null;
|
||||
const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port;
|
||||
const link = port
|
||||
? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port)
|
||||
: undefined;
|
||||
return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null;
|
||||
},
|
||||
pills: () => {
|
||||
const f = boxOf(x)?.func;
|
||||
return [
|
||||
...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })),
|
||||
...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -111,11 +111,11 @@ button.m02-dfpanel-chip:focus-visible {
|
||||
}
|
||||
|
||||
.m02-dfpanel-chip.is-out {
|
||||
border-color: #f59e0b;
|
||||
border-color: #27ae60;
|
||||
}
|
||||
|
||||
.m02-dfpanel-chip.is-var {
|
||||
border-color: #22a06b;
|
||||
border-color: #d99a00;
|
||||
}
|
||||
|
||||
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
|
||||
@@ -133,11 +133,11 @@ button.m02-dfpanel-chip:focus-visible {
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind="var"] {
|
||||
box-shadow: inset 0 0 0 2px #22a06b;
|
||||
box-shadow: inset 0 0 0 2px #d99a00;
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind="out"] {
|
||||
box-shadow: inset 0 0 0 2px #f59e0b;
|
||||
box-shadow: inset 0 0 0 2px #27ae60;
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind]::after {
|
||||
@@ -170,3 +170,112 @@ button.m02-dfpanel-chip:focus-visible {
|
||||
outline: 2px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */
|
||||
.m02-dfpanel-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-dfpanel-body > .m02-dfpanel-sheet {
|
||||
flex: 1 1 28em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-dfcells {
|
||||
flex: 0 1 24em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-dfcells__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-dfcells__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dfcells__table th,
|
||||
.m02-dfcells__table td {
|
||||
padding: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m02-dfcells__cell {
|
||||
width: 5em;
|
||||
}
|
||||
|
||||
.m02-dfcells .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m02-dfcells__kind {
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 2px solid;
|
||||
border-radius: var(--radius-pills);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-dfcells__row.is-in .m02-dfcells__kind {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.m02-dfcells__row.is-var .m02-dfcells__kind {
|
||||
border-color: #d99a00;
|
||||
}
|
||||
|
||||
.m02-dfcells__row.is-out .m02-dfcells__kind {
|
||||
border-color: #27ae60;
|
||||
}
|
||||
|
||||
/* 식 보기 */
|
||||
.m02-df-formula {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m02-df-formula__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-df-formula__line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: var(--spacing-4);
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.m02-df-formula__valued {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-df-formula__branches {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m02-df-formula__branches .is-now {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-df-formula__empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
Workbook,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
|
||||
import type { DetailOptions } from "./M02_DF_Detail";
|
||||
import { conditionsOf, drawCells, drawFormula, type ExtraContext } from "./M02_DF_FuncExtra";
|
||||
import { createRoundRow } from "./M02_DF_RoundRow";
|
||||
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
|
||||
@@ -37,6 +39,8 @@ interface State {
|
||||
lg: CfLogic;
|
||||
on: Handlers;
|
||||
readOnly: boolean;
|
||||
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
|
||||
detail: DetailOptions;
|
||||
handle: SpreadsheetHandle | null;
|
||||
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
|
||||
snap: CalcValues;
|
||||
@@ -48,6 +52,8 @@ interface State {
|
||||
suggest: number | null;
|
||||
parts: {
|
||||
head: HTMLElement;
|
||||
formula: HTMLElement;
|
||||
cells: HTMLElement;
|
||||
got: HTMLElement;
|
||||
vars: HTMLElement;
|
||||
bar: HTMLElement;
|
||||
@@ -201,6 +207,20 @@ function problems(st: State): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */
|
||||
|
||||
/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */
|
||||
const extra = (st: State): ExtraContext => ({
|
||||
lg: st.lg,
|
||||
boxId: st.boxId,
|
||||
detail: st.detail,
|
||||
readOnly: st.readOnly,
|
||||
place: (port, cell) => placePort(st, port, cell),
|
||||
setOutput: (cell) => setOutput(st, cell),
|
||||
patch: (make) => patchFunc(st, make),
|
||||
used: (f) => usedCells(f),
|
||||
});
|
||||
|
||||
/* ------------------------------ 칸 위 표시 ------------------------------ */
|
||||
|
||||
function applyMarks(st: State): void {
|
||||
@@ -563,6 +583,7 @@ async function mountSheet(st: State): Promise<void> {
|
||||
preview: true,
|
||||
tabs: false,
|
||||
readOnly: st.readOnly,
|
||||
conditions: conditionsOf(extra(st)),
|
||||
onChange: (b) => onBook(st, b),
|
||||
onSelect: (s) => {
|
||||
st.cell = s.칸;
|
||||
@@ -593,13 +614,14 @@ export function renderFuncPanel(
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: Handlers,
|
||||
opts: { readOnly?: boolean } = {},
|
||||
opts: DetailOptions & { readOnly?: boolean } = {},
|
||||
): void {
|
||||
const old = states.get(host);
|
||||
const readOnly = !!opts.readOnly;
|
||||
if (old && old.boxId === boxId && old.readOnly === readOnly) {
|
||||
old.lg = lg;
|
||||
old.on = on;
|
||||
old.detail = opts;
|
||||
const f = funcOf(old);
|
||||
if (old.handle && f && sheetJson(f) !== old.json) {
|
||||
old.json = sheetJson(f);
|
||||
@@ -615,6 +637,8 @@ export function renderFuncPanel(
|
||||
}
|
||||
const parts: State["parts"] = {
|
||||
head: el("div", { className: "m02-dfpanel-head" }),
|
||||
formula: el("div"),
|
||||
cells: el("div"),
|
||||
got: el("div", { className: "m02-dfpanel-row" }),
|
||||
vars: el("div", { className: "m02-dfpanel-vars" }),
|
||||
bar: el("div", { className: "m02-dfpanel-bar" }),
|
||||
@@ -626,6 +650,7 @@ export function renderFuncPanel(
|
||||
lg,
|
||||
on,
|
||||
readOnly,
|
||||
detail: opts,
|
||||
handle: null,
|
||||
snap: {},
|
||||
json: "",
|
||||
@@ -639,12 +664,22 @@ export function renderFuncPanel(
|
||||
drawGot(st);
|
||||
drawVars(st);
|
||||
drawBar(st);
|
||||
drawFormula(st.parts.formula, extra(st));
|
||||
drawCells(st.parts.cells, extra(st));
|
||||
applyMarks(st);
|
||||
},
|
||||
};
|
||||
states.set(host, st);
|
||||
host.classList.add("m02-dfpanel");
|
||||
host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet);
|
||||
host.replaceChildren(
|
||||
parts.head,
|
||||
parts.formula,
|
||||
parts.got,
|
||||
parts.vars,
|
||||
parts.bar,
|
||||
parts.msg,
|
||||
el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }),
|
||||
);
|
||||
parts.msg.hidden = true;
|
||||
st.redraw();
|
||||
void mountSheet(st);
|
||||
|
||||
@@ -1,42 +1,87 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_InputPanel_Logic.ts
|
||||
* 입력 판 — 입력 갈래 「다른 로직」: 저장한 흐름도 로직(또는 견본)을 찾아 고르면
|
||||
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값)
|
||||
* 입력 판 — 입력 갈래 「다른 로직」: 마스터 로직 전체(원문 · 장 · 찾기 · 쪽)와 내가 만든 흐름도 로직 가운데 하나를 고르면
|
||||
* 그 로직의 출력 = 내보내는 알약 · 설계 때 정할 것 = 받는 알약(이어 주지 않으면 그 로직의 예시값) · 값은 서버 계산 길
|
||||
* 옛 로직은 서버가 흐름도로 바꿔 읽음(`GET /api/m01/logic/flow` · 파일은 안 바뀜) · 자기 자신 · 돌고 도는 로직은 못 고름
|
||||
* 글 [한, 영] 짝은 아래 TEXT · 단추는 공용 작은 크기
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
||||
import { cfGet, cfList, cfPlain, type CfListItem } from "./M02_DF_Api";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
import { logicRefOf, setKind, setLogicInput } from "./M02_DF_InputPanel_Build";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { cfGet, cfList, cfLogicFlow, cfLogicValue, cfPlain } from "./M02_DF_Api";
|
||||
import type { CfLogic, CfLogicRef } from "./M02_DF_Model";
|
||||
import {
|
||||
filterLogics,
|
||||
logicBooks,
|
||||
logicChapters,
|
||||
logicRefOf,
|
||||
masterRef,
|
||||
pageRange,
|
||||
setKind,
|
||||
setLogicInput,
|
||||
wouldCycle,
|
||||
type MasterLogicBrief,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
|
||||
const TEXT = {
|
||||
Help: [
|
||||
"저장한 로직이나 견본을 불러와 그 결과를 이 상자의 알약으로 씀",
|
||||
"Use a saved logic or sample as an input",
|
||||
"마스터 로직이나 내가 만든 흐름도 로직을 불러와 그 결과를 이 상자의 알약으로 씀",
|
||||
"Use any master logic or a logic of mine as an input",
|
||||
],
|
||||
Search: ["로직 이름으로 찾기", "Search by name"],
|
||||
Search: ["로직 이름 · 번호 찾기 — 띄어 쓰면 모두 들어간 것", "Search — all words must match"],
|
||||
Book: ["원문", "Source"],
|
||||
Chapter: ["장", "Chapter"],
|
||||
All: ["전체", "All"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
None: ["찾는 로직 없음", "No logic found"],
|
||||
Sample: ["견본", "Sample"],
|
||||
Total: ["전체 {n} 개 중 {a}~{b} · {p} / {m} 쪽", "{a}-{b} of {n} · page {p} / {m}"],
|
||||
Prev: ["이전", "Prev"],
|
||||
Next: ["다음", "Next"],
|
||||
Mine: ["내가 만든 것", "Mine"],
|
||||
Blocked: ["읽을 수 없음", "Unreadable"],
|
||||
Chosen: ["고른 로직", "Chosen logic"],
|
||||
Outs: ["내보내는 알약 — 그 로직의 출력", "Pills it sends — its outputs"],
|
||||
Converted: [
|
||||
"옛 로직을 흐름도로 바꿔 읽음 — 파일은 안 바뀜",
|
||||
"Old logic read as a flow — the file is unchanged",
|
||||
],
|
||||
Notes: ["못 옮긴 곳", "Not carried over"],
|
||||
Outs: ["내보내는 알약 — 그 로직의 출력 · 값 · 단위", "Pills it sends — its outputs"],
|
||||
Designs: [
|
||||
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 그 로직의 예시값)",
|
||||
"받는 알약 — 그 로직의 설계 때 정할 것(이어 주지 않으면 예시값)",
|
||||
"Pills it takes — its design inputs",
|
||||
],
|
||||
NoOut: ["출력 상자가 없음", "No output box"],
|
||||
NoDesign: ["설계 때 정할 것이 없음", "Nothing to decide"],
|
||||
Change: ["다른 로직 고르기", "Pick another"],
|
||||
Remove: ["빼기", "Remove"],
|
||||
Self: ["이 로직 자신은 못 씀", "Can't use itself"],
|
||||
Self: ["이 로직 자신은 못 고름", "Can't pick itself"],
|
||||
Cycle: [
|
||||
"돌고 도는 로직이 됨 — 이 로직을 부르는 로직은 못 고름",
|
||||
"That would loop — a logic that uses this one can't be picked",
|
||||
],
|
||||
NoFlow: [
|
||||
"이 로직의 흐름을 아직 못 읽음 — 서버 흐름 받기 길이 없음",
|
||||
"Can't read this logic's flow yet",
|
||||
],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
Wait: ["값 셈 중…", "Calculating…"],
|
||||
} as const;
|
||||
|
||||
type TKey = keyof typeof TEXT;
|
||||
const t = (key: TKey): string => TEXT[key][currentLanguageIndex] as string;
|
||||
const t = (key: TKey, v: Record<string, string | number> = {}): string =>
|
||||
Object.entries(v).reduce(
|
||||
(s, [k, x]) => s.replace(`{${k}}`, String(x)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const fmt = (n: number): string => Number(n.toPrecision(8)).toLocaleString("ko-KR");
|
||||
|
||||
const plain = (e: unknown): string => {
|
||||
try {
|
||||
@@ -46,6 +91,40 @@ const plain = (e: unknown): string => {
|
||||
}
|
||||
};
|
||||
|
||||
const PAGE = 12;
|
||||
const MINE = "흐름도";
|
||||
|
||||
/** 마스터 로직 목록 한 번 + 내 흐름도 로직(견본은 뺌) */
|
||||
let cache: Promise<MasterLogicBrief[]> | null = null;
|
||||
const loadAll = (): Promise<MasterLogicBrief[]> =>
|
||||
(cache ??= Promise.all([fetchLogics(), cfList().catch(() => [])])
|
||||
.then(([master, mine]) => [
|
||||
...mine
|
||||
.filter((i) => !i.sample)
|
||||
.map((i): MasterLogicBrief => ({
|
||||
file: MINE,
|
||||
구분: MINE,
|
||||
상세구분: t("Mine"),
|
||||
키: i.key,
|
||||
원문번호: "",
|
||||
이름: i.name,
|
||||
결과단위: i.resultUnit,
|
||||
})),
|
||||
...master,
|
||||
])
|
||||
.catch((e: unknown) => ((cache = null), Promise.reject(e))));
|
||||
|
||||
/** 고른 로직에서 읽은 것 — 옛 로직을 바꿔 읽었나 · 못 옮긴 곳(화면에만 · 저장 안 함) */
|
||||
const readNotes = new Map<string, { converted: boolean; notes: string[] }>();
|
||||
|
||||
/** 로직 하나를 읽어 흐름도로 — 내 흐름도 로직 = 서버 저장 그대로 · 마스터 = 흐름 받기 길 */
|
||||
async function flowOf(
|
||||
item: Pick<MasterLogicBrief, "file" | "키">,
|
||||
): Promise<{ flow: CfLogic; converted: boolean; notes: string[] }> {
|
||||
if (item.file === MINE) return { flow: await cfGet(item.키), converted: false, notes: [] };
|
||||
return cfLogicFlow(item.file, item.키);
|
||||
}
|
||||
|
||||
/** 다른 로직 입력 판 */
|
||||
export function renderLogicInput(
|
||||
host: HTMLElement,
|
||||
@@ -55,32 +134,86 @@ export function renderLogicInput(
|
||||
): void {
|
||||
const ref = lg.boxes.find((b) => b.id === boxId)?.input?.logic;
|
||||
if (ref) return void showChosen(host, lg, boxId, ref, on);
|
||||
|
||||
const q = createInputField({ type: "search", placeholder: t("Search"), compact: true });
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
let all: CfListItem[] = [];
|
||||
const draw = (): void => {
|
||||
const word = q.input.value.trim().toLowerCase();
|
||||
const rows = all.filter(
|
||||
(i) => i.key !== lg.key && (!word || i.name.toLowerCase().includes(word)),
|
||||
const all = { value: "", text: t("All") };
|
||||
let book = "";
|
||||
let chapter = "";
|
||||
let page = 1;
|
||||
let items: MasterLogicBrief[] = [];
|
||||
const bookBox = createSelectField({
|
||||
label: t("Book"),
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
onChange: (v) => ((book = v), (chapter = ""), (page = 1), fillChapters(), draw()),
|
||||
});
|
||||
const chapterBox = createSelectField({
|
||||
label: t("Chapter"),
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
onChange: (v) => ((chapter = v), (page = 1), draw()),
|
||||
});
|
||||
const fillChapters = (): void =>
|
||||
chapterBox.setOptions(
|
||||
[all, ...logicChapters(items, book).map((c) => ({ value: c, text: c }))],
|
||||
chapter,
|
||||
);
|
||||
const list = el("div", { className: "m02-dfip__list" });
|
||||
const info = el("span", { className: "m02-dfip__muted" });
|
||||
const prev = createButton({
|
||||
label: t("Prev"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page -= 1), draw()),
|
||||
});
|
||||
const next = createButton({
|
||||
label: t("Next"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => ((page += 1), draw()),
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
const rows = filterLogics(
|
||||
items.filter((i) => i.키 !== lg.key), // 자기 자신은 목록에서 뺌
|
||||
{ text: q.input.value, book, chapter },
|
||||
);
|
||||
const range = pageRange(rows.length, page, PAGE);
|
||||
page = Math.min(page, range.pages);
|
||||
const shown = rows.slice((page - 1) * PAGE, page * PAGE);
|
||||
info.textContent = t("Total", {
|
||||
n: rows.length,
|
||||
a: range.from,
|
||||
b: range.to,
|
||||
p: page,
|
||||
m: range.pages,
|
||||
});
|
||||
prev.disabled = page <= 1;
|
||||
next.disabled = page >= range.pages;
|
||||
list.replaceChildren(
|
||||
...(rows.length
|
||||
? rows.map((i) => {
|
||||
...(shown.length
|
||||
? shown.map((i) => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfip__row",
|
||||
attrs: { type: "button" },
|
||||
attrs: {
|
||||
type: "button",
|
||||
...(i.blocked ? { disabled: "true", title: (i.reasons ?? []).join(" · ") } : {}),
|
||||
},
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfip__name",
|
||||
children: [
|
||||
el("strong", { text: i.name }),
|
||||
el("strong", { text: i.이름 }),
|
||||
el("span", { className: "m02-dfip__muted", text: ` ${i.원문번호}` }),
|
||||
el("span", {
|
||||
className: "m02-dfip__tag",
|
||||
text: i.sample ? t("Sample") : t("Mine"),
|
||||
text: i.blocked ? t("Blocked") : i.file === MINE ? t("Mine") : i.상세구분,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("span", { className: "m02-dfip__muted", text: i.resultUnit }),
|
||||
el("span", { className: "m02-dfip__muted", text: i.결과단위 }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => void pick(i));
|
||||
@@ -89,56 +222,114 @@ export function renderLogicInput(
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("None") })]),
|
||||
);
|
||||
};
|
||||
const pick = async (i: CfListItem): Promise<void> => {
|
||||
|
||||
/** 순환 검사에 쓰는 읽기 — 목록에서 파일을 찾아 흐름을 읽음 */
|
||||
const loadForCycle = async (key: string): Promise<CfLogic | null> => {
|
||||
const it = items.find((x) => x.키 === key);
|
||||
return it ? (await flowOf(it)).flow : null;
|
||||
};
|
||||
|
||||
const pick = async (i: MasterLogicBrief): Promise<void> => {
|
||||
try {
|
||||
const other = await cfGet(i.key);
|
||||
const next = setLogicInput(lg, boxId, logicRefOf(i.key, other));
|
||||
if (i.키 === lg.key) return void showToast(t("Self"), "error");
|
||||
if (await wouldCycle(lg.key, i.키, loadForCycle)) return void showToast(t("Cycle"), "error");
|
||||
const got = await flowOf(i);
|
||||
readNotes.set(i.키, { converted: got.converted, notes: got.notes });
|
||||
const picked = got.converted
|
||||
? masterRef(i.키, got.flow, true, i.결과단위)
|
||||
: { ...logicRefOf(i.키, got.flow), name: i.이름 };
|
||||
const done = setLogicInput(lg, boxId, picked);
|
||||
// 기본 이름이면 고른 로직 이름으로
|
||||
on.change({
|
||||
...next,
|
||||
boxes: next.boxes.map((b) =>
|
||||
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: other.name || b.name } : b,
|
||||
...done,
|
||||
boxes: done.boxes.map((b) =>
|
||||
b.id === boxId && /^(입력|새)/.test(b.name) ? { ...b, name: i.이름 || b.name } : b,
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
showToast(plain(e), "error");
|
||||
showToast((e as { status?: number }).status === 404 ? t("NoFlow") : plain(e), "error");
|
||||
}
|
||||
};
|
||||
q.input.addEventListener("input", draw);
|
||||
|
||||
let timer = 0;
|
||||
q.input.addEventListener("input", () => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => ((page = 1), draw()), 200);
|
||||
});
|
||||
list.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Loading") }));
|
||||
host.replaceChildren(el("p", { className: "m02-dfip__muted", text: t("Help") }), q.root, list);
|
||||
void cfList().then(
|
||||
(items) => ((all = items), draw()),
|
||||
host.replaceChildren(
|
||||
el("p", { className: "m02-dfip__muted", text: t("Help") }),
|
||||
el("div", { className: "m02-dfip__bar", children: [bookBox.root, chapterBox.root, q.root] }),
|
||||
info,
|
||||
list,
|
||||
el("div", { className: "m02-dfip__chips", children: [prev, next] }),
|
||||
);
|
||||
void loadAll().then(
|
||||
(rows) => {
|
||||
items = rows;
|
||||
bookBox.setOptions([all, ...logicBooks(rows).map((b) => ({ value: b, text: b }))], "");
|
||||
fillChapters();
|
||||
draw();
|
||||
},
|
||||
(e) => list.replaceChildren(el("p", { className: "m02-dfip__muted", text: plain(e) })),
|
||||
);
|
||||
}
|
||||
|
||||
/** 고른 로직 — 내보내는 알약 · 받는 알약 */
|
||||
/** 고른 로직 — 내보내는 알약(값 · 단위) · 받는 알약(예시값) */
|
||||
function showChosen(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
ref: NonNullable<CfLogic["boxes"][number]["input"]>["logic"],
|
||||
ref: CfLogicRef,
|
||||
on: { change(lg: CfLogic): void },
|
||||
): void {
|
||||
if (!ref) return;
|
||||
const pillLine = (p: { name: string; unit: string }): HTMLElement =>
|
||||
const read = readNotes.get(ref.key);
|
||||
const values = new Map<string, HTMLElement>();
|
||||
const errors = el("p", { className: "m02-dfip__muted is-bad" });
|
||||
const line = (name: string, unit: string, valueNode?: HTMLElement, extra = ""): HTMLElement =>
|
||||
el("div", {
|
||||
className: "m02-dfip__item",
|
||||
children: [
|
||||
el("span", { className: "m02-dfip__name", text: p.name }),
|
||||
el("span", { className: "m02-dfip__muted", text: p.unit || "-" }),
|
||||
el("span", { className: "m02-dfip__name", text: name }),
|
||||
...(valueNode
|
||||
? [valueNode]
|
||||
: extra
|
||||
? [el("span", { className: "m02-dfip__muted", text: extra })]
|
||||
: []),
|
||||
el("span", { className: "m02-dfip__muted", text: unit || "-" }),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("h5", { text: `${t("Chosen")} — ${ref.name ?? ref.key}` }),
|
||||
...(read?.converted
|
||||
? [
|
||||
el("p", { className: "m02-dfip__muted", text: t("Converted") }),
|
||||
...(read.notes.length
|
||||
? [
|
||||
el("details", {
|
||||
children: [
|
||||
el("summary", { text: `${t("Notes")} · ${read.notes.length}` }),
|
||||
...read.notes.map((n) => el("p", { text: n })),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: []),
|
||||
el("h5", { text: `${t("Outs")} · ${ref.outputs.length}` }),
|
||||
...(ref.outputs.length
|
||||
? ref.outputs.map(pillLine)
|
||||
? ref.outputs.map((o) => {
|
||||
const v = el("strong", { className: "m02-dfip__value", text: "—" });
|
||||
values.set(o.name, v);
|
||||
return line(o.name, o.unit, v);
|
||||
})
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoOut") })]),
|
||||
errors,
|
||||
el("h5", { text: `${t("Designs")} · ${ref.designs.length}` }),
|
||||
...(ref.designs.length
|
||||
? ref.designs.map(pillLine)
|
||||
? ref.designs.map((d) =>
|
||||
line(d.name, d.unit, undefined, String(ref.examples?.[d.name] ?? "")),
|
||||
)
|
||||
: [el("p", { className: "m02-dfip__muted", text: t("NoDesign") })]),
|
||||
el("div", {
|
||||
className: "m02-dfip__chips",
|
||||
@@ -158,4 +349,17 @@ function showChosen(
|
||||
],
|
||||
}),
|
||||
);
|
||||
// 값 — 서버 계산 길(받는 알약 예시값으로)
|
||||
errors.textContent = t("Wait");
|
||||
void cfLogicValue(ref.key, ref.examples ?? {}).then(
|
||||
(r) => {
|
||||
for (const [name, node] of values) {
|
||||
const v = r.outputs[name] ?? (r.cost as unknown as Record<string, unknown>)[name];
|
||||
if (typeof v === "number") node.textContent = fmt(v);
|
||||
else if (typeof v === "string") node.textContent = v;
|
||||
}
|
||||
errors.textContent = r.errors.join(" · ");
|
||||
},
|
||||
(e) => (errors.textContent = plain(e)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
gateConds,
|
||||
makeGate,
|
||||
portsOut,
|
||||
reaches,
|
||||
valueChoices,
|
||||
type CfGateCond,
|
||||
type CfGateOp,
|
||||
type CfLogic,
|
||||
type CfPort,
|
||||
@@ -77,6 +81,12 @@ const TEXT = {
|
||||
],
|
||||
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
|
||||
Count: ["선", "Line"],
|
||||
And: ["그리고", "and"],
|
||||
Or: ["또는", "or"],
|
||||
If: ["조건", "If"],
|
||||
AddLine: ["+ 조건 줄", "+ Condition line"],
|
||||
Join: ["잇는 말", "Join with"],
|
||||
OutRange: ["범위 {n} 밖", "Outside the range {n}"],
|
||||
Range: ["{n} (범위)", "{n} (range)"],
|
||||
} as const;
|
||||
|
||||
@@ -92,6 +102,7 @@ interface Ctx {
|
||||
linkId: string;
|
||||
readOnly: boolean;
|
||||
headers: string[];
|
||||
detail: DetailOptions;
|
||||
change(lg: CfLogic): void;
|
||||
}
|
||||
|
||||
@@ -240,28 +251,122 @@ function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTML
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */
|
||||
function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null {
|
||||
const v = ctx.lg.boxes
|
||||
.find((b) => b.id === p.box)
|
||||
?.input?.variables.find((x) => x.name === p.name);
|
||||
return v?.type === "number" && v.range ? v.range : null;
|
||||
}
|
||||
|
||||
/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */
|
||||
function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] {
|
||||
const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values });
|
||||
if (list?.length) {
|
||||
const key = (v: string | number): string => `${typeof v}:${v}`;
|
||||
const options = list.map((v) => ({ value: key(v), text: String(v) }));
|
||||
return [
|
||||
select(
|
||||
ctx,
|
||||
options,
|
||||
key(c.value),
|
||||
(k) => {
|
||||
const hit = list.find((v) => key(v) === k);
|
||||
if (hit !== undefined) onPick(hit);
|
||||
},
|
||||
tk("Value"),
|
||||
),
|
||||
];
|
||||
}
|
||||
const range = rangeOf(ctx, c.from);
|
||||
const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))];
|
||||
const n = Number(c.value);
|
||||
if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) {
|
||||
out.push(
|
||||
el("span", {
|
||||
className: "m02-dflink-warn",
|
||||
text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function gateEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "gate" }>): HTMLElement[] {
|
||||
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
|
||||
return [
|
||||
el("p", { className: "m02-dflink-note", text: tk("GateHint") }),
|
||||
row(
|
||||
select(
|
||||
ctx,
|
||||
pillOptions(ctx),
|
||||
toKey(rule.from),
|
||||
(v) => put({ from: fromKey(v) }),
|
||||
tk("Match"),
|
||||
const g = gateConds(rule);
|
||||
const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join));
|
||||
const edit = (i: number, patch: Partial<CfGateCond>): void =>
|
||||
put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c)));
|
||||
const out: HTMLElement[] = [el("p", { className: "m02-dflink-note", text: tk("GateHint") })];
|
||||
g.conds.forEach((c, i) => {
|
||||
out.push(
|
||||
row(
|
||||
el("span", {
|
||||
className: "m02-dflink-label",
|
||||
text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"),
|
||||
}),
|
||||
select(
|
||||
ctx,
|
||||
pillOptions(ctx),
|
||||
toKey(c.from),
|
||||
(v) => {
|
||||
const from = fromKey(v);
|
||||
edit(i, {
|
||||
from,
|
||||
value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0,
|
||||
});
|
||||
},
|
||||
tk("Match"),
|
||||
),
|
||||
select(
|
||||
ctx,
|
||||
GATE_OPS.map((o) => ({ value: o, text: o })),
|
||||
c.op,
|
||||
(v) => edit(i, { op: v as CfGateOp }),
|
||||
tk("Kinds"),
|
||||
),
|
||||
...gateValue(ctx, c, (v) => edit(i, { value: v })),
|
||||
ctx.readOnly || g.conds.length < 2
|
||||
? ""
|
||||
: createButton({
|
||||
label: tk("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => put(g.conds.filter((_, j) => j !== i)),
|
||||
}),
|
||||
),
|
||||
select(
|
||||
ctx,
|
||||
GATE_OPS.map((o) => ({ value: o, text: o })),
|
||||
rule.op,
|
||||
(v) => put({ op: v as CfGateOp }),
|
||||
tk("Kinds"),
|
||||
);
|
||||
});
|
||||
if (!ctx.readOnly) {
|
||||
const first = g.conds[0];
|
||||
out.push(
|
||||
row(
|
||||
createButton({
|
||||
label: tk("AddLine"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
put([...g.conds, { from: first?.from ?? { box: "", name: "" }, op: "=", value: 0 }]),
|
||||
}),
|
||||
...(g.conds.length > 1
|
||||
? [
|
||||
el("span", { className: "m02-dflink-label", text: tk("Join") }),
|
||||
select(
|
||||
ctx,
|
||||
[
|
||||
{ value: "그리고", text: tk("And") },
|
||||
{ value: "또는", text: tk("Or") },
|
||||
],
|
||||
g.join,
|
||||
(v) => put(g.conds, v as "그리고" | "또는"),
|
||||
tk("Join"),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
),
|
||||
numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")),
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
|
||||
@@ -323,7 +428,7 @@ function roundEditor(
|
||||
|
||||
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
|
||||
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
|
||||
gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }),
|
||||
gate: (from) => makeGate([{ from, op: "≥", value: 0 }]),
|
||||
scale: () => ({ kind: "scale", op: "×", value: 1 }),
|
||||
round: () => ({ kind: "round", places: 0, method: "반올림" }),
|
||||
};
|
||||
@@ -345,14 +450,23 @@ export function renderLinkPanel(
|
||||
Array.isArray(table) && Array.isArray(table[0])
|
||||
? table[0].map((h) => String(h ?? "")).filter(Boolean)
|
||||
: [];
|
||||
const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change };
|
||||
const ctx: Ctx = {
|
||||
lg,
|
||||
linkId,
|
||||
readOnly: !!opts.readOnly,
|
||||
headers,
|
||||
detail: opts,
|
||||
change: on.change,
|
||||
};
|
||||
const rule = link.rule;
|
||||
const kind: Kind = rule?.kind ?? "none";
|
||||
const parts: HTMLElement[] = [
|
||||
el("div", {
|
||||
className: "m02-dflink-head",
|
||||
children: [
|
||||
el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }),
|
||||
el("strong", {
|
||||
text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`,
|
||||
}),
|
||||
el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/* 오른쪽 흐름도 패널 — 부르는 쪽 칸 안에서 100% */
|
||||
.m02-dfp {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: var(--color-canvas);
|
||||
}
|
||||
|
||||
.m02-dfp__open {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
padding: var(--spacing-8) var(--spacing-12) var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-dfp__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dfp__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-dfp__tools {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
/* 좁은 패널 — 다이어그램 판 높이를 패널에 맞춤 */
|
||||
.m02-dfp .m02-dfdg__view {
|
||||
height: max(320px, 45vh);
|
||||
}
|
||||
|
||||
.m02-dfp__lock {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.m02-dfp__cost > summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
/* 왼쪽 가장자리 끌기 */
|
||||
.m02-dfp__grip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
z-index: 2;
|
||||
width: 8px;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.m02-dfp__grip:hover,
|
||||
.m02-dfp__grip:focus-visible,
|
||||
.m02-dfp.is-resizing .m02-dfp__grip {
|
||||
background: color-mix(in srgb, var(--color-accent) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.m02-dfp.is-resizing {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 접기 = 얇은 띠 */
|
||||
.m02-dfp__strip {
|
||||
width: 100%;
|
||||
padding: var(--spacing-12) 0;
|
||||
border: 0;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font: inherit;
|
||||
writing-mode: vertical-rl;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-dfp__strip:hover,
|
||||
.m02-dfp__strip:focus-visible {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m02-dfp__strip[hidden],
|
||||
.m02-dfp__open[hidden],
|
||||
.m02-dfp__grip[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 크게 보기 — 화면 전체 */
|
||||
.m02-dfp-full {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
overflow: auto;
|
||||
background: var(--color-canvas);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.m02-dfp-full__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m02-dfp-full__head strong {
|
||||
flex: 1;
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m02-dfp-full__diagram .m02-dfdg__view {
|
||||
height: max(420px, 60vh);
|
||||
}
|
||||
|
||||
.m02-dfp-full__lower {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-16);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.m02-dfp-full__lower h3 {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.m02-dfp-full__lower {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Panel.ts
|
||||
* 오른쪽 흐름도 패널(흐름도 적용 정함 5) — mountFlowPanel(host, opts) → { set, destroy, expand }
|
||||
* 패널 안 세로: 머리(제목 · 「크게 보기」 · 접기) → 다이어그램(+ 입력 · 함수 · 출력 · 실행 취소 · 맞추기) →
|
||||
* 상세 내용(상자 · 알약 · 선 판) → 원가 · 검증 칸(접기). 저장 단추 없음(페이지 [저장]) · 바뀔 때마다 onChange.
|
||||
* 너비 = 부르는 쪽 칸 안 100% · 왼쪽 가장자리 끌기 → onResize(px) · 마지막 너비 localStorage ·
|
||||
* 접기 = 얇은 띠(「흐름도 ▸」) · 크게 보기 = 화면 전체(같은 흐름 객체 · 닫으면 패널로 · 고친 것 그대로).
|
||||
* 고치는 핵심은 `M02_DF_Paper.ts`(판 DOM 을 옮겨 끼움).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
import { createPaper } from "./M02_DF_Paper";
|
||||
import "./M02_DF_Panel.css";
|
||||
|
||||
const TEXT = {
|
||||
Big: ["크게 보기", "Full screen"],
|
||||
Fold: ["접기", "Collapse"],
|
||||
Strip: ["흐름도 ▸", "Flow ▸"],
|
||||
StripTitle: ["흐름도 펴기", "Open the flow panel"],
|
||||
Close: ["닫기", "Close"],
|
||||
Detail: ["상세 내용", "Details"],
|
||||
Cost: ["원가 · 검증", "Cost · check"],
|
||||
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
|
||||
} as const;
|
||||
|
||||
const tf = (key: keyof typeof TEXT): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
|
||||
|
||||
export interface FlowPanelOpts {
|
||||
logic: CfLogic;
|
||||
readOnly?: boolean;
|
||||
title: string;
|
||||
/** 고칠 때마다 — 객체 그대로(페이지 캐시에 쌓음 · [저장] 은 페이지) */
|
||||
onChange(lg: CfLogic): void;
|
||||
/** 다른 로직 입력이 부르는 로직 */
|
||||
logics?(key: string): Promise<CfLogic | null>;
|
||||
/** 패널 너비가 바뀜(끌기 · 접기 · 펴기) — 부르는 쪽이 칸 너비를 맞춤 */
|
||||
onResize?(px: number): void;
|
||||
}
|
||||
|
||||
export interface FlowPanel {
|
||||
/** 밖에서 바꿈(고친 것 버리기 · 다른 로직 열기) */
|
||||
set(lg: CfLogic): void;
|
||||
destroy(): void;
|
||||
/** 크게 보기 열기 */
|
||||
expand(): void;
|
||||
}
|
||||
|
||||
/* ── 너비(DOM 없음) ─────────────────────────────────────────────────── */
|
||||
|
||||
export const PANEL_MIN = 320;
|
||||
export const STRIP_WIDTH = 40;
|
||||
/** 좁은 패널에서 저절로 맞출 때 가장 작은 확대 */
|
||||
export const PANEL_FIT_MIN = 0.25;
|
||||
const WIDTH_KEY = "m02.dfpanel.width";
|
||||
const FOLD_KEY = "m02.dfpanel.folded";
|
||||
|
||||
/** 끌어서 정한 너비 — 왼쪽 가장자리를 왼쪽으로 끌면 넓어짐 · 가장 작게 PANEL_MIN · 가장 크게 화면의 85% */
|
||||
export function dragWidth(start: number, startX: number, x: number, viewport: number): number {
|
||||
const max = Math.max(PANEL_MIN, Math.round(viewport * 0.85));
|
||||
return Math.min(max, Math.max(PANEL_MIN, Math.round(start + (startX - x))));
|
||||
}
|
||||
|
||||
/** 저장해 둔 너비 — 숫자가 아니거나 너무 작으면 null */
|
||||
export function storedWidth(raw: string | null): number | null {
|
||||
const n = Number(raw);
|
||||
return raw && Number.isFinite(n) && n >= PANEL_MIN ? Math.round(n) : null;
|
||||
}
|
||||
|
||||
const readStore = (key: string): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const writeStore = (key: string, value: string): void => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* 저장 칸이 막힌 브라우저 — 이번 화면에서만 */
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 패널 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
export function mountFlowPanel(host: HTMLElement, opts: FlowPanelOpts): FlowPanel {
|
||||
/** 크게 보기 화면 — 열려 있을 때만 */
|
||||
let full: HTMLElement | null = null;
|
||||
const paper = createPaper({
|
||||
logic: opts.logic,
|
||||
readOnly: opts.readOnly,
|
||||
onChange: opts.onChange,
|
||||
logics: opts.logics,
|
||||
// 좁은 패널은 「한눈에」 가 되게 더 작게 · 크게 보기는 글이 읽히게 90% 아래로 안 내림
|
||||
fitMin: () => (full ? 0.9 : PANEL_FIT_MIN),
|
||||
});
|
||||
const { diagram, detail, summary, note, undo } = paper.parts;
|
||||
let width = storedWidth(readStore(WIDTH_KEY));
|
||||
let folded = readStore(FOLD_KEY) === "1";
|
||||
const tell = (): void => opts.onResize?.(folded ? STRIP_WIDTH : (width ?? host.clientWidth));
|
||||
|
||||
const sm = (label: string, onClick: () => void, variant: "ghost" | "filled" = "ghost") =>
|
||||
createButton({ label, size: "sm", variant, onClick });
|
||||
|
||||
/* 펼친 패널 */
|
||||
const costBox = el("details", {
|
||||
className: "m02-dfp__cost",
|
||||
attrs: { open: "" },
|
||||
children: [el("summary", { text: tf("Cost") })],
|
||||
});
|
||||
const tools = el("div", { className: "m02-dfp__tools", children: [undo] });
|
||||
const open = el("div", {
|
||||
className: "m02-dfp__open",
|
||||
children: [
|
||||
el("header", {
|
||||
className: "m02-dfp__head",
|
||||
children: [
|
||||
el("strong", { className: "m02-dfp__title", text: opts.title }),
|
||||
sm(tf("Big"), () => expand()),
|
||||
sm(tf("Fold"), () => setFolded(true)),
|
||||
],
|
||||
}),
|
||||
tools,
|
||||
note,
|
||||
diagram,
|
||||
detail,
|
||||
costBox,
|
||||
],
|
||||
});
|
||||
costBox.append(summary);
|
||||
|
||||
/* 접힌 띠 */
|
||||
const strip = el("button", {
|
||||
className: "m02-dfp__strip",
|
||||
text: tf("Strip"),
|
||||
attrs: { type: "button", title: tf("StripTitle"), "aria-label": tf("StripTitle") },
|
||||
});
|
||||
strip.addEventListener("click", () => setFolded(false));
|
||||
|
||||
/* 왼쪽 가장자리 — 끌기 · 화살표 키 */
|
||||
const grip = el("div", {
|
||||
className: "m02-dfp__grip",
|
||||
attrs: {
|
||||
role: "separator",
|
||||
"aria-orientation": "vertical",
|
||||
"aria-label": tf("Grip"),
|
||||
tabindex: "0",
|
||||
},
|
||||
});
|
||||
const setWidth = (px: number, save: boolean): void => {
|
||||
width = px;
|
||||
if (save) writeStore(WIDTH_KEY, String(px));
|
||||
tell();
|
||||
};
|
||||
grip.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.button !== 0) return;
|
||||
ev.preventDefault();
|
||||
const start = root.getBoundingClientRect().width;
|
||||
const startX = ev.clientX;
|
||||
root.classList.add("is-resizing");
|
||||
const move = (m: PointerEvent): void =>
|
||||
setWidth(dragWidth(start, startX, m.clientX, window.innerWidth), false);
|
||||
const up = (u: PointerEvent): void => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", up);
|
||||
root.classList.remove("is-resizing");
|
||||
setWidth(dragWidth(start, startX, u.clientX, window.innerWidth), true);
|
||||
paper.refit();
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", up);
|
||||
});
|
||||
grip.addEventListener("keydown", (ev) => {
|
||||
const step = ev.shiftKey ? 80 : 20;
|
||||
const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0;
|
||||
if (!d) return;
|
||||
ev.preventDefault();
|
||||
const now = root.getBoundingClientRect().width;
|
||||
setWidth(dragWidth(now, 0, -d, window.innerWidth), true);
|
||||
});
|
||||
|
||||
const root = el("aside", { className: "m02-dfp", children: [grip, open, strip] });
|
||||
root.addEventListener("keydown", (ev) => {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
|
||||
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
}
|
||||
});
|
||||
|
||||
const setFolded = (on: boolean): void => {
|
||||
folded = on;
|
||||
writeStore(FOLD_KEY, on ? "1" : "0");
|
||||
root.classList.toggle("is-folded", on);
|
||||
open.hidden = on;
|
||||
strip.hidden = !on;
|
||||
grip.hidden = on;
|
||||
tell();
|
||||
if (!on) paper.refit();
|
||||
};
|
||||
|
||||
/* 크게 보기 — 같은 판을 화면 전체로 옮겨 끼우고 닫으면 되돌림 */
|
||||
const expand = (): void => {
|
||||
if (full) return;
|
||||
const close = sm(tf("Close"), () => shrink());
|
||||
full = el("div", {
|
||||
className: "m02-dfp-full",
|
||||
attrs: { role: "dialog", "aria-modal": "true", "aria-label": opts.title, tabindex: "-1" },
|
||||
children: [
|
||||
el("header", {
|
||||
className: "m02-dfp-full__head",
|
||||
children: [el("strong", { text: opts.title }), undo, close],
|
||||
}),
|
||||
note,
|
||||
el("div", { className: "m02-dfp-full__diagram", children: [diagram] }),
|
||||
el("div", {
|
||||
className: "m02-dfp-full__lower",
|
||||
children: [
|
||||
el("section", { children: [el("h3", { text: tf("Detail") }), detail] }),
|
||||
el("section", { children: [el("h3", { text: tf("Cost") }), summary] }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
full.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Escape" && !ev.defaultPrevented) shrink();
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "z" && !ev.shiftKey) {
|
||||
if ((ev.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
|
||||
ev.preventDefault();
|
||||
paper.undo();
|
||||
}
|
||||
});
|
||||
document.body.append(full);
|
||||
full.focus();
|
||||
paper.refit();
|
||||
};
|
||||
const shrink = (): void => {
|
||||
if (!full) return;
|
||||
full.remove();
|
||||
full = null;
|
||||
tools.append(undo);
|
||||
costBox.append(summary);
|
||||
// 머리 · 실행 취소 줄 뒤에 판을 처음 차례대로 되돌려 끼움
|
||||
open.replaceChildren(
|
||||
open.firstElementChild as HTMLElement,
|
||||
tools,
|
||||
note,
|
||||
diagram,
|
||||
detail,
|
||||
costBox,
|
||||
);
|
||||
paper.refit();
|
||||
};
|
||||
|
||||
host.replaceChildren(root);
|
||||
setFolded(folded);
|
||||
|
||||
return {
|
||||
set: (lg) => paper.set(lg),
|
||||
destroy: () => {
|
||||
shrink();
|
||||
root.remove();
|
||||
},
|
||||
expand,
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,8 @@ export interface PaperOpts {
|
||||
onChange(lg: CfLogic): void;
|
||||
/** 다른 로직 입력이 부르는 로직 — 없으면 흐름도 저장본(DF-) 만 */
|
||||
logics?(key: string): Promise<CfLogic | null>;
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 부를 때마다 물음(패널 · 크게 보기가 다름) */
|
||||
fitMin?(): number;
|
||||
}
|
||||
|
||||
export interface Paper {
|
||||
@@ -304,6 +306,7 @@ export function createPaper(opts: PaperOpts): Paper {
|
||||
breaks: broken,
|
||||
readOnly,
|
||||
fitKey,
|
||||
fitMin: opts.fitMin?.(),
|
||||
});
|
||||
drawPanel();
|
||||
renderSummary(summaryHost, lg, result, { change: editExample, select: choose }, { values });
|
||||
|
||||
@@ -102,3 +102,8 @@ export function showRounded(v: number, r?: CfRound): string {
|
||||
maximumFractionDigits: Math.max(0, r.places),
|
||||
});
|
||||
}
|
||||
|
||||
/** 원가 합계 기본 끝수(표를 읽어 오기 전에는 계금 1원 미만 버림) — 화면이 바로 쓰는 값 */
|
||||
let totalNow: CfRound = { places: 0, method: "버림" };
|
||||
void defaultTotalRound().then((r) => (totalNow = r));
|
||||
export const totalRoundNow = (): CfRound => totalNow;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Sample.ts
|
||||
* 단가산출 흐름도 견본 — 처음 열었을 때 바로 보고 따라 만들 견본(`_sample/*.json` · 손으로 짬)
|
||||
* ① 돌쌓기 메쌓기(인력) — 변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증(직렬) · 품 × 노임(병렬) → 노무비
|
||||
* ② 지반 가르기 — 같은 굴착량이 「조건 통과」 선 둘(지반 = 토사 / ≠ 토사)로 굴삭기 · 브레이커에 감 → 경비
|
||||
* ③ 흄관 관경 조건 찾기 — 재료 자리(조건 찾기 · 규격에 관경 포함) → 본수 × 단가 → 재료비
|
||||
* ④ 다른 로직 잇기 — 작업량 로직(⑤) 출력 → 장비 시간 → 경비
|
||||
* ⑤ 덤프트럭 시간당 작업량(값) — ④ 가 부르는 다른 로직
|
||||
* 견본은 읽기만 — `sampleLoad` 는 「복사해서 열기」 사본(key 없음 · 저장하면 `_data` 에 새 번호).
|
||||
* 입력 값(노임 · 표 · 재료)은 견본에 안 들고 열 때 · 저장 때 마스터에서 받음.
|
||||
* 모양 = `M02_DF_Model` CfLogic(선 규칙 · 재료 방식 · 다른 로직 입력 포함) · 알약 이름 규칙 — 정함
|
||||
* `docs/raw/M01_쉬운단가/흐름도_정함.md` 1 · 4~7 · 12. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
|
||||
const FILES = import.meta.glob<CfLogic>("./_sample/*.json", { eager: true, import: "default" });
|
||||
|
||||
/** 견본마다 한 줄 설명 — [한, 영] */
|
||||
const NOTE: Record<string, readonly [string, string]> = {
|
||||
masonry: [
|
||||
"변수 · 품표 · 증가율표 · 인력 → 표 찾기 · 높이 할증 → 품 × 노임 → 노무비",
|
||||
"Variables, tables and labor → lookup, height surcharge → crew × wage → labor cost",
|
||||
],
|
||||
ground: [
|
||||
"지반이 토사면 굴삭기로 · 아니면 브레이커로 — 조건 통과 선 둘 → 경비",
|
||||
"Soil goes to the excavator, rock to the breaker — two gate lines → expense",
|
||||
],
|
||||
hume: [
|
||||
"관경으로 흄관 품목을 찾음(재료 자리 · 규격에 관경 포함) → 본수 × 단가 → 재료비",
|
||||
"Pipe size picks the hume pipe item (spec contains size) → count × price → material cost",
|
||||
],
|
||||
linked: [
|
||||
"작업량 로직의 출력을 받아 장비 시간 → 경비",
|
||||
"Takes the output of a productivity logic → machine hours → expense",
|
||||
],
|
||||
value: [
|
||||
"q · f · E · Cm → 60 × q × f × E ÷ Cm → 값(시간당 작업량)",
|
||||
"q, f, E, Cm → 60 × q × f × E ÷ Cm → value (output per hour)",
|
||||
],
|
||||
};
|
||||
|
||||
/** 보여 줄 차례 */
|
||||
const ORDER = ["masonry", "ground", "hume", "linked", "value"];
|
||||
|
||||
export interface CfSampleItem {
|
||||
/** 파일 이름(확장자 뺌) */
|
||||
id: string;
|
||||
name: string;
|
||||
resultUnit: string;
|
||||
/** 쉬운 말 한 줄 */
|
||||
note: string;
|
||||
}
|
||||
|
||||
const idOf = (path: string): string => path.replace(/^.*\//, "").replace(/\.json$/, "");
|
||||
const byId = (): Map<string, CfLogic> =>
|
||||
new Map(Object.entries(FILES).map(([path, lg]) => [idOf(path), lg]));
|
||||
|
||||
/** 견본 목록 — 정한 차례 먼저 · 나머지는 이름 차례 */
|
||||
export function sampleList(): CfSampleItem[] {
|
||||
const all = byId();
|
||||
const rank = (id: string): number => (ORDER.includes(id) ? ORDER.indexOf(id) : ORDER.length);
|
||||
return [...all.entries()]
|
||||
.sort(([a, x], [b, y]) => rank(a) - rank(b) || x.name.localeCompare(y.name))
|
||||
.map(([id, lg]) => ({
|
||||
id,
|
||||
name: lg.name,
|
||||
resultUnit: lg.resultUnit,
|
||||
note: NOTE[id]?.[currentLanguageIndex === 1 ? 1 : 0] ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
/** 견본 하나 「복사해서 열기」 — 깊은 사본 · key 없음(저장하면 서버가 새 번호) · 없으면 null */
|
||||
export function sampleLoad(id: string): CfLogic | null {
|
||||
const lg = byId().get(id);
|
||||
return lg ? { ...(JSON.parse(JSON.stringify(lg)) as CfLogic), key: null } : null;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
"""흐름 로직 왼쪽 「단가산출 상세」 줄 만들기(`M01_MasterData_UI_LogicLab_FlowRows.ts`) — 순수 부분.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(DataFlow Round …)는 이어 트랜스파일 ·
|
||||
화면 부품(@ui · @config)은 빈 흉내. 견본 로직 · 계산 결과는 이 시험 안에 둠(마스터 내용에 기대지 않음).
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_FlowRows.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 : k === "API_BASE_URL" ? "http://127.0.0.1:1/api" : () => 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) => m.flowRows(job.lg, job.ev, job.round));
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
setTimeout(() => process.exit(0), 50);
|
||||
"""
|
||||
|
||||
ROUND = {"item": {"places": 1, "method": "버림"}, "total": {"places": 0, "method": "버림"}}
|
||||
|
||||
|
||||
def box(id_, kind, name, unit="원", **extra):
|
||||
return {
|
||||
"id": id_,
|
||||
"kind": kind,
|
||||
"name": name,
|
||||
"color": "#000",
|
||||
"unit": unit,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def out_box(id_, name, cost, unit="원", round_=None):
|
||||
output = {"cost": cost, "unit": unit}
|
||||
if round_:
|
||||
output["round"] = round_
|
||||
return box(id_, "output", name, unit, output=output)
|
||||
|
||||
|
||||
def func_box(id_, name, out_name, unit="시간"):
|
||||
func = {
|
||||
"sheet": {"id": "s-" + id_, "이름": name, "칸": {}},
|
||||
"inputs": [],
|
||||
"variables": [],
|
||||
"output": {"cell": "B1", "name": out_name, "unit": unit},
|
||||
}
|
||||
return box(id_, "function", name, unit, func=func)
|
||||
|
||||
|
||||
def logic(boxes):
|
||||
return {
|
||||
"key": None,
|
||||
"name": "시험",
|
||||
"resultUnit": "원/㎡",
|
||||
"boxes": boxes,
|
||||
"links": [],
|
||||
"userElements": [],
|
||||
}
|
||||
|
||||
|
||||
def result(outputs, cost, ports=None, errors=None):
|
||||
return {"ports": ports or {}, "outputs": outputs, "cost": cost, "errors": errors or []}
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
node = "node"
|
||||
done = subprocess.run(
|
||||
[node, "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps(jobs),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert done.returncode == 0, done.stderr
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sample():
|
||||
lg = logic(
|
||||
[
|
||||
out_box("o1", "인건비", "노무비"),
|
||||
out_box("o2", "재료값", "재료비"),
|
||||
out_box("o3", "시간당 작업량", "값", "㎥/시간"),
|
||||
out_box("o4", "특별 끝수", "경비", round_={"places": 2, "method": "반올림"}),
|
||||
func_box("f1", "운반 시간", "시간", "시간"),
|
||||
]
|
||||
)
|
||||
ev = result(
|
||||
{"o1": 3216.69, "o2": 100.04, "o3": 12.5, "o4": 1.239},
|
||||
{
|
||||
"노무비": 3216.69,
|
||||
"재료비": 100.04,
|
||||
"경비": 1.239,
|
||||
"값들": {"시간당 작업량": 12.5},
|
||||
"계": 3317.969,
|
||||
},
|
||||
ports={"f1": {"시간": 6.024}},
|
||||
)
|
||||
return run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
|
||||
|
||||
|
||||
def group(rows, key):
|
||||
return next(g for g in rows["groups"] if g["key"] == key)
|
||||
|
||||
|
||||
def test_group_order_and_costs_only(sample):
|
||||
assert [g["key"] for g in sample["groups"]] == ["labor", "material", "cost", "plain"]
|
||||
|
||||
|
||||
def test_line_uses_output_round(sample):
|
||||
# 출력 끝수를 안 정한 비용 줄 = 기본 0.1원 버림 → 3,216.6 (정수로 반올림하지 않음)
|
||||
assert group(sample, "labor")["lines"][0]["text"] == "3,216.6"
|
||||
assert group(sample, "material")["lines"][0]["text"] == "100"
|
||||
# 그 출력의 끝수가 있으면 그 자리대로(소수 둘째 반올림)
|
||||
assert group(sample, "cost")["lines"][0]["text"] == "1.24"
|
||||
|
||||
|
||||
def test_plain_value_is_not_rounded_and_has_no_subtotal(sample):
|
||||
plain = group(sample, "plain")
|
||||
assert plain["lines"][0]["text"] == "12.5"
|
||||
assert plain["lines"][0]["unit"] == "㎥/시간"
|
||||
assert plain["sub"] is None
|
||||
|
||||
|
||||
def test_subtotal_and_total_floor_to_won(sample):
|
||||
assert group(sample, "labor")["sub"] == 3216
|
||||
assert group(sample, "labor")["subText"] == "3,216"
|
||||
assert sample["total"] == 3317
|
||||
assert sample["totalText"] == "3,317"
|
||||
|
||||
|
||||
def test_function_rows(sample):
|
||||
assert sample["funcs"] == [
|
||||
{"box": "f1", "name": "운반 시간", "unit": "시간", "value": 6.024, "text": "6.024"}
|
||||
]
|
||||
|
||||
|
||||
def test_empty_group_is_hidden():
|
||||
lg = logic([out_box("o1", "인건비", "노무비")])
|
||||
ev = result({"o1": 10}, {"노무비": 10, "재료비": 0, "경비": 0, "값들": {}, "계": 10})
|
||||
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
|
||||
assert [g["key"] for g in rows["groups"]] == ["labor"]
|
||||
assert rows["total"] == 10
|
||||
|
||||
|
||||
def test_missing_value_shows_dash():
|
||||
lg = logic([out_box("o1", "인건비", "노무비")])
|
||||
ev = result(
|
||||
{"o1": None}, {"노무비": 0, "재료비": 0, "경비": 0, "값들": {}, "계": 0}, errors=["끊김"]
|
||||
)
|
||||
rows = run([{"lg": lg, "ev": ev, "round": ROUND}])[0]
|
||||
assert group(rows, "labor")["lines"][0]["text"] == "—"
|
||||
assert rows["errors"] == ["끊김"]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""M02 단가산출 다이어그램 견본(35-7) 시험 — `M02_MasterTemplete/CostFlow/_sample/*.json` · `M02_CF_Sample.ts`.
|
||||
"""M02 단가산출 다이어그램 견본(35-7) 시험 — 시험 견본 `resources/tester/fixtures_df/costflow/*.json` · `M02_CF_Sample.ts`.
|
||||
|
||||
· 견본 셋 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
|
||||
· 견본 ① 원가 = 지금 `/calc`(GF000219 · 같은 예시값 · 끝수 원 미만) — 함수 상자 시트를 우리 스프레드시트 엔진
|
||||
@@ -21,7 +21,7 @@ import pytest
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOLDER = ROOT / "M02_MasterTemplete" / "CostFlow"
|
||||
SAMPLE_TS = FOLDER / "M02_CF_Sample.ts"
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df" / "costflow").glob("*.json"))} # fmt: skip
|
||||
|
||||
HOOKS = r"""
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""단가산출 흐름도 「식 보기」(`M02_DF_FormulaView.ts`) · 엑셀 「조건 만들기」 글 쪽(`spreadsheet_condition_text.ts`).
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(엔진 포함)는 이어 트랜스파일 · 화면 부품(@ui 의 elements · locale · modal)은 빈 흉내 · 엔진이 쓰는 @ui/sheet 는 그대로.
|
||||
견본 함수 상자는 이 시험 안에 둠.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VIEW = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_FormulaView.ts"
|
||||
TEXT = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_condition_text.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, viewFile, textFile] = 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(".") || id.startsWith("@ui/sheet/")) {
|
||||
const p = id.startsWith("@ui/sheet/")
|
||||
? path.join(root, "ui_template", "sheet", id.slice("@ui/sheet/".length))
|
||||
: 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 v = load(viewFile);
|
||||
const t = load(textFile);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "lines") {
|
||||
const values = v.cellValuesOf(job.box, job.arrived);
|
||||
return { values, lines: v.formulaLines(job.box, values) };
|
||||
}
|
||||
if (job.op === "toFormula") return t.draftToFormula(job.draft);
|
||||
if (job.op === "toDraft") return t.formulaToDraft(job.formula);
|
||||
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(VIEW), str(TEXT)],
|
||||
input=json.dumps(jobs, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def func_box(cells: dict, inputs: list, variables: list, out_cell: str, out_name: str) -> dict:
|
||||
return {
|
||||
"id": "f1",
|
||||
"kind": "function",
|
||||
"name": "석공 품",
|
||||
"color": "",
|
||||
"unit": "인",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"func": {
|
||||
"sheet": {"id": "s1", "이름": "석공 품", "칸": cells},
|
||||
"inputs": inputs,
|
||||
"variables": variables,
|
||||
"output": {"cell": out_cell, "name": out_name, "unit": "인"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def var(name: str, cell: str, example) -> dict:
|
||||
return {"name": name, "type": "number", "unit": "-", "example": example, "cell": cell}
|
||||
|
||||
|
||||
# 석공량(받는 알약 B1) · 높이(변수 B2) → B3 = 석공량 × (1 + 높이 ÷ 100)
|
||||
PLAIN = func_box(
|
||||
{
|
||||
"A1": {"값": "석공량"},
|
||||
"B3": {"식": "B1*(1+B2/100)"},
|
||||
},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[var("높이", "B2", 4)],
|
||||
"B3",
|
||||
"석공 인",
|
||||
)
|
||||
|
||||
|
||||
def test_이름_식과_값_식():
|
||||
(got,) = run([{"op": "lines", "box": PLAIN, "arrived": {"석공량": 0.15}}])
|
||||
assert got["values"]["B3"] == pytest.approx(0.156)
|
||||
(line,) = got["lines"]
|
||||
assert line["name"] == "석공 인"
|
||||
assert line["named"] == "석공량 × (1 + 높이 ÷ 100)"
|
||||
assert line["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156"
|
||||
assert "branches" not in line
|
||||
|
||||
|
||||
def test_중간_칸이_먼저_결과_칸이_마지막():
|
||||
box = func_box(
|
||||
{"B3": {"식": "B1*B2"}, "B4": {"식": "B3+1"}},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[var("높이", "B2", 4)],
|
||||
"B4",
|
||||
"합",
|
||||
)
|
||||
(got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 2}}])
|
||||
assert [x["name"] for x in got["lines"]] == ["B3", "합"] # 연결 없는 칸은 주소 그대로
|
||||
assert got["lines"][1]["named"] == "B3 + 1"
|
||||
assert got["lines"][1]["valued"] == "8 + 1 = 9"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ground", "now"),
|
||||
[("토사", [True, False]), ("암", [False, True])],
|
||||
)
|
||||
def test_IF_줄과_지금_갈래(ground, now):
|
||||
box = func_box(
|
||||
{"B3": {"식": 'IF(B4="토사",B1*2,B1*3)'}},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[{"name": "지반", "type": "choice", "unit": "-", "example": ground, "cell": "B4"}],
|
||||
"B3",
|
||||
"결과",
|
||||
)
|
||||
(got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 10}}])
|
||||
(line,) = got["lines"]
|
||||
assert [b["now"] for b in line["branches"]] == now
|
||||
assert line["branches"][0]["when"] == '지반 = "토사"'
|
||||
assert line["branches"][0]["then"] == "석공량 × 2"
|
||||
assert line["branches"][1] == {"when": "아니면", "then": "석공량 × 3", "now": now[1]}
|
||||
assert line["valued"].endswith("= 20" if ground == "토사" else "= 30")
|
||||
|
||||
|
||||
def test_조건_만들기_식과_다시_줄():
|
||||
draft = {
|
||||
"branches": [
|
||||
{
|
||||
"left": "B1",
|
||||
"op": "=",
|
||||
"right": {"kind": "value", "value": "토사"},
|
||||
"then": {"kind": "cell", "cell": "C1"},
|
||||
},
|
||||
{
|
||||
"left": "B1",
|
||||
"op": "≥",
|
||||
"right": {"kind": "value", "value": 3},
|
||||
"then": {"kind": "value", "value": 0.5},
|
||||
},
|
||||
],
|
||||
"otherwise": {"kind": "value", "value": 0},
|
||||
}
|
||||
(formula,) = run([{"op": "toFormula", "draft": draft}])
|
||||
assert formula == 'IF(B1="토사",C1,IF(B1>=3,0.5,0))'
|
||||
(back,) = run([{"op": "toDraft", "formula": formula}])
|
||||
assert back == draft
|
||||
|
||||
|
||||
@pytest.mark.parametrize("formula", ["SUM(B1:B2)", "IF(B1+1>2,1,2)", "IF(B1>2,B2*2,0)"])
|
||||
def test_못_바꾸는_식은_null(formula):
|
||||
(back,) = run([{"op": "toDraft", "formula": formula}])
|
||||
assert back is None
|
||||
@@ -0,0 +1,230 @@
|
||||
"""단가산출 흐름도 입력 판 — 다른 로직 입력 = 마스터 로직 고르기(순수 부분 · `M02_DF_InputPanel_Build.ts`).
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림. 견본 로직 줄 · 흐름은 이 시험 안에 둠(마스터 내용에 기대지 않음).
|
||||
"""
|
||||
|
||||
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_InputPanel_Build.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 js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
|
||||
const m = box.exports;
|
||||
(async () => {
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = [];
|
||||
for (const j of jobs) {
|
||||
if (j.name === "wouldCycle") {
|
||||
const graph = j.args[2];
|
||||
out.push(await m.wouldCycle(j.args[0], j.args[1], async (k) => graph[k] ?? null));
|
||||
} else out.push(await m[j.name](...j.args));
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
"""
|
||||
|
||||
|
||||
def call(name: str, *args):
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
|
||||
input=json.dumps([{"name": name, "args": list(args)}], ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)[0]
|
||||
|
||||
|
||||
def row(key, name, book, chapter, no="", unit="원/㎡", **more):
|
||||
return {
|
||||
"file": f"로직_{book}.json",
|
||||
"구분": book,
|
||||
"상세구분": chapter,
|
||||
"키": key,
|
||||
"원문번호": no,
|
||||
"이름": name,
|
||||
"결과단위": unit,
|
||||
**more,
|
||||
}
|
||||
|
||||
|
||||
ITEMS = [
|
||||
row("GF000219", "돌쌓기 메쌓기(인력)", "산림품셈", "13장 구조물", "13-4-1"),
|
||||
row("GF000220", "돌쌓기 찰쌓기(인력)", "산림품셈", "13장 구조물", "13-4-2"),
|
||||
row("GF000025", "떼붙임(재배잔디)", "산림품셈", "05장 녹화", "5-12"),
|
||||
row("GC000051", "보행자 안전통로", "건설품셈", "01장 공통", "1-3-9", "원/m"),
|
||||
row("DF-0001", "내 흄관 로직", "흐름도", "내가 만든 것", "", "원/m"),
|
||||
]
|
||||
|
||||
|
||||
def keys(items):
|
||||
return [i["키"] for i in items]
|
||||
|
||||
|
||||
def test_찾기는_띄어쓰기로_나눈_말을_모두_포함():
|
||||
got = call("filterLogics", ITEMS, {"text": "돌쌓기 메쌓기"})
|
||||
assert keys(got) == ["GF000219"]
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "돌쌓기"})) == ["GF000219", "GF000220"]
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "돌 쌓기메쌓기"})) == [
|
||||
"GF000219"
|
||||
] # 띄어쓰기는 가리지 않음
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "13-4"})) == [
|
||||
"GF000219",
|
||||
"GF000220",
|
||||
] # 원문번호
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "gc000051"})) == [
|
||||
"GC000051"
|
||||
] # 번호 · 대소문자
|
||||
assert call("filterLogics", ITEMS, {"text": ""}) == ITEMS
|
||||
|
||||
|
||||
def test_원문_장으로_거름():
|
||||
assert keys(call("filterLogics", ITEMS, {"text": "", "book": "건설품셈"})) == ["GC000051"]
|
||||
got = call("filterLogics", ITEMS, {"text": "", "book": "산림품셈", "chapter": "05장 녹화"})
|
||||
assert keys(got) == ["GF000025"]
|
||||
assert call("filterLogics", ITEMS, {"text": "메쌓기", "book": "건설품셈"}) == []
|
||||
|
||||
|
||||
def test_원문_장_목록은_나온_차례():
|
||||
assert call("logicBooks", ITEMS) == ["산림품셈", "건설품셈", "흐름도"]
|
||||
assert call("logicChapters", ITEMS, "산림품셈") == ["13장 구조물", "05장 녹화"]
|
||||
assert call("logicChapters", ITEMS, "") == [
|
||||
"13장 구조물",
|
||||
"05장 녹화",
|
||||
"01장 공통",
|
||||
"내가 만든 것",
|
||||
]
|
||||
|
||||
|
||||
def box(id_, kind, name, **more):
|
||||
return {
|
||||
"id": id_,
|
||||
"kind": kind,
|
||||
"name": name,
|
||||
"color": "#fff",
|
||||
"unit": "-",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
**more,
|
||||
}
|
||||
|
||||
|
||||
FLOW = {
|
||||
"key": "GF000219",
|
||||
"name": "돌쌓기 메쌓기(인력)",
|
||||
"resultUnit": "원/㎡",
|
||||
"userElements": [],
|
||||
"links": [],
|
||||
"boxes": [
|
||||
box(
|
||||
"b1",
|
||||
"input",
|
||||
"입력",
|
||||
input={
|
||||
"mode": "variable",
|
||||
"elements": [],
|
||||
"variables": [
|
||||
{"name": "뒷길이", "type": "number", "unit": "㎝", "example": 30},
|
||||
{
|
||||
"name": "돌",
|
||||
"type": "choice",
|
||||
"unit": "",
|
||||
"choices": ["깬돌", "견치돌"],
|
||||
"example": "깬돌",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
box("o1", "output", "노무비", unit="원/㎡", output={"cost": "노무비", "unit": "원/㎡"}),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_흐름_로직_고르기는_출력_상자와_설계_때_정할_것():
|
||||
ref = call("masterRef", "GF000219", FLOW, False, "원/㎡")
|
||||
assert ref["key"] == "GF000219"
|
||||
assert ref["outputs"] == [{"name": "노무비", "unit": "원/㎡"}]
|
||||
assert ref["designs"] == [{"name": "뒷길이", "unit": "㎝"}, {"name": "돌", "unit": "-"}]
|
||||
assert ref["examples"] == {"뒷길이": 30, "돌": "깬돌"}
|
||||
|
||||
|
||||
def test_옛_로직_변환은_노무비_재료비_경비_계_넷():
|
||||
ref = call("masterRef", "GF000219", FLOW, True, "원/㎡")
|
||||
assert [o["name"] for o in ref["outputs"]] == ["노무비", "재료비", "경비", "계"]
|
||||
assert {o["unit"] for o in ref["outputs"]} == {"원/㎡"}
|
||||
assert [d["name"] for d in ref["designs"]] == ["뒷길이", "돌"] # 받는 알약은 흐름 그대로
|
||||
|
||||
|
||||
def test_이어지지_않은_다른_로직_받는_알약도_설계_때_정할_것():
|
||||
inner = {
|
||||
"mode": "logic",
|
||||
"elements": [],
|
||||
"variables": [],
|
||||
"logic": {
|
||||
"key": "GF000250",
|
||||
"outputs": [{"name": "작업량", "unit": "㎥/시간"}],
|
||||
"designs": [{"name": "지역", "unit": "-"}, {"name": "기계", "unit": "-"}],
|
||||
"examples": {"지역": "서울"},
|
||||
},
|
||||
}
|
||||
flow = {**FLOW, "boxes": [*FLOW["boxes"], box("b2", "input", "작업량", input=inner)]}
|
||||
flow["links"] = [
|
||||
{"id": "l1", "from": {"box": "b1", "name": "돌"}, "to": {"box": "b2", "name": "기계"}}
|
||||
]
|
||||
ref = call("logicRefOf", "X", flow)
|
||||
names = [d["name"] for d in ref["designs"]]
|
||||
assert "지역" in names and "기계" not in names # 선이 이어진 받는 알약은 뺌
|
||||
assert ref["examples"]["지역"] == "서울"
|
||||
|
||||
|
||||
def test_자기_자신과_돌고_도는_로직은_막음():
|
||||
graph = {
|
||||
"A": {
|
||||
"boxes": [
|
||||
box(
|
||||
"b",
|
||||
"input",
|
||||
"x",
|
||||
input={"mode": "logic", "elements": [], "variables": [], "logic": {"key": "B"}},
|
||||
)
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"boxes": [
|
||||
box(
|
||||
"b",
|
||||
"input",
|
||||
"x",
|
||||
input={
|
||||
"mode": "logic",
|
||||
"elements": [],
|
||||
"variables": [],
|
||||
"logic": {"key": "SELF"},
|
||||
},
|
||||
)
|
||||
]
|
||||
},
|
||||
"C": {"boxes": []},
|
||||
}
|
||||
assert call("wouldCycle", "SELF", "SELF", graph) is True # 자기 자신
|
||||
assert call("wouldCycle", "SELF", "A", graph) is True # A → B → SELF
|
||||
assert call("wouldCycle", "SELF", "C", graph) is False
|
||||
assert call("wouldCycle", None, "A", graph) is False # 새 로직은 아직 키가 없어 돌 수 없음
|
||||
assert call("wouldCycle", "SELF", "없음", graph) is False # 못 읽으면 막지 않음
|
||||
@@ -646,7 +646,9 @@ def test_쉬는_갈래와_문제_무게() -> None:
|
||||
|
||||
|
||||
def _ground() -> dict:
|
||||
return json.loads((CF / "_sample" / "ground.json").read_text(encoding="utf-8"))
|
||||
return json.loads(
|
||||
(ROOT / "resources" / "tester" / "fixtures_df" / "ground.json").read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
|
||||
def _server(lg: dict, values: dict) -> dict:
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""오른쪽 흐름도 패널(`M02_DF_Panel.ts`) 순수 부분 — 끌어서 정한 너비 · 저장해 둔 너비.
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림(부품 · 판은 빈 흉내 · 순수 함수만).
|
||||
"""
|
||||
|
||||
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_Panel.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 js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const stub = new Proxy({}, { get: () => () => undefined });
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
|
||||
const m = box.exports;
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((j) =>
|
||||
j.op === "drag" ? m.dragWidth(j.start, j.startX, j.x, j.viewport)
|
||||
: j.op === "stored" ? m.storedWidth(j.raw)
|
||||
: j.op === "consts" ? [m.PANEL_MIN, m.STRIP_WIDTH]
|
||||
: 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 test_왼쪽_가장자리_끌기_너비():
|
||||
[consts, wider, narrower, floor, ceil] = run(
|
||||
[
|
||||
{"op": "consts"},
|
||||
# 왼쪽으로 100 끌면 넓어짐
|
||||
{"op": "drag", "start": 480, "startX": 1000, "x": 900, "viewport": 1600},
|
||||
# 오른쪽으로 끌면 좁아짐
|
||||
{"op": "drag", "start": 480, "startX": 1000, "x": 1060, "viewport": 1600},
|
||||
# 너무 좁게 — 가장 작은 너비에서 멈춤
|
||||
{"op": "drag", "start": 480, "startX": 1000, "x": 1400, "viewport": 1600},
|
||||
# 너무 넓게 — 화면의 85% 에서 멈춤
|
||||
{"op": "drag", "start": 480, "startX": 1000, "x": -2000, "viewport": 1600},
|
||||
]
|
||||
)
|
||||
min_width, strip = consts
|
||||
assert (min_width, strip) == (320, 40)
|
||||
assert wider == 580 and narrower == 420
|
||||
assert floor == min_width and ceil == 1360
|
||||
|
||||
|
||||
def test_저장해_둔_너비():
|
||||
got = run(
|
||||
[
|
||||
{"op": "stored", "raw": "640"},
|
||||
{"op": "stored", "raw": "640.6"},
|
||||
{"op": "stored", "raw": None},
|
||||
{"op": "stored", "raw": "abc"},
|
||||
{"op": "stored", "raw": "100"},
|
||||
]
|
||||
)
|
||||
assert got == [640, 641, None, None, None]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""M02 단가산출 흐름도 견본(35-9) 시험 — `M02_MasterTemplete/DataFlow/_sample/*.json` · `M02_DF_Sample.ts`.
|
||||
"""M02 단가산출 흐름도 견본(35-9) 시험 — 시험 견본 `resources/tester/fixtures_df/*.json`(36-7 · 화면 견본 목록은 지움).
|
||||
|
||||
견본 넷(메쌓기 · 지반 가르기 · 흄관 조건 찾기 · 다른 로직 잇기) + 넷째가 부르는 작업량 로직(value)
|
||||
· 모양 — 상자(입력 · 함수 · 출력) · 알약 이름 규칙 · 기본 색 · 선(받는 자리마다 하나 · 출력에서 안 나감 · 순환 없음)
|
||||
@@ -6,7 +6,7 @@
|
||||
· 흐름 — 출력에서 거꾸로 따라가면 닿는 출처(선 · 선 규칙의 조건 알약 · 재료 조건 알약 포함)가 견본 뜻과 같음
|
||||
· 값(내부 확인) — 함수 상자 시트를 우리 스프레드시트 엔진(Node 번들)으로 차례대로 셈 · 선 규칙 · 끝수를 지남 ·
|
||||
입력 값(노임 · 품셈 표 · 재료 단가)은 마스터에서 읽기만 · 메쌓기 = /calc GF000219 · value = /calc GF000133
|
||||
· `sampleList` · `sampleLoad`(사본 · key 없음) — Node 로 바로 돌림 · 파일 안 씀
|
||||
· 파일 안 씀
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -14,7 +14,6 @@ from __future__ import annotations
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,8 +21,7 @@ import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FOLDER = ROOT / "M02_MasterTemplete" / "DataFlow"
|
||||
SAMPLE_TS = FOLDER / "M02_DF_Sample.ts"
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((FOLDER / "_sample").glob("*.json"))} # fmt: skip
|
||||
SAMPLES = {p.stem: json.loads(p.read_text(encoding="utf-8")) for p in sorted((ROOT / "resources" / "tester" / "fixtures_df").glob("*.json"))} # fmt: skip
|
||||
NAMES = ["masonry", "ground", "hume", "linked", "value"]
|
||||
BOX_COLOR = dict(
|
||||
re.findall(
|
||||
@@ -34,40 +32,6 @@ BOX_COLOR = dict(
|
||||
)
|
||||
)
|
||||
|
||||
HOOKS = r"""
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { stripTypeScriptTypes } from "node:module";
|
||||
export async function load(url, ctx, next) {
|
||||
if (!url.endsWith(".ts")) return next(url, ctx);
|
||||
let src = readFileSync(fileURLToPath(url), "utf8");
|
||||
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\([^)]*\)/g, "globalThis.__samples");
|
||||
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names) => {
|
||||
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
|
||||
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
|
||||
});
|
||||
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def _node(body: str) -> object:
|
||||
files = {f"./_sample/{k}.json": v for k, v in SAMPLES.items()}
|
||||
script = (
|
||||
"import { register } from 'node:module';\n"
|
||||
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
|
||||
"globalThis.__stub = { currentLanguageIndex: 0 };\n"
|
||||
f"globalThis.__samples = {json.dumps(files, ensure_ascii=False)};\n"
|
||||
f"const S = await import({json.dumps(SAMPLE_TS.as_uri())});\n"
|
||||
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--no-warnings", "--input-type=module", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=120, cwd=ROOT,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr[-2000:]
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
# ── 알약 · 선 ─────────────────────────────────────────────────────────────
|
||||
def _ports_out(box: dict) -> list[str]:
|
||||
@@ -215,16 +179,6 @@ def test_견본_흐름_규칙_방식() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_견본_목록_열기() -> None:
|
||||
got = _node(
|
||||
"out({ list: S.sampleList(), one: S.sampleLoad('ground'), none: S.sampleLoad('없음') });"
|
||||
)
|
||||
assert [x["id"] for x in got["list"]] == NAMES
|
||||
assert all(x["note"] for x in got["list"])
|
||||
assert got["one"]["key"] is None and got["one"]["links"] == SAMPLES["ground"]["links"]
|
||||
assert got["none"] is None
|
||||
|
||||
|
||||
# ── 값(내부 확인) ─────────────────────────────────────────────────────────
|
||||
@pytest.fixture(scope="module")
|
||||
def master():
|
||||
|
||||
Reference in New Issue
Block a user