Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_7' into main_laptop_1

This commit is contained in:
2026-10-01 19:49:21 +09:00
14 changed files with 2222 additions and 227 deletions
@@ -0,0 +1,254 @@
/* =============================================================================
* M02_DF_Detail.ts
* 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 겹침/안 맞음 · 흐름 줄 · 끊김 까닭 · DOM 없음
* 모델의 traceBack · traceForward · breaks · applyRule · ruleLabel 이 아직 준비 중이면 같은 뜻의 작은 셈으로 메움
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { breaks, evalLogic, type CfEvalResult, type CfValue, type CfValues } from "./M02_DF_Eval";
import {
portsIn,
portsOut,
traceBack,
traceForward,
type CfBox,
type CfGateOp,
type CfLink,
type CfLogic,
type CfPort,
type CfRound,
type CfRule,
type CfStep,
} from "./M02_DF_Model";
import { applyRule, ruleLabel } from "./M02_DF_Rule";
const TEXT = {
Table: ["표 {n}줄", "table, {n} rows"],
Empty: ["값 없음", "no value"],
LabelLookup: ["표에서 「{col}」 찾기", "Look up “{col}” in the table"],
LabelGate: ["「{pill}」 {op} {value} 일 때만 보냄", "Send only when “{pill}” {op} {value}"],
LabelScale: ["값 {op} {value}", "value {op} {value}"],
LabelRound: ["소수 {places}자리 {method}", "{places} decimals, {method}"],
NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"],
Blocked: ["통과 안 됨", "does not pass"],
NoRowLater: ["표에서 맞는 줄은 계산 뒤에 보임", "The matching row shows after calculation"],
EmptyIn: ["빈 받는 자리 — 선이 없음", "Empty receiving spot — no line"],
NoCell: ["받은 알약을 칸에 놓지 않음", "The received pill is not placed in a cell"],
Dead: ["아직 어디로도 안 감", "Goes nowhere yet"],
NoValue: ["값이 아직 없음", "No value yet"],
} as const;
export const td = (key: keyof typeof TEXT, fill: Record<string, string | number> = {}): string =>
Object.entries(fill).reduce(
(s, [k, v]) => s.replace(`{${k}}`, String(v)),
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
);
export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"];
export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"];
export const boxOf = (lg: CfLogic, id: string): CfBox | undefined =>
lg.boxes.find((b) => b.id === id);
export const portText = (lg: CfLogic, p: CfPort): string =>
`${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`;
/** 계산 한 번 — 못 하면 null */
export function evalSafe(lg: CfLogic, values?: CfValues): CfEvalResult | null {
try {
return evalLogic(lg, values);
} catch {
return null;
}
}
export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined =>
ev?.ports[p.box]?.[p.name];
/** 화면에 보일 값 글 — 표는 줄 수 */
export function showValue(v: CfValue | undefined): string {
if (v === undefined || v === null) return td("Empty");
if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) });
return typeof v === "number" ? String(Number(v.toPrecision(12))) : String(v);
}
export function unitOf(lg: CfLogic, p: CfPort): string {
const box = boxOf(lg, p.box);
return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-";
}
/** 알약이 나가는(out) 쪽인가 */
export const isOut = (box: CfBox, name: string): boolean =>
portsOut(box).some((p) => p.name === name);
/* ── 규칙 ─────────────────────────────────────────────────────────────── */
const num = (v: unknown): number | null => {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) && v !== "" && v !== null ? n : null;
};
/** 조건 통과 — 숫자면 숫자끼리 · 아니면 글끼리 */
export function gatePass(op: CfGateOp, v: unknown, value: number | string): boolean {
const a = num(v);
const b = num(value);
const [x, y] = a !== null && b !== null ? [a, b] : [String(v ?? ""), String(value)];
switch (op) {
case "=":
return x === y;
case "≠":
return x !== y;
case "≥":
return x >= y;
case "≤":
return x <= y;
case ">":
return x > y;
default:
return x < y;
}
}
export function roundValue(r: CfRound, v: number): number {
const k = 10 ** r.places;
const f = r.method === "올림" ? Math.ceil : r.method === "버림" ? Math.floor : Math.round;
return f(v * k) / k;
}
/** 선 위 규칙 글 — 모델이 준비되면 그것, 아니면 같은 뜻의 글 */
export function labelOf(lg: CfLogic, rule: CfRule | undefined): string {
if (!rule) return td("NoRule");
try {
return ruleLabel(rule);
} catch {
if (rule.kind === "lookup") return td("LabelLookup", { col: rule.col });
if (rule.kind === "gate")
return td("LabelGate", { pill: portText(lg, rule.from), op: rule.op, value: rule.value });
if (rule.kind === "scale") return td("LabelScale", { op: rule.op, value: rule.value });
return td("LabelRound", { places: rule.places, method: rule.method });
}
}
/** 지나가기 전 값 → 지난 뒤 값 미리 보기 */
export function preview(
link: CfLink,
rule: CfRule | undefined,
ev: CfEvalResult | null,
): { before: string; after: string } {
const raw = valueOf(ev, link.from);
const before = showValue(raw);
if (!rule) return { before, after: before };
const conds: Record<string, unknown> = {};
if (rule.kind === "lookup") for (const c of rule.conds) conds[c.col] = valueOf(ev, c.from);
try {
const r = applyRule(rule, raw, conds);
return {
before,
after: r.value === null ? (r.why ?? td("Empty")) : showValue(r.value as CfValue),
};
} catch {
const n = num(raw);
if (rule.kind === "gate")
return {
before,
after: gatePass(rule.op, valueOf(ev, rule.from), rule.value) ? before : td("Blocked"),
};
if (rule.kind === "lookup" || n === null) return { before, after: td("NoRowLater") };
if (rule.kind === "round") return { before, after: showValue(roundValue(rule, n)) };
const v = rule.value;
const out =
rule.op === "×"
? n * v
: rule.op === "÷"
? v
? n / v
: NaN
: rule.op === "+%"
? n * (1 + v / 100)
: n * (1 - v / 100);
return { before, after: Number.isFinite(out) ? showValue(out) : td("Empty") };
}
}
/** 같은 알약에서 나간 조건 선 — 지금 값으로 통과하는 수 */
export function gateReport(
lg: CfLogic,
link: CfLink,
ev: CfEvalResult | null,
): { total: number; pass: string[] } {
const same = lg.links.filter(
(l) =>
l.from.box === link.from.box && l.from.name === link.from.name && l.rule?.kind === "gate",
);
const pass = same
.filter((l) => {
const r = l.rule as Extract<CfRule, { kind: "gate" }>;
return gatePass(r.op, valueOf(ev, r.from), r.value);
})
.map((l) => l.id);
return { total: same.length, pass };
}
/* ── 흐름 · 끊김 ─────────────────────────────────────────────────────── */
/** 거슬러 오르는 길(dir "in" = 온 길) · 따라가는 길(dir "out") — 모델 것, 없으면 작은 셈 */
export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] {
try {
return dir === "in" ? traceBack(lg, p) : traceForward(lg, p);
} catch {
const out: CfStep[] = [];
const seen = new Set<string>();
const walk = (port: CfPort, back: boolean): void => {
const key = `${port.box}/${port.name}`;
if (seen.has(key)) return;
seen.add(key);
const box = boxOf(lg, port.box);
if (!box) return;
if (back) {
const into = lg.links.filter(
(l) => l.to.box === port.box && (isOut(box, port.name) || l.to.name === port.name),
);
for (const l of into) {
out.push({ box: l.from.box, port: l.from.name, dir: "out", link: l.id, rule: l.rule });
walk(l.from, true);
}
} else {
const outs = lg.links.filter((l) => l.from.box === port.box && l.from.name === port.name);
for (const l of outs) {
out.push({ box: l.to.box, port: l.to.name, dir: "in", link: l.id, rule: l.rule });
const next = boxOf(lg, l.to.box);
for (const o of next ? portsOut(next) : []) walk({ box: next!.id, name: o.name }, false);
}
}
};
walk(p, dir === "in");
return out;
}
}
/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */
export function breakLines(lg: CfLogic, boxId: string, name?: string): string[] {
try {
return breaks(lg)
.filter((b) => b.box === boxId && (!name || !b.port || b.port === name))
.map((b) => b.text);
} catch {
// 모델의 끊긴 곳 목록이 준비되기 전 — 같은 뜻의 작은 셈
}
const box = boxOf(lg, boxId);
if (!box) return [];
const out: string[] = [];
for (const p of portsIn(box)) {
if (name && p.name !== name) continue;
if (!lg.links.some((l) => l.to.box === boxId && l.to.name === p.name)) out.push(td("EmptyIn"));
else if (box.func?.inputs.some((i) => i.port === p.name && !i.cell)) out.push(td("NoCell"));
}
for (const p of portsOut(box)) {
if (name && p.name !== name) continue;
if (!lg.links.some((l) => l.from.box === boxId && l.from.name === p.name)) out.push(td("Dead"));
}
return out;
}
export const noValue = (): string => td("NoValue");
+275 -75
View File
@@ -1,7 +1,9 @@
/* =============================================================================
* M02_DF_Eval.ts
* 단가산출 다이어그램 계산 · 검사 — 함수 상자는 차례대로 시트를 우리 스프레드시트 엔진으로 셈
* (받는 칸에 앞 값을 넣음) · 출력 상자를 비용 종류별로 모아 원가 · DOM 없음
* 단가산출 흐름도 계산 · 검사 — 상자를 기댐 차례대로(입력 → 선 규칙 → 함수 · 다른 로직 → 출력) 셈
* 함수 상자 = 시트를 우리 스프레드시트 엔진으로 셈(받는 칸에 앞 값) · 선 규칙 · 끝수도 같은 엔진(`M02_DF_Rule`)
* 출력 상자를 비용 종류별로 모아 원가 · 끊긴 곳(빈 받는 자리 · 통과 안 됨 · 표에 없는 조건 · 맞는 재료 없음 ·
* 조건 겹침 · 조건 안 맞음 · 오류) · DOM 없음
* 엔진을 정적으로 가져옴 — 화면(Entry)은 DataFlow 를 import() 로 늦게 불러 첫 묶음에 안 들어가게
* ========================================================================== */
@@ -9,19 +11,50 @@ import { currentLanguageIndex } from "@ui/ui_template_locale";
import { parseA1, toA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import type { CalcValue, Cell, Sheet } from "../../A00_Common/spreadsheet/spreadsheet_types";
import { OUTPUT_PORT, portsIn, portsOut, type CfBox, type CfLogic } from "./M02_DF_Model";
import {
OUTPUT_PORT,
depends,
designInputs,
elementPort,
portsIn,
portsOut,
ruleSources,
type CfBox,
type CfLink,
type CfLogic,
} from "./M02_DF_Model";
import { applyRound, applyRule, ruleLabel } from "./M02_DF_Rule";
/** 알약 값 — 수 · 글 · 참거짓 · 빈 값 · 표(머리 줄 포함 칸 덩어리) */
export type CfValue = number | string | boolean | null | (number | string | null)[][];
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값) */
/** 입력 값 묶음 — 상자 id → 알약 이름 → 값(입력 상자 요소 값 · 표 · 변수 값 · 함수 상자 변수 값 · 다른 로직 출력) */
export type CfValues = Record<string, Record<string, CfValue>>;
type Scalar = number | string | boolean | null;
/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */
export interface CfBreak {
kind:
| "빈 받는 자리"
| "통과 안 됨"
| "표에 없는 조건"
| "맞는 재료 없음"
| "조건 겹침"
| "조건 안 맞음"
| "오류";
box: string;
port?: string;
link?: string;
/** 쉬운 말 */
text: string;
}
export interface CfEvalResult {
/** 상자 id → 내보내는 알약 이름 → 값 */
ports: Record<string, Record<string, CfValue>>;
/** 상자 id → 받는 알약 이름 → 선 규칙을 지나 들어온 값 */
arrived?: Record<string, Record<string, CfValue>>;
/** 출력 상자 id → 값 */
outputs: Record<string, Scalar>;
cost: {
@@ -34,6 +67,14 @@ export interface CfEvalResult {
};
/** 쉬운 말 */
errors: string[];
/** 끊긴 곳(셈 때 생긴 것) */
breaks?: CfBreak[];
}
/** 다른 로직 입력을 셀 때 쓸 로직들(저장 · 견본 key → 로직) */
export interface CfEvalOptions {
logics?: Record<string, CfLogic>;
depth?: number;
}
const TEXT = {
@@ -58,6 +99,40 @@ const TEXT = {
"Pill 「{pill}」 of 「{box}」 is not placed in a cell",
],
NoValue: ["「{box}」 의 「{pill}」 값이 아직 없음", "「{pill}」 of 「{box}」 has no value yet"],
/** {from} {box} {rule} */
NotPassed: [
"「{from}」 → 「{box}」 선이 조건({rule})을 통과하지 않음",
"Line 「{from}」 → 「{box}」 doesn't pass ({rule})",
],
NoRow: [
"「{from}」 → 「{box}」 선 — 표에 맞는 줄이 없음",
"Line 「{from}」 → 「{box}」 — no matching table row",
],
ManyRows: [
"「{from}」 → 「{box}」 선 — 표에 맞는 줄이 여럿",
"Line 「{from}」 → 「{box}」 — several table rows match",
],
RuleError: [
"「{from}」 → 「{box}」 선 규칙을 셀 수 없음",
"Line 「{from}」 → 「{box}」 rule can't be worked out",
],
/** {box} {pill} */
NoMaterial: [
"「{box}」 의 「{pill}」 — 조건에 맞는 재료가 없음",
"「{pill}」 of 「{box}」 — no material matches",
],
Overlap: [
"「{box}」 의 「{pill}」 에서 나간 조건 선이 둘 이상 통과함",
"Several condition lines from 「{pill}」 of 「{box}」 pass",
],
NoneMatch: [
"「{box}」 의 「{pill}」 에서 나간 조건 선이 하나도 통과하지 않음",
"No condition line from 「{pill}」 of 「{box}」 passes",
],
NoLogic: [
"「{box}」 이 부르는 로직을 찾을 수 없음",
"The logic used by 「{box}」 can't be found",
],
} as const;
const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
@@ -68,23 +143,22 @@ const tl = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =
/* ── 차례 ───────────────────────────────────────────────────────────────── */
/** 함수 상자 차례 — 앞 함수가 먼저 · 돌고 도는 상자는 loop 로 */
function order(lg: CfLogic): { funcs: CfBox[]; loop: CfBox[] } {
const funcs = lg.boxes.filter((b) => b.kind === "function");
/** 상자 차례 — 기댐(선 · 규칙 조건 · 재료 조건)대로 · 돌고 도는 상자는 loop */
function order(lg: CfLogic): { boxes: CfBox[]; loop: CfBox[] } {
const deps = depends(lg);
const done: CfBox[] = [];
const left = new Set(funcs.map((f) => f.id));
const left = new Set(lg.boxes.map((b) => b.id));
for (let moved = true; moved && left.size;) {
moved = false;
for (const f of funcs) {
if (!left.has(f.id)) continue;
const waits = lg.links.some((l) => l.to.box === f.id && left.has(l.from.box));
if (waits) continue;
done.push(f);
left.delete(f.id);
for (const b of lg.boxes) {
if (!left.has(b.id)) continue;
if (deps.some((d) => d.to === b.id && d.from !== b.id && left.has(d.from))) continue;
done.push(b);
left.delete(b.id);
moved = true;
}
}
return { funcs: done, loop: funcs.filter((f) => left.has(f.id)) };
return { boxes: done, loop: lg.boxes.filter((b) => left.has(b.id)) };
}
/* ── 칸 ─────────────────────────────────────────────────────────────────── */
@@ -113,16 +187,18 @@ const fromCalc = (v: CalcValue | undefined): Scalar => {
return v.오류;
};
/* ── 계산 ───────────────────────────────────────────────────────────────── */
/* ── 상자마다 ───────────────────────────────────────────────────────────── */
/** 입력 상자 알약 값 — 넘긴 값 · 없으면 변수 예시값 · 사용자 요소 값 */
function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record<string, CfValue> {
const given = values[box.id] ?? {};
const pills = portsOut(box);
function inputPorts(
lg: CfLogic,
box: CfBox,
given: Record<string, CfValue>,
): Record<string, CfValue> {
const out: Record<string, CfValue> = {};
const inp = box.input;
if (!inp) return out;
// portsOut 차례 = 요소 · 표 · 변수
const pills = portsOut(box);
const sources: CfValue[] = [
...(inp.mode === "group"
? inp.elements.map((e) => lg.userElements.find((u) => u.id === e.ref)?.value ?? null)
@@ -134,16 +210,16 @@ function inputPorts(lg: CfLogic, box: CfBox, values: CfValues): Record<string, C
return out;
}
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 */
/** 함수 상자 하나 — 받는 칸 · 변수 칸에 값을 넣고 시트 하나를 셈 · 출력 끝수 */
function runFunc(
box: CfBox,
incoming: Record<string, CfValue>,
arrive: Record<string, CfValue>,
given: Record<string, CfValue>,
errors: string[],
): Scalar {
const func = box.func!;
const sheet: Sheet = structuredClone(func.sheet);
for (const i of func.inputs) put(sheet.칸, i.cell, incoming[i.port] ?? given[i.port] ?? null);
for (const i of func.inputs) put(sheet.칸, i.cell, arrive[i.port] ?? given[i.port] ?? null);
for (const v of func.variables)
if (v.cell) put(sheet.칸, v.cell, v.name in given ? given[v.name] : v.example);
const values = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
@@ -154,79 +230,203 @@ function runFunc(
}
const at = func.output.cell.toUpperCase();
const c = sheet.칸[at];
if (!c) return null;
return c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
const got = !c ? null : c.식 !== undefined ? fromCalc(values[sheet.id]?.[at]) : (c.값 ?? null);
return typeof got === "number" && func.output.round ? applyRound(func.output.round, got) : got;
}
/** 화면 계산 */
export function evalLogic(lg: CfLogic, values: CfValues = {}): CfEvalResult {
/* ── 계산 ───────────────────────────────────────────────────────────────── */
/** 화면 계산 — opts.logics 가 있으면 다른 로직 입력을 그 로직으로 셈(없으면 values 의 값) */
export function evalLogic(
lg: CfLogic,
values: CfValues = {},
opts: CfEvalOptions = {},
): CfEvalResult {
const errors: string[] = [];
const found: CfBreak[] = [];
const ports: CfEvalResult["ports"] = {};
for (const b of lg.boxes) if (b.kind === "input") ports[b.id] = inputPorts(lg, b, values);
const into = (box: string): Record<string, CfValue> =>
Object.fromEntries(
lg.links
.filter((l) => l.to.box === box)
.map((l) => [l.to.name, ports[l.from.box]?.[l.from.name] ?? null]),
const arrived: NonNullable<CfEvalResult["arrived"]> = {};
const name = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
const valueOf = (box: string, pill: string): CfValue => ports[box]?.[pill] ?? null;
const passed = new Map<string, boolean>();
/** 선 규칙에서 막힌 상자 — 그 함수는 셈하지 않고 빈 값을 넘김(갈래가 끊김) */
const blocked = new Set<string>();
/** 선 하나로 들어오는 값 — 규칙이 있으면 엔진으로 */
const through = (l: CfLink): CfValue => {
const v = valueOf(l.from.box, l.from.name);
if (!l.rule) return v;
if (v === null && (l.rule.kind === "scale" || l.rule.kind === "round")) return null;
const conds = Object.fromEntries(
ruleSources(l.rule).map((p) => [p.name, valueOf(p.box, p.name)]),
);
const { funcs, loop } = order(lg);
for (const f of loop) errors.push(tl("Loop", { box: f.name }));
for (const f of funcs)
ports[f.id] = { [f.func!.output.name]: runFunc(f, into(f.id), values[f.id] ?? {}, errors) };
const res = applyRule(l.rule, v, conds);
if (l.rule.kind === "gate") passed.set(l.id, res.why === null);
const fill = { from: name(l.from.box), box: name(l.to.box), rule: ruleLabel(l.rule) };
if (res.why === "통과 안 됨")
found.push({
kind: "통과 안 됨",
box: l.to.box,
port: l.to.name,
link: l.id,
text: tl("NotPassed", fill),
});
else if (res.why) {
const kind = res.why === "표에 없는 조건" ? "표에 없는 조건" : "오류";
const key =
res.why === "표에 없는 조건"
? "NoRow"
: res.why === "표에 여럿 맞음"
? "ManyRows"
: "RuleError";
const text = tl(key, fill);
found.push({ kind, box: l.to.box, port: l.to.name, link: l.id, text });
errors.push(text);
}
if (res.why) blocked.add(l.to.box);
return res.why ? null : res.value;
};
const arrive = (box: CfBox): Record<string, CfValue> => {
const got = Object.fromEntries(
lg.links.filter((l) => l.to.box === box.id).map((l) => [l.to.name, through(l)]),
);
arrived[box.id] = got;
return got;
};
const { boxes, loop } = order(lg);
for (const b of loop) errors.push(tl("Loop", { box: b.name }));
const outputs: CfEvalResult["outputs"] = {};
const cost: CfEvalResult["cost"] = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
for (const b of lg.boxes.filter((x) => x.kind === "output")) {
const v = into(b.id)[OUTPUT_PORT] ?? null;
const scalar: Scalar = Array.isArray(v) ? null : v;
outputs[b.id] = scalar;
const kind = b.output?.cost ?? "값";
if (kind === "값") cost.값들[b.name] = scalar;
else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar;
else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name }));
for (const b of boxes) {
const given = values[b.id] ?? {};
if (b.kind === "input" && b.input?.mode !== "logic") {
ports[b.id] = inputPorts(lg, b, given);
for (const e of b.input?.elements ?? [])
if (e.mode === "rule" && ports[b.id][elementPort(e)] === null) {
const text = tl("NoMaterial", { box: b.name, pill: elementPort(e) });
found.push({ kind: "맞는 재료 없음", box: b.id, port: elementPort(e), text });
errors.push(text);
}
} else if (b.kind === "input") ports[b.id] = otherLogic(b, arrive(b), given, opts, errors);
else if (b.kind === "function") {
const got = arrive(b);
ports[b.id] = {
[b.func!.output.name]: blocked.has(b.id) ? null : runFunc(b, got, given, errors),
};
} else {
const v = arrive(b)[OUTPUT_PORT] ?? null;
let scalar: Scalar = Array.isArray(v) ? null : v;
const round = b.output?.round;
if (typeof scalar === "number" && round) scalar = applyRound(round, scalar);
outputs[b.id] = scalar;
const kind = b.output?.cost ?? "값";
if (kind === "값") cost.값들[b.name] = scalar;
else if (typeof scalar === "number" && Number.isFinite(scalar)) cost[kind] += scalar;
else if (scalar !== null) errors.push(tl("NotNumber", { box: b.name }));
}
}
cost.계 = cost.노무비 + cost.재료비 + cost.경비;
return { ports, outputs, cost, errors };
gates(lg, passed, name, found, errors);
return { ports, arrived, outputs, cost, errors, breaks: found };
}
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
export function checkLogic(lg: CfLogic, values: CfValues = {}): string[] {
const out: string[] = [];
/** 다른 로직 입력 — 그 로직을 받은 값(설계 때 정할 것)으로 셈 · 없으면 넘긴 값 */
function otherLogic(
box: CfBox,
arrive: Record<string, CfValue>,
given: Record<string, CfValue>,
opts: CfEvalOptions,
errors: string[],
): Record<string, CfValue> {
const ref = box.input?.logic;
const out: Record<string, CfValue> = {};
const other = ref ? opts.logics?.[ref.key] : undefined;
if (!other || (opts.depth ?? 0) > 8) {
if (ref && opts.logics && !other) errors.push(tl("NoLogic", { box: box.name }));
for (const p of ref?.outputs ?? []) out[p.name] = given[p.name] ?? null;
return out;
}
const inner: CfValues = {};
for (const d of designInputs(other)) {
const v = d.port in arrive ? arrive[d.port] : given[d.port];
if (v !== undefined && v !== null) (inner[d.box] ??= {})[d.port] = v;
}
const ev = evalLogic(other, inner, { logics: opts.logics, depth: (opts.depth ?? 0) + 1 });
for (const e of ev.errors) errors.push(`「${box.name}」 · ${e}`);
for (const p of ref?.outputs ?? []) {
const ob = other.boxes.find((b) => b.kind === "output" && b.name === p.name);
out[p.name] = ob ? (ev.outputs[ob.id] ?? null) : (given[p.name] ?? null);
}
return out;
}
/** 한 알약에서 나간 조건 선(둘 이상) — 겹침 · 안 맞음 */
function gates(
lg: CfLogic,
passed: Map<string, boolean>,
name: (id: string) => string,
found: CfBreak[],
errors: string[],
): void {
const groups = new Map<string, CfLink[]>();
for (const l of lg.links)
if (l.rule?.kind === "gate") {
const k = `${l.rule.from.box}|${l.rule.from.name}|${l.from.box}|${l.from.name}`;
groups.set(k, [...(groups.get(k) ?? []), l]);
}
for (const list of groups.values()) {
if (list.length < 2) continue;
const n = list.filter((l) => passed.get(l.id)).length;
if (n === 1) continue;
const g = list[0].rule as Extract<CfLink["rule"], { kind: "gate" }>;
const fill = { box: name(g.from.box), pill: g.from.name };
const text = tl(n > 1 ? "Overlap" : "NoneMatch", fill);
found.push({
kind: n > 1 ? "조건 겹침" : "조건 안 맞음",
box: g.from.box,
port: g.from.name,
text,
});
errors.push(text);
}
}
/* ── 검사 ───────────────────────────────────────────────────────────────── */
/** 끊긴 곳 목록 — 빈 받는 자리(다른 로직의 받는 알약은 설계 때 정할 것이라 뺌) + 셈 때 생긴 것 */
export function breaks(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): CfBreak[] {
const out: CfBreak[] = [];
for (const b of lg.boxes) {
if (b.kind === "input") continue;
for (const p of portsIn(b))
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
out.push(tl("EmptyIn", { box: b.name, pill: p.name }));
out.push({
kind: "빈 받는 자리",
box: b.id,
port: p.name,
text: tl("EmptyIn", { box: b.name, pill: p.name }),
});
}
return [...out, ...(evalLogic(lg, values, opts).breaks ?? [])];
}
/** 검사 — 빈 받는 자리 · 출력 칸 없음 · 칸에 안 놓인 받는 알약 · 출력 상자 없음 · 값 없는 입력 · 순환 · 오류(쉬운 말 목록) */
export function checkLogic(lg: CfLogic, values: CfValues = {}, opts: CfEvalOptions = {}): string[] {
const out: string[] = [];
for (const b of lg.boxes) {
if (b.kind !== "input")
for (const p of portsIn(b))
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
out.push(tl("EmptyIn", { box: b.name, pill: p.name }));
if (b.kind === "function" && !parseA1(b.func?.output.cell ?? ""))
out.push(tl("NoCell", { box: b.name }));
for (const i of b.func?.inputs ?? [])
if (!parseA1(i.cell)) out.push(tl("NoInCell", { box: b.name, pill: i.port }));
}
if (!lg.boxes.some((b) => b.kind === "output")) out.push(tl("NoOutput"));
const ev = evalLogic(lg, values);
for (const b of lg.boxes.filter((x) => x.kind === "input"))
const ev = evalLogic(lg, values, opts);
for (const b of lg.boxes.filter((x) => x.kind === "input" && x.input?.mode !== "logic"))
for (const [pill, v] of Object.entries(ev.ports[b.id] ?? {}))
if (v === null && lg.links.some((l) => l.from.box === b.id && l.from.name === pill))
out.push(tl("NoValue", { box: b.name, pill }));
return [...out, ...ev.errors];
}
/** 끊긴 곳 하나 — 상자 · 알약 · 선 위에 표시할 것 */
export interface CfBreak {
kind:
| "빈 받는 자리"
| "통과 안 됨"
| "표에 없는 조건"
| "맞는 재료 없음"
| "조건 겹침"
| "조건 안 맞음"
| "오류";
box: string;
port?: string;
link?: string;
/** 쉬운 말 */
text: string;
}
/** 끊긴 곳 목록(넓힘 · 준비 중) */
export function breaks(_lg: CfLogic, _values: CfValues = {}): CfBreak[] {
throw new Error("준비 중");
return [...new Set([...out, ...ev.errors])];
}
@@ -50,13 +50,6 @@
font-size: var(--text-body-sm);
}
.m02-dfpanel .ui-btn,
.m02-dfpanel .ui-input,
.m02-dfpanel .ui-select {
min-height: 44px;
font-size: var(--text-body);
}
.m02-dfpanel-form {
align-items: flex-end;
padding: var(--spacing-8) var(--spacing-12);
@@ -92,8 +85,7 @@
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
min-height: 44px;
padding: 0 var(--spacing-12);
padding: var(--spacing-4) var(--spacing-12);
border: 2px solid;
border-radius: var(--radius-pills);
background: var(--color-surface);
+53 -102
View File
@@ -7,100 +7,18 @@
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type {
CalcValues,
SpreadsheetHandle,
Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet";
import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model";
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
import { createRoundRow } from "./M02_DF_RoundRow";
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
import "./M02_DF_FuncPanel.css";
const TEXT = {
Unit: ["단위", "Unit"],
Cell: ["고른 칸", "Selected cell"],
Got: ["받은 알약", "Received pills"],
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
Placed: ["놓임", "placed"],
Free: ["아직 안 놓은 알약", "not placed yet"],
Busy: ["이미 쓰는 칸", "That cell is already in use"],
Locked: [
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
"This cell is filled by the wire — cannot edit",
],
Vars: ["변수", "Variables"],
MakeVar: ["변수 만들기", "Make variable"],
NoVars: ["아직 변수가 없음", "No variables yet"],
VarName: ["이름", "Name"],
VarType: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
VarUnit: ["단위", "Unit"],
VarMin: ["최소", "Min"],
VarMax: ["최대", "Max"],
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
VarExample: ["예시값", "Example value"],
VarCell: ["붙을 칸", "Attach to"],
Make: ["만들기", "Make"],
Cancel: ["취소", "Cancel"],
Remove: ["지움", "Remove"],
NeedName: ["이름을 적음", "Enter a name"],
SameName: ["같은 이름이 이미 있음", "That name already exists"],
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
OutBtn: ["출력 칸으로", "Make output cell"],
OutNow: ["출력", "Output"],
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
Frames: ["틀", "Templates"],
FrameMul: ["곱하기", "Multiply"],
FrameMore: ["할증", "Markup"],
FrameDiv: ["나누기", "Divide"],
FrameIf: ["만약", "If"],
FrameMin: ["최소", "Min"],
FrameMax: ["최대", "Max"],
FrameUp: ["올림", "Round up"],
FrameLook: ["표에서 찾기", "Look up in table"],
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
ReadOnly: ["읽기만", "Read only"],
Loading: ["시트를 불러오는 중", "Loading sheet"],
Problem: ["⚠ ", "⚠ "],
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
Err_DIV0: ["0으로 나눔", "divides by zero"],
Err_NAME: ["알 수 없는 이름", "unknown name"],
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
Err_NA: ["찾는 값이 없음", "value not found"],
Err_OTHER: ["수식이 틀림", "formula is wrong"],
} as const;
const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
const FRAMES: [keyof typeof TEXT, string][] = [
["FrameMul", "=B1*B2"],
["FrameMore", "=B1*(1+B2/100)"],
["FrameDiv", "=B1/B2"],
["FrameIf", '=IF(B3="급",10,0)'],
["FrameMin", "=MIN(B1,B2)"],
["FrameMax", "=MAX(B1,B2)"],
["FrameUp", "=ROUNDUP(B4,3)"],
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
];
const ERR_KEY: Record<string, keyof typeof TEXT> = {
"#DIV/0!": "Err_DIV0",
"#NAME?": "Err_NAME",
"#REF!": "Err_REF",
"#VALUE!": "Err_VALUE",
"#CYCLE!": "Err_CYCLE",
"#N/A": "Err_NA",
};
type Handlers = { change(lg: CfLogic): void };
interface VarForm {
@@ -126,6 +44,8 @@ interface State {
json: string;
cell: string;
form: VarForm | null;
/** 단위 표준이 제안한 출력 소수자리 */
suggest: number | null;
parts: {
head: HTMLElement;
got: HTMLElement;
@@ -317,20 +237,19 @@ function applyMarks(st: State): void {
function drawHead(st: State): void {
const box = boxOf(st);
const unit = el("input", {
className: "ui-input m02-dfpanel-unit",
attrs: { type: "text", "aria-label": tf("Unit") },
});
unit.value = box?.unit ?? "-";
if (st.readOnly) unit.readOnly = true;
unit.addEventListener("change", () => {
const v = unit.value.trim() || "-";
unit.value = v;
patchBox(st, (b) => ({
...b,
unit: v,
func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func,
}));
const unit = createUnitPick({
value: box?.unit ?? "-",
readOnly: st.readOnly,
extra: st.lg.resultUnit ? [st.lg.resultUnit] : [],
ariaLabel: tf("Unit"),
onPick: (v) => {
patchBox(st, (x) => ({
...x,
unit: v,
func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func,
}));
void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st)));
},
});
st.parts.head.replaceChildren(
el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }),
@@ -372,8 +291,21 @@ function formField(
cls = "",
): HTMLElement {
const form = st.form!;
if (key === "unit") {
return el("label", {
className: "m02-dfpanel-fieldbox",
children: [
el("span", { className: "m02-dfpanel-note", text: tf(label) }),
createUnitPick({
value: form.unit || "-",
ariaLabel: tf(label),
onPick: (u) => ((form.unit = u), drawVars(st)),
}),
],
});
}
const input = el("input", {
className: `ui-input m02-dfpanel-field ${cls}`,
className: `ui-input ui-input--sm m02-dfpanel-field ${cls}`,
attrs: { type: "text", "aria-label": tf(label) },
});
input.value = form[key];
@@ -443,7 +375,7 @@ function drawVars(st: State): void {
);
} else {
const type = el("select", {
className: "ui-select m02-dfpanel-field",
className: "ui-select ui-select--sm m02-dfpanel-field",
attrs: { "aria-label": tf("VarType") },
});
for (const [v, k] of [
@@ -520,6 +452,16 @@ function drawBar(st: State): void {
el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }),
],
}),
...(f?.output.cell
? [
createRoundRow({
round: f.output.round,
suggest: st.suggest,
readOnly: st.readOnly,
onApply: (round) => writeRound(st, round),
}),
]
: []),
el("div", {
className: "m02-dfpanel-row",
children: [
@@ -538,6 +480,14 @@ function drawBar(st: State): void {
);
}
/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */
function writeRound(st: State, round: CfRound | null): void {
patchFunc(st, (x) => {
const { round: _old, ...rest } = x.output;
return { ...x, output: round ? { ...rest, round } : rest };
});
}
/** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */
function setOutput(st: State, cell: string): void {
const f = funcOf(st);
@@ -681,6 +631,7 @@ export function renderFuncPanel(
json: "",
cell: "A1",
form: null,
suggest: null,
parts,
observer: null,
redraw: () => {
@@ -0,0 +1,90 @@
/* =============================================================================
* M02_DF_FuncText.ts
* 함수 상자 판 글 [한, 영] 짝 · 틀 단추 수식 · 수식 오류 말 — 판 파일이 700줄 안에 들도록 뗌
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
export const TEXT = {
Unit: ["단위", "Unit"],
Cell: ["고른 칸", "Selected cell"],
Got: ["받은 알약", "Received pills"],
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
Placed: ["놓임", "placed"],
Free: ["아직 안 놓은 알약", "not placed yet"],
Busy: ["이미 쓰는 칸", "That cell is already in use"],
Locked: [
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
"This cell is filled by the wire — cannot edit",
],
Vars: ["변수", "Variables"],
MakeVar: ["변수 만들기", "Make variable"],
NoVars: ["아직 변수가 없음", "No variables yet"],
VarName: ["이름", "Name"],
VarType: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
VarUnit: ["단위", "Unit"],
VarMin: ["최소", "Min"],
VarMax: ["최대", "Max"],
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
VarExample: ["예시값", "Example value"],
VarCell: ["붙을 칸", "Attach to"],
Make: ["만들기", "Make"],
Cancel: ["취소", "Cancel"],
Remove: ["지움", "Remove"],
NeedName: ["이름을 적음", "Enter a name"],
SameName: ["같은 이름이 이미 있음", "That name already exists"],
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
OutBtn: ["출력 칸으로", "Make output cell"],
OutNow: ["출력", "Output"],
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
Frames: ["틀", "Templates"],
FrameMul: ["곱하기", "Multiply"],
FrameMore: ["할증", "Markup"],
FrameDiv: ["나누기", "Divide"],
FrameIf: ["만약", "If"],
FrameMin: ["최소", "Min"],
FrameMax: ["최대", "Max"],
FrameUp: ["올림", "Round up"],
FrameLook: ["표에서 찾기", "Look up in table"],
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
ReadOnly: ["읽기만", "Read only"],
Loading: ["시트를 불러오는 중", "Loading sheet"],
Problem: ["⚠ ", "⚠ "],
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
Err_DIV0: ["0으로 나눔", "divides by zero"],
Err_NAME: ["알 수 없는 이름", "unknown name"],
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
Err_NA: ["찾는 값이 없음", "value not found"],
Err_OTHER: ["수식이 틀림", "formula is wrong"],
} as const;
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
export const FRAMES: [keyof typeof TEXT, string][] = [
["FrameMul", "=B1*B2"],
["FrameMore", "=B1*(1+B2/100)"],
["FrameDiv", "=B1/B2"],
["FrameIf", '=IF(B3="급",10,0)'],
["FrameMin", "=MIN(B1,B2)"],
["FrameMax", "=MAX(B1,B2)"],
["FrameUp", "=ROUNDUP(B4,3)"],
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
];
export const ERR_KEY: Record<string, keyof typeof TEXT> = {
"#DIV/0!": "Err_DIV0",
"#NAME?": "Err_NAME",
"#REF!": "Err_REF",
"#VALUE!": "Err_VALUE",
"#CYCLE!": "Err_CYCLE",
"#N/A": "Err_NA",
};
@@ -0,0 +1,82 @@
.m02-dflink {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m02-dflink-head,
.m02-dflink-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dflink-label {
font-weight: var(--font-weight-bold);
}
.m02-dflink-note {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-dflink-value {
padding: 2px var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
background: var(--color-surface-raised);
font-weight: var(--font-weight-medium);
}
.m02-dflink-num {
width: 7em;
}
.m02-dflink-warn {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-warning);
background: var(--color-surface-raised);
font-weight: var(--font-weight-medium);
}
/* 알약 판 */
.m02-dfpill {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m02-dfpill-title {
font-weight: var(--font-weight-bold);
}
.m02-dfpill-list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0;
padding: 0;
list-style: none;
}
.m02-dfpill-step {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.m02-dfpill-rule {
padding: 0 var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-pills);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
@@ -0,0 +1,388 @@
/* =============================================================================
* M02_DF_LinkPanel.ts
* 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ·
* 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfValues } from "./M02_DF_Eval";
import {
portsOut,
reaches,
type CfGateOp,
type CfLogic,
type CfPort,
type CfRound,
type CfRule,
} from "./M02_DF_Model";
import {
GATE_OPS,
SCALE_OPS,
evalSafe,
gateReport,
labelOf,
portText,
preview,
unitOf,
valueOf,
} from "./M02_DF_Detail";
import { suggestPlaces } from "./M02_DF_UnitPick";
import "./M02_DF_LinkPanel.css";
const TEXT = {
Title: ["선 규칙", "Line rule"],
Kinds: ["규칙 고르기", "Pick a rule"],
K_none: ["없음", "None"],
K_lookup: ["표에서 찾기", "Look up in table"],
K_gate: ["조건 통과", "Pass if"],
K_scale: ["바꾸기", "Change"],
K_round: ["끝수", "Rounding"],
Label: ["선 위 글", "Text on the line"],
Before: ["지나가기 전 값", "Value before"],
After: ["지난 뒤 값", "Value after"],
Col: ["조건 칸", "Condition column"],
Match: ["맞출 알약", "Pill to match"],
Take: ["꺼낼 칸", "Column to take"],
AddCond: ["+ 조건 칸", "+ Condition"],
Remove: ["지움", "Remove"],
NoHeader: [
"보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름",
"The sender is not a table or it is not loaded yet",
],
Pick: ["고르기", "pick"],
GateHint: [
"알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음",
"Sent only when the pill passes",
],
Value: ["값", "Value"],
ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"],
Places: ["소수 {n}자리", "{n} decimals"],
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
Apply: ["적용", "Apply"],
WarnBoth: [
"같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침",
"{n} conditional lines pass at once — overlap",
],
WarnNone: [
"같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음",
"None of the {n} conditional lines match",
],
WarnLookup: [
"조건 칸이 없음 — 표에서 줄을 못 고름",
"No condition column — a row can't be picked",
],
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
Count: ["선", "Line"],
} as const;
const tk = (key: keyof typeof TEXT, n?: number | string): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
type Kind = "none" | CfRule["kind"];
const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"];
const SEP = "\u0001";
interface Ctx {
lg: CfLogic;
linkId: string;
readOnly: boolean;
headers: string[];
change(lg: CfLogic): void;
}
const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`;
const fromKey = (k: string): CfPort => {
const [box, ...rest] = k.split(SEP);
return { box, name: rest.join(SEP) };
};
function setRule(ctx: Ctx, rule: CfRule | undefined): void {
ctx.change({
...ctx.lg,
links: ctx.lg.links.map((l) => {
if (l.id !== ctx.linkId) return l;
const { rule: _old, ...rest } = l;
return rule ? { ...rest, rule } : rest;
}),
});
}
function select(
ctx: Ctx,
options: { value: string; text: string }[],
value: string,
onPick: (v: string) => void,
aria: string,
): HTMLElement {
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 }));
if (!options.some((o) => o.value === value))
s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") }));
s.value = value;
s.disabled = ctx.readOnly;
s.addEventListener("change", () => onPick(s.value));
return s;
}
function numberBox(
ctx: Ctx,
value: number | string,
onCommit: (v: number | string) => void,
aria: string,
): HTMLElement {
const i = el("input", {
className: "ui-input ui-input--sm m02-dflink-num",
attrs: { type: "text", "aria-label": aria },
});
i.value = String(value);
i.readOnly = ctx.readOnly;
i.addEventListener("change", () => {
const t = i.value.trim().replace(/,/g, "");
onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t);
});
return i;
}
/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */
const pillOptions = (ctx: Ctx): { value: string; text: string }[] => {
const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? "";
return ctx.lg.boxes
.filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id))
.flatMap((b) =>
portsOut(b).map((p) => ({
value: toKey({ box: b.id, name: p.name }),
text: portText(ctx.lg, { box: b.id, name: p.name }),
})),
);
};
const row = (...children: (HTMLElement | string)[]): HTMLElement =>
el("div", { className: "m02-dflink-row", children });
/* ── 규칙마다 ─────────────────────────────────────────────────────────── */
function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTMLElement[] {
const cols = [
...new Set([...ctx.headers, ...rule.conds.map((c) => c.col), rule.col].filter(Boolean)),
];
const colOpts = cols.map((c) => ({ value: c, text: c }));
const pills = pillOptions(ctx);
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
const rows = rule.conds.map((c, i) =>
row(
select(
ctx,
colOpts,
c.col,
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }),
tk("Col"),
),
"=",
select(
ctx,
pills,
toKey(c.from),
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }),
tk("Match"),
),
ctx.readOnly
? ""
: createButton({
label: tk("Remove"),
variant: "ghost",
size: "sm",
onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }),
}),
),
);
const out: HTMLElement[] = [...rows];
if (!ctx.readOnly) {
out.push(
row(
createButton({
label: tk("AddCond"),
variant: "ghost",
size: "sm",
disabled: !pills.length || !cols.length,
onClick: () =>
put({ conds: [...rule.conds, { col: cols[0], from: fromKey(pills[0].value) }] }),
}),
),
);
}
out.push(
row(
el("span", { className: "m02-dflink-label", text: tk("Take") }),
select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")),
),
);
if (!cols.length) out.push(el("p", { className: "m02-dflink-note", text: tk("NoHeader") }));
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"),
),
select(
ctx,
GATE_OPS.map((o) => ({ value: o, text: o })),
rule.op,
(v) => put({ op: v as CfGateOp }),
tk("Kinds"),
),
numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")),
),
];
}
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
return [
el("p", { className: "m02-dflink-note", text: tk("ScaleHint") }),
row(
select(
ctx,
SCALE_OPS.map((o) => ({ value: o, text: o })),
rule.op,
(v) => setRule(ctx, { ...rule, op: v as typeof rule.op }),
tk("Kinds"),
),
numberBox(
ctx,
rule.value,
(v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }),
tk("Value"),
),
),
];
}
function roundEditor(
ctx: Ctx,
rule: Extract<CfRule, { kind: "round" }>,
unit: string,
): HTMLElement[] {
const put = (next: Partial<CfRound>): void => setRule(ctx, { ...rule, ...next });
const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) }));
const hint = el("span", { className: "m02-dflink-note" });
void suggestPlaces(unit).then((n) => {
if (n === null || n === rule.places || ctx.readOnly) return;
hint.append(
tk("Suggest", n),
" ",
createButton({
label: tk("Apply"),
variant: "ghost",
size: "sm",
onClick: () => put({ places: n }),
}),
);
});
return [
row(
select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")),
select(
ctx,
(["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })),
rule.method,
(v) => put({ method: v as CfRound["method"] }),
tk("K_round"),
),
hint,
),
];
}
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }),
scale: () => ({ kind: "scale", op: "×", value: 1 }),
round: () => ({ kind: "round", places: 0, method: "반올림" }),
};
/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */
export function renderLinkPanel(
host: HTMLElement,
lg: CfLogic,
linkId: string,
on: { change(lg: CfLogic): void },
opts: { values?: CfValues; readOnly?: boolean } = {},
): void {
const link = lg.links.find((l) => l.id === linkId);
host.classList.add("m02-dflink");
if (!link) return host.replaceChildren();
const ev = evalSafe(lg, opts.values);
const table = valueOf(ev, link.from);
const headers =
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 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("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
],
}),
row(
el("span", { className: "m02-dflink-label", text: tk("Kinds") }),
...KINDS.map((k) =>
createButton({
label: tk(`K_${k}` as "K_none"),
variant: k === kind ? "filled" : "ghost",
size: "sm",
disabled: ctx.readOnly,
onClick: () =>
k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)),
}),
),
),
];
if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule));
if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule));
if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule));
if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from)));
const pv = preview(link, rule, ev);
parts.push(
row(
el("span", { className: "m02-dflink-label", text: tk("Before") }),
el("span", { className: "m02-dflink-value", text: pv.before }),
"→",
el("span", { className: "m02-dflink-label", text: tk("After") }),
el("span", { className: "m02-dflink-value", text: pv.after }),
),
row(
el("span", { className: "m02-dflink-label", text: tk("Label") }),
el("span", { text: labelOf(lg, rule) }),
),
);
const warns: string[] = [];
if (rule?.kind === "gate") {
const rep = gateReport(lg, link, ev);
if (rep.pass.length > 1) warns.push(tk("WarnBoth", rep.pass.length));
if (rep.total > 0 && rep.pass.length === 0) warns.push(tk("WarnNone", rep.total));
}
if (rule?.kind === "lookup") {
if (!rule.conds.length) warns.push(tk("WarnLookup"));
if (!rule.col) warns.push(tk("WarnTake"));
}
parts.push(...warns.map((w) => el("p", { className: "m02-dflink-warn", text: `⚠ ${w}` })));
host.replaceChildren(...parts);
}
+191 -30
View File
@@ -193,6 +193,7 @@ const TEXT = {
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
Taken: ["이미 선이 들어온 자리", "This spot already has a line"],
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
CondPill: ["규칙의 조건 알약이 없음", "The rule's condition pill doesn't exist"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
@@ -309,57 +310,100 @@ export function setUnit(lg: CfLogic, id: string, unit: string): CfLogic {
/* ── 알약 ───────────────────────────────────────────────────────────────── */
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소면 element.name · 표면 「표」 · 변수면 variable.name
* 함수 = func.output.name 하나 · 출력 = 없음 */
/** 요소 알약 이름 — 재료 선택 자리 · 조건 찾기는 자리 이름(slot) · 나머지는 element.name */
export const elementPort = (e: CfElementRef): string =>
e.mode && e.mode !== "fixed" && e.slot?.trim() ? e.slot.trim() : e.name;
/** 내보내는 알약(이름 규칙 · 브레인 정함) — 입력 = 요소(elementPort) · 표면 「표」 · 변수면 variable.name
* · 다른 로직이면 그 로직 출력 · 함수 = func.output.name 하나 · 출력 = 없음 */
export function portsOut(box: CfBox): CfPill[] {
if (box.kind === "function" && box.func)
return [{ name: box.func.output.name, unit: box.func.output.unit || box.unit }];
if (box.kind !== "input" || !box.input) return [];
const inp = box.input;
if (inp.mode === "logic") return (inp.logic?.outputs ?? []).map((p) => ({ ...p }));
return [
...(inp.mode === "group"
? inp.elements.map((e) => ({ name: e.name, unit: e.unit || "-" }))
? inp.elements.map((e) => ({ name: elementPort(e), unit: e.unit || "-" }))
: []),
...(inp.mode === "group" && inp.table ? [{ name: TABLE_PORT, unit: "-" }] : []),
...inp.variables.map((v) => ({ name: v.name, unit: v.unit || "-" })),
];
}
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 입력 = 없음 */
/** 받는 알약 — 함수 = func.inputs[].port · 출력 = 「값」 하나 · 다른 로직 입력 = 그 로직의 설계 때 정할 것 · 그 밖 입력 = 없음 */
export function portsIn(box: CfBox): CfPill[] {
if (box.kind === "function" && box.func)
return box.func.inputs.map((i) => ({ name: i.port, unit: "-" }));
if (box.kind === "output") return [{ name: OUTPUT_PORT, unit: box.output?.unit ?? box.unit }];
if (box.input?.mode === "logic") return (box.input.logic?.designs ?? []).map((p) => ({ ...p }));
return [];
}
/** 알약 쪽 — 함수 출력 이름이면 out · 출력 상자는 in · 다른 로직 입력은 출력 이름이면 out */
export function portDir(box: CfBox, name: string): "in" | "out" {
if (box.kind === "output") return "in";
if (portsOut(box).some((p) => p.name === name)) return "out";
return "in";
}
/* ── 선 ─────────────────────────────────────────────────────────────────── */
/** from 상자에서 선을 따라 to 상자에 닿나 */
/** 선 규칙이 기대는 알약(조건 찾기 · 조건 통과) */
export function ruleSources(rule: CfRule | undefined): CfPort[] {
if (!rule) return [];
if (rule.kind === "lookup") return rule.conds.map((c) => c.from);
if (rule.kind === "gate") return [rule.from];
return [];
}
/** 상자 사이 기댐 — 선 + 선 규칙의 조건 알약 + 재료 조건 찾기의 알약(→ 그 상자) */
export function depends(lg: CfLogic): { from: string; to: string }[] {
const out: { from: string; to: string }[] = [];
for (const l of lg.links) {
out.push({ from: l.from.box, to: l.to.box });
for (const p of ruleSources(l.rule)) out.push({ from: p.box, to: l.to.box });
}
for (const b of lg.boxes)
for (const e of b.input?.elements ?? [])
if (e.mode === "rule" && e.rule) out.push({ from: e.rule.from.box, to: b.id });
return out;
}
/** from 상자에서 기댐을 따라 to 상자에 닿나 */
export function reaches(lg: CfLogic, from: string, to: string): boolean {
const deps = depends(lg);
const seen = new Set<string>();
const walk = (id: string): boolean => {
if (id === to) return true;
if (seen.has(id)) return false;
seen.add(id);
return lg.links.filter((l) => l.from.box === id).some((l) => walk(l.to.box));
return deps.filter((d) => d.from === id).some((d) => walk(d.to));
};
return walk(from);
}
/** 선 잇기 — 입력 → 함수 · 출력 / 함수 → 함수 · 출력 · 출력에서 나감 없음 · 순환 · 같은 받는 자리 두 번 막음
/** 선 잇기 — 입력 → 함수 · 출력 · 다른 로직 입력 / 함수 → 함수 · 출력 · 다른 로직 입력 · 출력에서 나감 없음 ·
* 순환 · 같은 받는 자리 두 번 막음 · 규칙(rule)은 함께 붙임(조건 알약이 없으면 막음)
* 받는 쪽이 함수 상자이고 to.name 의 받는 알약이 없으면 새로 만듦(to.name 이 빈 글이면 보낸 알약 이름 ·
* 같은 이름이 있으면 「이름 (2)」) · func.inputs 에 칸 없이(`cell: ""`) 더함 — 칸은 함수 판에서 놓음 */
export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why: string | null } {
export function link(
lg: CfLogic,
from: CfPort,
to: CfPort,
rule?: CfRule,
): { lg: CfLogic; why: string | null } {
const a = boxOf(lg, from.box);
const b = boxOf(lg, to.box);
if (!a || !b) return { lg, why: tl("NoPill") };
if (a.kind === "output") return { lg, why: tl("FromOutput") };
if (b.kind === "input") return { lg, why: tl("ToInput") };
if (b.kind === "input" && b.input?.mode !== "logic") return { lg, why: tl("ToInput") };
if (a.id === b.id) return { lg, why: tl("Self") };
if (!portsOut(a).some((p) => p.name === from.name)) return { lg, why: tl("NoPill") };
const has = portsIn(b).some((p) => p.name === to.name);
if (!has && b.kind !== "function") return { lg, why: tl("NoPill") };
const why = ruleWhy(lg, b.id, rule);
if (why) return { lg, why };
if (reaches(lg, b.id, a.id)) return { lg, why: tl("Loop") };
if (has && lg.links.some((l) => l.to.box === to.box && l.to.name === to.name))
return { lg, why: tl("Taken") };
@@ -376,13 +420,37 @@ export function link(lg: CfLogic, from: CfPort, to: CfPort): { lg: CfLogic; why:
"l",
next.links.map((l) => l.id),
);
return {
lg: {
...next,
links: [...next.links, { id, from: { ...from }, to: { box: to.box, name: port } }],
},
why: null,
};
const made: CfLink = { id, from: { ...from }, to: { box: to.box, name: port } };
if (rule) made.rule = clone(rule);
return { lg: { ...next, links: [...next.links, made] }, why: null };
}
/** 규칙의 조건 알약 검사 — 없거나 돌고 돌면 까닭 */
function ruleWhy(lg: CfLogic, target: string, rule: CfRule | undefined): string | null {
for (const p of ruleSources(rule)) {
const box = boxOf(lg, p.box);
if (!box || !portsOut(box).some((x) => x.name === p.name)) return tl("CondPill");
if (box.id === target || reaches(lg, target, box.id)) return tl("Loop");
}
return null;
}
/** 선 규칙 바꾸기 · 빼기(null) — 조건 알약이 없거나 돌고 돌면 막고 까닭 */
export function setRule(
lg: CfLogic,
id: string,
rule: CfRule | null,
): { lg: CfLogic; why: string | null } {
const l = lg.links.find((x) => x.id === id);
if (!l) return { lg, why: tl("NoPill") };
const why = rule ? ruleWhy(lg, l.to.box, rule) : null;
if (why) return { lg, why };
const links = lg.links.map((x) => {
if (x.id !== id) return x;
const { rule: _old, ...rest } = x;
return rule ? { ...rest, rule: clone(rule) } : rest;
});
return { lg: { ...lg, links }, why: null };
}
/** 같은 이름이면 「이름 (2)」 「이름 (3)」 */
@@ -403,23 +471,116 @@ export function unlink(lg: CfLogic, id: string): CfLogic {
return updateBox(next, box.id, { func: { ...box.func, inputs } });
}
/* ── 흐름(넓힘 · 준비 중) ───────────────────────────────────────────────── */
/* ── 흐름 따라가기 ──────────────────────────────────────────────────────── */
const notReady = (): never => {
throw new Error("준비 중");
};
/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터) */
export function traceBack(_lg: CfLogic, _port: CfPort): CfStep[] {
return notReady();
/** 그 알약 값이 온 길 — 출처 사슬(가까운 것부터 · 한 번씩) */
export function traceBack(lg: CfLogic, port: CfPort): CfStep[] {
const steps: CfStep[] = [];
const seen = new Set<string>();
const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => {
const box = boxOf(lg, p.box);
const key = `${p.box}|${p.name}|${dir}`;
if (!box || seen.has(key)) return;
seen.add(key);
steps.push({
box: p.box,
port: p.name,
dir,
...(via ? { link: via.id } : {}),
...(via?.rule ? { rule: clone(via.rule) } : {}),
});
if (dir === "in") {
for (const l of lg.links.filter((x) => x.to.box === p.box && x.to.name === p.name)) {
visit(l.from, "out");
for (const c of ruleSources(l.rule)) visit(c, "out");
}
return;
}
// 내보내는 알약 — 함수 · 다른 로직은 받는 알약들에서 옴 · 재료 조건 찾기는 그 알약에서 옴
for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsIn(box) : [])
visit(
{ box: box.id, name: q.name },
"in",
lg.links.find((l) => l.to.box === box.id && l.to.name === q.name),
);
for (const e of box.input?.elements ?? [])
if (e.mode === "rule" && e.rule && elementPort(e) === p.name) visit(e.rule.from, "out");
};
const box = boxOf(lg, port.box);
if (!box) return [];
const dir = portDir(box, port.name);
visit(
port,
dir,
dir === "in"
? lg.links.find((l) => l.to.box === port.box && l.to.name === port.name)
: undefined,
);
return steps;
}
/** 그 알약 값이 가는 길 */
export function traceForward(_lg: CfLogic, _port: CfPort): CfStep[] {
return notReady();
/** 그 알약 값이 가는 길 — 선 · 선 규칙의 조건으로 쓰이는 곳 · 재료 조건 찾기 · 함수 · 다른 로직을 지나 출력까지 */
export function traceForward(lg: CfLogic, port: CfPort): CfStep[] {
const steps: CfStep[] = [];
const seen = new Set<string>();
const visit = (p: CfPort, dir: "in" | "out", via?: CfLink): void => {
const box = boxOf(lg, p.box);
const key = `${p.box}|${p.name}|${dir}`;
if (!box || seen.has(key)) return;
seen.add(key);
steps.push({
box: p.box,
port: p.name,
dir,
...(via ? { link: via.id } : {}),
...(via?.rule ? { rule: clone(via.rule) } : {}),
});
if (dir === "in") {
// 받는 알약 → 그 상자의 내보내는 알약
for (const q of box.kind === "function" || box.input?.mode === "logic" ? portsOut(box) : [])
visit({ box: box.id, name: q.name }, "out");
return;
}
for (const l of lg.links.filter((x) => x.from.box === p.box && x.from.name === p.name))
visit(l.to, "in", l);
// 이 알약을 조건으로 쓰는 선 · 재료 조건 찾기
for (const l of lg.links.filter((x) =>
ruleSources(x.rule).some((c) => c.box === p.box && c.name === p.name),
))
visit(l.to, "in", l);
for (const b of lg.boxes)
for (const e of b.input?.elements ?? [])
if (e.mode === "rule" && e.rule?.from.box === p.box && e.rule.from.name === p.name)
visit({ box: b.id, name: elementPort(e) }, "out");
};
const box = boxOf(lg, port.box);
if (!box) return [];
visit(port, portDir(box, port.name));
return steps;
}
/** 설계 때 정할 것 — 변수 · 재료 자리(선택) · 다른 로직의 받는 알약(선이 없을 때) */
export function designInputs(_lg: CfLogic): CfDesign[] {
return notReady();
/* ── 설계 때 정할 것 ─────────────────────────────────────────────────────── */
/** 설계 때 정할 것 — 변수(입력 · 함수 상자) · 재료 선택 자리 · 다른 로직의 받는 알약(선이 없을 때) */
export function designInputs(lg: CfLogic): CfDesign[] {
const out: CfDesign[] = [];
for (const b of lg.boxes) {
const vars = b.input?.variables ?? b.func?.variables ?? [];
for (const v of vars)
out.push({ box: b.id, port: v.name, kind: "변수", unit: v.unit || "-", variable: clone(v) });
for (const e of b.input?.mode === "group" ? b.input.elements : [])
if (e.mode === "choice")
out.push({
box: b.id,
port: elementPort(e),
kind: "재료 자리",
unit: e.unit || "-",
element: clone(e),
});
if (b.input?.mode === "logic")
for (const d of b.input.logic?.designs ?? [])
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === d.name))
out.push({ box: b.id, port: d.name, kind: "다른 로직", unit: d.unit || "-" });
}
return out;
}
@@ -0,0 +1,139 @@
/* =============================================================================
* M02_DF_PillPanel.ts
* 상세 내용 판 — 알약: 알약을 고르면 이름 · 값 · 단위 · 온 길(출처 사슬) · 가는 곳(이어진 선 · 받는 상자) · 끊김 까닭
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 8 · 9 · M02_DF_Model 의 CfStep 그대로 · 읽기만(고치기는 선 · 상자 판에서)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfValues } from "./M02_DF_Eval";
import type { CfLogic, CfStep } from "./M02_DF_Model";
import {
boxOf,
breakLines,
evalSafe,
isOut,
labelOf,
portText,
showValue,
trace,
unitOf,
valueOf,
} from "./M02_DF_Detail";
import "./M02_DF_LinkPanel.css";
const TEXT = {
Name: ["이름", "Name"],
Value: ["값", "Value"],
Unit: ["단위", "Unit"],
Kind_out: ["내보내는 알약", "Sending pill"],
Kind_in: ["받는 알약", "Receiving pill"],
From: ["온 길", "Where it came from"],
To: ["가는 곳", "Where it goes"],
Start: ["여기서 시작 — 더 거슬러 갈 곳이 없음", "It starts here — nothing before it"],
End: ["여기가 끝 — 더 가는 곳이 없음", "It ends here — nothing after it"],
Breaks: ["끊긴 곳", "Breaks"],
NoBreak: ["끊긴 곳 없음", "No breaks"],
Open: ["이 상자 보기", "Open this box"],
Line: ["선", "Line"],
} as const;
const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement =>
el("div", {
className: "m02-dflink-row",
children: [el("span", { className: "m02-dflink-label", text: label }), ...value],
});
function steps(
lg: CfLogic,
list: CfStep[],
empty: string,
onBox?: (id: string) => void,
): HTMLElement {
if (!list.length) return el("p", { className: "m02-dflink-note", text: empty });
return el("ol", {
className: "m02-dfpill-list",
children: list.map((s) => {
const parts: (HTMLElement | string)[] = [
s.dir === "out" ? "◀" : "▶",
onBox
? createButton({
label: portText(lg, { box: s.box, name: s.port }),
variant: "ghost",
size: "sm",
onClick: () => onBox(s.box),
})
: portText(lg, { box: s.box, name: s.port }),
];
if (s.rule)
parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(lg, s.rule) }));
return el("li", { className: "m02-dfpill-step", children: parts });
}),
});
}
/** 알약 판 그리기 — 알약을 고르면 부름 · on.pick 이 있으면 줄의 상자 이름을 눌러 그 상자로 감 */
export function renderPillPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
port: string,
on: { pick?(boxId: string): void } = {},
opts: { values?: CfValues } = {},
): void {
host.classList.add("m02-dfpill");
const box = boxOf(lg, boxId);
if (!box) return host.replaceChildren();
const ev = evalSafe(lg, opts.values);
const out = isOut(box, port);
// 받는 알약의 값 = 그 자리로 들어오는 선의 보낸 알약 값(규칙 지나기 전)
const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port);
const value = out
? valueOf(ev, { box: boxId, name: port })
: incoming
? valueOf(ev, incoming.from)
: undefined;
const unit = out
? unitOf(lg, { box: boxId, name: port })
: incoming
? unitOf(lg, incoming.from)
: box.unit;
const back = trace(lg, { box: boxId, name: port }, "in");
const forward = trace(lg, { box: boxId, name: port }, "out");
const breaks = breakLines(lg, boxId, port);
host.replaceChildren(
el("div", {
className: "m02-dflink-head",
children: [
el("strong", { className: "m02-dfpill-title", text: `${box.name} › ${port}` }),
el("span", { className: "m02-dflink-note", text: tp(out ? "Kind_out" : "Kind_in") }),
],
}),
line(tp("Name"), port),
line(tp("Value"), el("span", { className: "m02-dflink-value", text: showValue(value) })),
line(tp("Unit"), unit || "-"),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("From") }),
steps(lg, back, tp("Start"), on.pick),
],
}),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("To") }),
steps(lg, forward, tp("End"), on.pick),
],
}),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("Breaks") }),
...(breaks.length
? breaks.map((b) => el("p", { className: "m02-dflink-warn", text: `⚠ ${b}` }))
: [el("p", { className: "m02-dflink-note", text: tp("NoBreak") })]),
],
}),
);
}
@@ -0,0 +1,78 @@
/* =============================================================================
* M02_DF_RoundRow.ts
* 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추)
* 모양은 M02_DF_Model 의 CfRound · 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfRound } from "./M02_DF_Model";
const TEXT = {
Label: ["끝수", "Rounding"],
None: ["안 씀", "none"],
Places: ["소수 {n}자리", "{n} decimals"],
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
Apply: ["적용", "Apply"],
PlacesAria: ["소수자리", "Decimal places"],
ModeAria: ["방법", "Method"],
} as const;
const METHODS: CfRound["method"][] = ["반올림", "올림", "버림"];
const tr = (key: keyof typeof TEXT, n?: number): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
export interface RoundRowOptions {
/** 지금 끝수 — 없으면 undefined */
round: CfRound | undefined;
/** 단위 표준이 제안한 소수자리 */
suggest: number | null;
readOnly?: boolean;
/** null = 끝수 벗김 */
onApply(round: CfRound | null): void;
}
export function createRoundRow(opts: RoundRowOptions): HTMLElement {
const now = opts.round ?? null;
const places = el("select", {
className: "ui-select ui-select--sm",
attrs: { "aria-label": tr("PlacesAria") },
});
places.append(el("option", { attrs: { value: "" }, text: tr("None") }));
for (let n = 0; n <= 6; n++) {
places.append(el("option", { attrs: { value: String(n) }, text: tr("Places", n) }));
}
places.value = now ? String(now.places) : "";
const method = el("select", {
className: "ui-select ui-select--sm",
attrs: { "aria-label": tr("ModeAria") },
});
for (const m of METHODS) method.append(el("option", { attrs: { value: m }, text: m }));
method.value = now?.method ?? "반올림";
if (opts.readOnly) places.disabled = method.disabled = true;
const emit = (): void =>
opts.onApply(
places.value === ""
? null
: { places: Number(places.value), method: method.value as CfRound["method"] },
);
places.addEventListener("change", emit);
method.addEventListener("change", () => places.value !== "" && emit());
const row = el("div", {
className: "m02-dfpanel-row",
children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, method],
});
if (opts.suggest !== null && !now && !opts.readOnly) {
row.append(
el("span", { className: "m02-dfpanel-note", text: tr("Suggest", opts.suggest) }),
createButton({
label: tr("Apply"),
variant: "ghost",
size: "sm",
onClick: () => opts.onApply({ places: opts.suggest!, method: "반올림" }),
}),
);
}
return row;
}
+188 -11
View File
@@ -1,33 +1,210 @@
/* =============================================================================
* M02_DF_Rule.ts
* 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음
*
* 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식
* lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림) → 맞은 줄 수 · INDEX/MATCH
* gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN
* ========================================================================== */
import type { CfRound, CfRule } from "./M02_DF_Model";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName } from "../../A00_Common/spreadsheet/spreadsheet_address";
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
import type { CalcValue, Cell } from "../../A00_Common/spreadsheet/spreadsheet_types";
import type { CfGateOp, CfRound, CfRule } from "./M02_DF_Model";
type Scalar = number | string | boolean | null;
/** 규칙을 지난 값 — 통과 안 됨 · 표에 없는 조건 · 둘 이상 맞음은 value null + why */
export interface CfRuleResult {
value: number | string | boolean | null;
value: Scalar;
why: "통과 안 됨" | "표에 없는 조건" | "표에 여럿 맞음" | "오류" | null;
/** 받는 칸에 들어갈 엑셀 수식(보이기 · 시험) */
formula: string;
}
const TEXT = {
Lookup: ["표에서 찾기", "Look up in table"],
Gate: ["일 때만", "only when"],
Places: ["소수 {n}자리", "{n} decimals"],
Round: ["반올림", "round"],
Up: ["올림", "round up"],
Down: ["버림", "round down"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 엑셀 비교 기호 */
const OP: Record<CfGateOp, string> = {
"=": "=",
"≠": "<>",
"≥": ">=",
"≤": "<=",
">": ">",
"<": "<",
};
const ROUND_FN: Record<CfRound["method"], string> = {
반올림: "ROUND",
올림: "ROUNDUP",
버림: "ROUNDDOWN",
};
/** 선 위에 늘 보일 규칙 글 */
export function ruleLabel(_rule: CfRule): string {
throw new Error("준비 중");
export function ruleLabel(rule: CfRule): string {
switch (rule.kind) {
case "lookup": {
const conds = rule.conds.map((c) => `${c.col} = ${c.from.name}`).join(" · ");
return `${tl("Lookup")}${conds ? ` (${conds})` : ""} → ${rule.col}`;
}
case "gate":
return `${rule.from.name} ${rule.op} ${rule.value} ${tl("Gate")}`;
case "scale":
return rule.op === "+%" || rule.op === "-%"
? `${rule.op[0]}${rule.value}%`
: `${rule.op} ${rule.value}`;
case "round":
return `${tl("Places").replace("{n}", String(rule.places))} ${roundWord(rule.method)}`;
}
}
/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 값(lookup · gate) */
const roundWord = (m: CfRound["method"]): string =>
m === "반올림" ? tl("Round") : m === "올림" ? tl("Up") : tl("Down");
/* ── 엔진 한 번 ─────────────────────────────────────────────────────────── */
const fromCalc = (v: CalcValue | undefined): Scalar => {
if (!v) return null;
if ("수" in v) return Number(v.수);
if ("글" in v) return v.글;
if ("참" in v) return v.참;
return v.오류;
};
const cellOf = (v: unknown): Cell | null =>
v === null || v === undefined || v === ""
? null
: typeof v === "number" || typeof v === "string" || typeof v === "boolean"
? { 값: v }
: { 값: String(v) };
/** 칸들을 시트 하나로 셈 → 읽을 칸 값 */
function run(칸: Record<string, Cell>, read: string[]): Scalar[] {
const id = "rule";
const values = recalcWorkbook({
종류: "통합문서",
판: 1,
열: "DF",
서식: [{}],
시트: [{ id, 이름: "규칙", 칸 }],
});
return read.map((a) =>
칸[a]?.식 !== undefined ? fromCalc(values[id]?.[a]) : (칸[a]?.값 ?? null),
);
}
const isError = (v: Scalar): boolean => typeof v === "string" && /^#[A-Z/0!?]+$/.test(v);
const lit = (v: number | string): string =>
typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`;
/* ── 규칙 ───────────────────────────────────────────────────────────────── */
/** 규칙 적용 — value = 보낸 알약 값(표면 머리 줄 포함 칸 덩어리) · conds = 조건 알약 이름 → 값(lookup · gate) */
export function applyRule(
_rule: CfRule,
_value: unknown,
_conds: Record<string, unknown> = {},
rule: CfRule,
value: unknown,
conds: Record<string, unknown> = {},
): CfRuleResult {
throw new Error("준비 중");
if (rule.kind === "lookup") return lookup(rule, value, conds);
const 칸: Record<string, Cell> = {};
const a1 = cellOf(Array.isArray(value) ? null : value);
if (a1) 칸.A1 = a1;
let formula: string;
if (rule.kind === "gate") {
const c1 = cellOf(conds[rule.from.name]);
if (c1) 칸.C1 = c1;
formula = `IF(C1${OP[rule.op]}${lit(rule.value)},A1,NA())`;
} else if (rule.kind === "scale") {
const v = rule.value;
formula =
rule.op === "×"
? `A1*${v}`
: rule.op === "÷"
? `A1/${v}`
: rule.op === "+%"
? `A1*(1+${v}/100)`
: `A1*(1-${v}/100)`;
} else formula = `${ROUND_FN[rule.method]}(A1,${rule.places})`;
칸.B1 = { 식: formula };
const [got] = run(칸, ["B1"]);
if (rule.kind === "gate" && got === "#N/A") return { value: null, why: "통과 안 됨", formula };
if (isError(got)) return { value: null, why: "오류", formula };
return { value: got, why: null, formula };
}
/** 표에서 찾기 — 줄마다 맞음 칸 · 맞은 줄 수 · INDEX/MATCH(범위 칸 「이름·아래」 「이름·위」 · 초과 · 이하) */
function lookup(
rule: Extract<CfRule, { kind: "lookup" }>,
value: unknown,
conds: Record<string, unknown>,
): CfRuleResult {
const block = Array.isArray(value) ? (value as unknown[][]) : [];
const head = (block[0] ?? []).map((h) => String(h ?? ""));
const rows = block.length - 1;
const W = head.length;
const 칸: Record<string, Cell> = {};
block.forEach((line, r) =>
line.forEach((x, c) => {
const cell = cellOf(x);
if (cell) 칸[`${colName(c)}${r + 1}`] = cell;
}),
);
const at = (name: string): string | null => {
const i = head.indexOf(name);
return i < 0 ? null : colName(i);
};
const out = at(rule.col);
// 조건 값 = 1행 · 블록 오른쪽 한 칸 띄고
const tests: ((r: number) => string)[] = [];
let bad = !out || rows < 1;
rule.conds.forEach((c, i) => {
const ref = `$${colName(W + 1 + i)}$1`;
const cv = cellOf(conds[c.from.name]);
if (cv) 칸[`${colName(W + 1 + i)}1`] = cv;
const same = at(c.col);
const low = at(`${c.col}·아래`);
const high = at(`${c.col}·위`);
if (same) tests.push((r) => `${same}${r}=${ref}`);
else if (low && high)
tests.push(
(r) =>
`AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r}))`,
);
else bad = true;
});
const hc = colName(W + 1 + rule.conds.length);
const last = rows + 1;
for (let r = 2; r <= last; r++)
칸[`${hc}${r}`] = {
식: tests.length ? `IF(AND(${tests.map((t) => t(r)).join(",")}),1,0)` : "1",
};
const range = `${hc}2:${hc}${Math.max(2, last)}`;
const count = `${colName(W + 2 + rule.conds.length)}1`;
const formula = out ? `INDEX(${out}2:${out}${Math.max(2, last)},MATCH(1,${range},0))` : "NA()";
칸[count] = { 식: `SUM(${range})` };
칸.ZZ1 = { 식: formula };
if (bad) return { value: null, why: "오류", formula };
const [n, got] = run(칸, [count, "ZZ1"]);
if (n === 0) return { value: null, why: "표에 없는 조건", formula };
if (typeof n === "number" && n > 1) return { value: null, why: "표에 여럿 맞음", formula };
if (isError(got)) return { value: null, why: "오류", formula };
return { value: got, why: null, formula };
}
/** 끝수 적용 — 엔진 ROUND · ROUNDUP · ROUNDDOWN */
export function applyRound(_round: CfRound, _value: number): number {
throw new Error("준비 중");
export function applyRound(round: CfRound, value: number): number {
const [got] = run(
{ A1: { 값: value }, B1: { 식: `${ROUND_FN[round.method]}(A1,${round.places})` } },
["B1"],
);
return typeof got === "number" ? got : value;
}
@@ -0,0 +1,56 @@
.m02-df-unitpick {
position: relative;
display: inline-block;
}
.m02-df-unitpick__panel {
position: absolute;
top: 100%;
left: 0;
z-index: 50;
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 14em;
max-height: 20em;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
box-shadow: var(--shadow-lg);
}
.m02-df-unitpick__list {
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
}
.m02-df-unitpick__item {
display: block;
width: 100%;
padding: var(--spacing-4) var(--spacing-8);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.m02-df-unitpick__item.is-on,
.m02-df-unitpick__item:hover {
background: var(--color-surface-raised);
}
.m02-df-unitpick__item.is-now {
font-weight: var(--font-weight-bold);
}
.m02-df-unitpick__empty {
padding: var(--spacing-8);
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
@@ -0,0 +1,181 @@
/* =============================================================================
* M02_DF_UnitPick.ts
* 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음) · 「-」 = 없음
* 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원
* 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 처음 값 `_standard/단위표준.json`) — 다른 판도 이것을 씀
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import "./M02_DF_UnitPick.css";
const TEXT = {
None: ["없음", "none"],
Filter: ["단위를 쳐서 찾음", "Type to filter units"],
Empty: ["맞는 단위가 없음 — 목록에서만 고름", "No match — pick from the list only"],
Pick: ["단위 고르기", "Pick a unit"],
} as const;
const tu = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/** 「없음」 을 뜻하는 단위 글 */
export const NO_UNIT = "-";
/** 표준 표에서 읽은 것 — 단위 목록 · 단위마다 소수자리 제안(단위수량 쪽에서 가장 많이 쓴 자리) */
export interface UnitStandard {
units: string[];
places: Record<string, number>;
}
type UnitSource = () => Promise<UnitStandard>;
interface StandardRow {
"규격 단위"?: string | null;
"단위수량 단위"?: string | null;
"단위수량 소수자리"?: number | null;
}
/** 처음 값 — 번들에 든 표준 표(단위표준.json) */
const bundled: UnitSource = async () => {
const table = (await import("./_standard/단위표준.json")).default as { 줄: StandardRow[] };
const units = table.줄
.flatMap((r) => [r["규격 단위"], r["단위수량 단위"]])
.filter((u): u is string => !!u);
const count: Record<string, Record<number, number>> = {};
for (const r of table.줄) {
const u = r["단위수량 단위"];
const p = r["단위수량 소수자리"];
if (!u || u === "없음" || typeof p !== "number") continue;
count[u] = { ...count[u], [p]: (count[u]?.[p] ?? 0) + 1 };
}
const places = Object.fromEntries(
Object.entries(count).map(([u, c]) => [
u,
Number(Object.entries(c).sort((a, b) => b[1] - a[1])[0][0]),
]),
);
return { units, places };
};
let source: UnitSource = bundled;
let cache: Promise<UnitStandard> | null = null;
/** 표준 표 읽는 길 바꾸기(서버에서 고친 표를 읽을 때) — 목록을 다시 받음 */
export function setUnitSource(next: UnitSource): void {
source = next;
cache = null;
}
/** 표준 단위 + 원 — 같은 글은 한 번 · 「없음」 은 뺌 */
export function loadStandard(): Promise<UnitStandard> {
cache ??= source().then((std) => {
const set = new Set([...std.units, "원"]);
set.delete("없음");
set.delete(NO_UNIT);
return { units: [...set], places: std.places };
});
return cache;
}
/** 단위를 고르면 표준 표에서 소수자리 제안 — 없으면 null */
export async function suggestPlaces(unit: string): Promise<number | null> {
return (await loadStandard()).places[unit] ?? null;
}
export interface UnitPickOptions {
/** 지금 단위 — 「-」 = 없음 */
value: string;
onPick(unit: string): void;
/** 더 보일 단위(마스터 결과단위 등) */
extra?: string[];
readOnly?: boolean;
ariaLabel?: string;
}
/** 단위 고르기 — 단추를 누르면 걸러지는 목록 · 목록에서 고르기만 됨 */
export function createUnitPick(opts: UnitPickOptions): HTMLElement {
const label = (u: string): string => (u === NO_UNIT ? `${NO_UNIT} (${tu("None")})` : u);
const root = el("span", { className: "m02-df-unitpick" });
const open = createButton({
label: opts.value || NO_UNIT,
variant: "ghost",
size: "sm",
disabled: opts.readOnly,
onClick: () => toggle(),
});
open.setAttribute("aria-label", `${opts.ariaLabel ?? tu("Pick")}: ${opts.value || NO_UNIT}`);
open.setAttribute("aria-haspopup", "listbox");
root.append(open);
if (opts.readOnly) return root;
const input = el("input", {
className: "ui-input ui-input--sm m02-df-unitpick__filter",
attrs: { type: "text", placeholder: tu("Filter"), "aria-label": tu("Filter") },
});
const list = el("ul", { className: "m02-df-unitpick__list", attrs: { role: "listbox" } });
const panel = el("div", { className: "m02-df-unitpick__panel", children: [input, list] });
panel.hidden = true;
root.append(panel);
let all: string[] = [];
let shown: string[] = [];
let at = 0;
const pick = (u: string): void => {
close();
if (u !== (opts.value || NO_UNIT)) opts.onPick(u);
};
const draw = (): void => {
const q = input.value.trim().toLowerCase();
shown = all.filter((u) => u === NO_UNIT || !q || u.toLowerCase().includes(q));
if (q && !NO_UNIT.includes(q)) shown = shown.filter((u) => u !== NO_UNIT);
at = Math.min(at, Math.max(0, shown.length - 1));
list.replaceChildren(
...(shown.length
? shown.map((u, i) => {
const b = el("button", {
className: `m02-df-unitpick__item${i === at ? " is-on" : ""}${u === (opts.value || NO_UNIT) ? " is-now" : ""}`,
text: label(u),
attrs: { type: "button", role: "option" },
});
b.addEventListener("click", () => pick(u));
return el("li", { children: [b] });
})
: [el("li", { className: "m02-df-unitpick__empty", text: tu("Empty") })]),
);
};
const close = (): void => {
panel.hidden = true;
document.removeEventListener("pointerdown", outside, true);
};
const outside = (e: Event): void => {
if (!root.contains(e.target as Node)) close();
};
function toggle(): void {
if (!panel.hidden) return close();
panel.hidden = false;
document.addEventListener("pointerdown", outside, true);
input.value = "";
void loadStandard().then(({ units }) => {
const mine = [...(opts.extra ?? []), ...units];
all = [NO_UNIT, ...new Set(mine.filter((u) => u && u !== NO_UNIT))];
at = 0;
draw();
input.focus();
});
}
input.addEventListener("input", () => ((at = 0), draw()));
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") return (e.stopPropagation(), close(), open.focus());
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
at = Math.max(0, Math.min(shown.length - 1, at + (e.key === "ArrowDown" ? 1 : -1)));
return draw();
}
if (e.key === "Enter" && shown[at] !== undefined) {
e.preventDefault();
pick(shown[at]);
}
});
return root;
}
+246
View File
@@ -346,3 +346,249 @@ def test_함수_출력_기본_이름은_겹치지_않음_받는_알약은_번호
["결과 4", "결과 4"],
]
assert ports == ["결과 1", "결과 2", "결과 1 (2)"]
# ── 흐름도 넓힘(35-9) — 견본 셋 ─────────────────────────────────────────────
RULE = r"""
const R = loadTs(path.join(ROOT, "M02_MasterTemplete", "DataFlow", "M02_DF_Rule.ts"));
const rjoin = (lg, a, ap, b, bp, rule) => {
const r = M.link(lg, { box: a.id, name: ap }, { box: b.id, name: bp }, rule);
if (r.why) throw new Error(r.why);
return r.lg;
};
const vars = (lg, box, list) => M.updateBox(lg, box.id, { input: { mode: "variable", elements: [], variables: list } });
const num = (name, example, unit = "-") => ({ name, type: "number", unit, example });
"""
# 견본 가 — 재료 조건 찾기: 관경(변수) → 흄관(규격에 관경 포함) 단가 · 수량 × 단가 → 재료비 · 거푸집 = 선택 자리
PIPE = (
RULE
+ r"""
let lg = M.newLogic("흄관 조건 찾기");
let r = add(lg, "input"); lg = r.lg; const dia = r.box;
lg = vars(lg, dia, [num("관경", 300, "㎜"), num("길이", 12, "m")]);
r = add(lg, "input"); lg = r.lg; const mat = r.box;
lg = M.updateBox(lg, mat.id, { input: { mode: "group", group: "재료", variables: [], elements: [
{ ref: "", name: "흄관", spec: "", unit: "m", mode: "rule", slot: "흄관 단가", scope: { 구분: "관·수로", 상세구분: "흄관" },
rule: { field: "규격", op: "포함", from: { box: dia.id, name: "관경" } } },
{ ref: "", name: "거푸집", spec: "", unit: "㎡", mode: "choice", slot: "거푸집 자리", default: "MT000123" } ] } });
r = add(lg, "function"); lg = r.lg; const f = r.box;
lg = func(lg, f, { B9: { 식: "B1*B2" } }, [{ port: "길이", cell: "B1" }, { port: "흄관 단가", cell: "B2" }], { cell: "B9", name: "재료값" });
r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "재료비", unit: "원" } });
lg = join(lg, dia, "길이", f, "길이");
lg = join(lg, mat, "흄관 단가", f, "흄관 단가");
lg = join(lg, f, "재료값", o, "값");
"""
)
def test_가_재료_조건_찾기_흐름() -> None:
got = _run(
PIPE + "const ok = E.evalLogic(lg, { [mat.id]: { '흄관 단가': 52000 } });\n"
"const none = E.breaks(lg, {});\n"
"out([M.portsOut(lg.boxes[1]).map(p => p.name), ok.cost.재료비, ok.arrived[f.id], none,"
" M.traceBack(lg, { box: o.id, name: '값' }), M.designInputs(lg).map(d => [d.kind, d.port]),"
" M.reaches(lg, dia.id, f.id)]);"
)
pills, cost, arrived, none, back, designs, dep = got
assert pills == ["흄관 단가", "거푸집 자리"] # 재료 자리 = 알약 이름
assert cost == 12 * 52000 and arrived == {"길이": 12, "흄관 단가": 52000}
assert [(b["kind"], b["port"]) for b in none] == [("맞는 재료 없음", "흄관 단가")]
assert none[0]["text"] == "「입력 2」 의 「흄관 단가」 — 조건에 맞는 재료가 없음"
# 출력 값 ← 함수 재료값 ← 길이 · 흄관 단가 ← (조건) 관경
assert [(s["box"], s["port"], s["dir"]) for s in back] == [
("b4", "값", "in"),
("b3", "재료값", "out"),
("b3", "길이", "in"),
("b1", "길이", "out"),
("b3", "흄관 단가", "in"),
("b2", "흄관 단가", "out"),
("b1", "관경", "out"),
]
assert designs == [["변수", "관경"], ["변수", "길이"], ["재료 자리", "거푸집 자리"]]
assert dep is True # 관경 → (재료 조건) → 함수
# 견본 나 — 조건 통과로 둘로 가르기(지반 토사 · 암) + 표에서 찾기 + 바꾸기 · 끝수
SPLIT = (
RULE
+ r"""
let lg = M.newLogic("지반 가르기");
let r = add(lg, "input"); lg = r.lg; const v = r.box;
lg = vars(lg, v, [{ name: "지반", type: "choice", unit: "-", choices: ["토사", "암"], example: "토사" },
num("높이", 4.5, "m"), num("품", 0.5, "인")]);
r = add(lg, "input"); lg = r.lg; const t = r.box;
lg = M.updateBox(lg, t.id, { input: { mode: "group", group: "소요량", elements: [], variables: [], table: { key: "QF000422" } } });
const fn = (out) => { r = add(lg, "function"); lg = r.lg; const b = r.box;
lg = func(lg, b, { B9: { 식: "B1*100000" } }, [{ port: "가", cell: "B1" }], { cell: "B9", name: out }); return b; };
const soil = fn("토사 노임"), rock = fn("암 노임");
r = add(lg, "function"); lg = r.lg; const rate = r.box;
lg = func(lg, rate, { B9: { 식: "B1" } }, [{ port: "증가율", cell: "B1" }], { cell: "B9", name: "증가율 값" });
r = add(lg, "output"); lg = r.lg; const o1 = r.box;
r = add(lg, "output"); lg = r.lg; const o2 = r.box;
r = add(lg, "output"); lg = r.lg; const o3 = r.box; lg = M.updateBox(lg, o3.id, { output: { cost: "값", unit: "%" } });
const gate = (value, op = "=") => ({ kind: "gate", from: { box: v.id, name: "지반" }, op, value });
lg = rjoin(lg, v, "품", soil, "가", gate("토사"));
lg = rjoin(lg, v, "품", rock, "가", gate("암"));
lg = rjoin(lg, t, "표", rate, "증가율", { kind: "lookup", conds: [{ col: "높이", from: { box: v.id, name: "높이" } }], col: "증가율" });
lg = rjoin(lg, soil, "토사 노임", o1, "값", { kind: "scale", op: "+%", value: 10 });
lg = rjoin(lg, rock, "암 노임", o2, "값", { kind: "round", places: -3, method: "버림" });
lg = join(lg, rate, "증가율 값", o3, "값");
const TABLE = [["높이·아래", "높이·위", "증가율"], [3, 4, 30], [4, 5.5, 40], [5.5, 7.5, 60]];
const V = (soilKind, h = 4.5) => ({ [v.id]: { 지반: soilKind, 높이: h }, [t.id]: { 표: TABLE } });
"""
)
def test_나_조건_통과로_둘로_가르기_표에서_찾기() -> None:
got = _run(
SPLIT + "const a = E.evalLogic(lg, V('토사'));\n"
"const b = E.evalLogic(lg, V('암', 3.5));\n"
"out([a.outputs, a.breaks.map(x => [x.kind, x.link]), b.outputs, b.breaks.map(x => x.kind),"
" E.breaks(lg, V('토사', 9)).map(x => [x.kind, x.text])]);"
)
a_out, a_breaks, b_out, b_breaks, far = got
# 토사: 0.5 × 100000 × 1.1 · 암 줄은 통과 안 됨 · 높이 4.5 → 증가율 40
assert a_out == {"b6": 55000, "b7": None, "b8": 40}
assert a_breaks == [["통과 안 됨", "l2"]]
assert b_out == {
"b6": None,
"b7": 50000,
"b8": 30,
} # 암 50,000 → 천 원 버림 그대로 · 높이 3.5 → 30
assert b_breaks == ["통과 안 됨"]
assert far == [
["통과 안 됨", "「입력」 → 「함수 2」 선이 조건(지반 = 암 일 때만)을 통과하지 않음"],
["표에 없는 조건", "「입력 2」 → 「함수 3」 선 — 표에 맞는 줄이 없음"],
]
def test_나_조건_선_겹침_안맞음() -> None:
got = _run(
SPLIT + "const l2 = lg.links[1].id;\n"
"const overlap = M.setRule(lg, l2, { kind: 'gate', from: { box: v.id, name: '지반' }, op: '≠', value: '암' }).lg;\n"
"out([E.breaks(overlap, V('토사')).map(x => [x.kind, x.text]), E.breaks(lg, V('자갈')).map(x => x.kind)]);"
)
overlap, none = got
assert overlap == [["조건 겹침", "「입력」 의 「지반」 에서 나간 조건 선이 둘 이상 통과함"]]
assert none == ["통과 안 됨", "통과 안 됨", "조건 안 맞음"]
def test_나_규칙_글과_엑셀_수식() -> None:
got = _run(
SPLIT + "out([lg.links.map(l => l.rule ? R.ruleLabel(l.rule) : null),"
" lg.links.map(l => l.rule ? R.applyRule(l.rule, 1000, { 지반: '토사', 높이: 4.5 }).formula : null).slice(0, 2),"
" R.applyRule({ kind: 'scale', op: '+%', value: 10 }, 200).formula, R.applyRule({ kind: 'scale', op: '÷', value: 4 }, 10).value,"
" R.applyRule({ kind: 'round', places: 1, method: '올림' }, 1.21).value, R.applyRound({ places: 0, method: '버림' }, 9.9)]);"
)
labels, gates, scale_f, div, up, down = got
assert labels == [
"지반 = 토사 일 때만",
"지반 = 암 일 때만",
"표에서 찾기 (높이 = 높이) → 증가율",
"+10%",
"소수 -3자리 버림",
None,
]
assert gates == ['IF(C1="토사",A1,NA())', 'IF(C1="암",A1,NA())']
assert scale_f == "A1*(1+10/100)" and div == 2.5 and up == 1.3 and down == 9
def test_나_흐름_따라가기_앞으로() -> None:
got = _run(
SPLIT
+ "out(M.traceForward(lg, { box: v.id, name: '지반' }).map(s => [s.box, s.port, s.dir, s.rule ? s.rule.kind : null]));"
)
assert got == [
["b1", "지반", "out", None],
["b3", "가", "in", "gate"],
["b3", "토사 노임", "out", None],
["b6", "값", "in", "scale"],
["b4", "가", "in", "gate"],
["b4", "암 노임", "out", None],
["b7", "값", "in", "round"],
]
def test_규칙_조건_알약_없으면_막음_순환() -> None:
got = _run(
SPLIT
+ "const bad = M.link(lg, { box: v.id, name: '품' }, { box: rate.id, name: '' }, { kind: 'gate', from: { box: v.id, name: '없음' }, op: '=', value: 1 }).why;\n"
"const loop = M.setRule(lg, lg.links[0].id, { kind: 'gate', from: { box: soil.id, name: '토사 노임' }, op: '>', value: 0 }).why;\n"
"const off = M.setRule(lg, lg.links[3].id, null).lg.links[3];\n"
"out([bad, loop, off]);"
)
bad, loop, off = got
assert bad == "규칙의 조건 알약이 없음"
assert loop == "이으면 돌고 도는 선이 됨"
assert "rule" not in off
# 견본 다 — 다른 로직 잇기: 안쪽 로직(면적 × 계수 = 작업량 값) · 바깥 로직이 길이를 면적으로 넘기고 작업량을 받아 경비
LOGIC = (
RULE
+ r"""
let inner = M.newLogic("작업량");
let r = add(inner, "input"); inner = r.lg; const ia = r.box;
inner = vars(inner, ia, [num("면적", 1, "㎡"), num("계수", 3)]);
r = add(inner, "function"); inner = r.lg; const ifn = r.box;
inner = func(inner, ifn, { B9: { 식: "B1*B2" } }, [{ port: "면적", cell: "B1" }, { port: "계수", cell: "B2" }], { cell: "B9", name: "작업량 값" });
r = add(inner, "output"); inner = r.lg; const io = r.box;
inner = M.updateBox(inner, io.id, { name: "작업량", output: { cost: "값", unit: "㎥/시간" } });
inner = join(inner, ia, "면적", ifn, "면적");
inner = join(inner, ia, "계수", ifn, "계수");
inner = join(inner, ifn, "작업량 값", inner.boxes.find(b => b.id === io.id), "값");
let lg = M.newLogic("바깥");
r = add(lg, "input"); lg = r.lg; const len = r.box; lg = vars(lg, len, [num("길이", 7, "m")]);
r = add(lg, "input"); lg = r.lg; const other = r.box;
lg = M.updateBox(lg, other.id, { input: { mode: "logic", elements: [], variables: [],
logic: { key: "DF-0001", name: "작업량", outputs: [{ name: "작업량", unit: "㎥/시간" }],
designs: M.designInputs(inner).map(d => ({ name: d.port, unit: d.unit })) } } });
r = add(lg, "function"); lg = r.lg; const f = r.box;
lg = func(lg, f, { B9: { 식: "B1*1000" } }, [{ port: "작업량", cell: "B1" }], { cell: "B9", name: "경비 값" });
r = add(lg, "output"); lg = r.lg; const o = r.box; lg = M.updateBox(lg, o.id, { output: { cost: "경비", unit: "원" } });
lg = join(lg, len, "길이", other, "면적");
lg = join(lg, other, "작업량", f, "작업량");
lg = join(lg, f, "경비 값", o, "값");
const LOGICS = { "DF-0001": inner };
"""
)
def test_다_다른_로직_잇기() -> None:
got = _run(
LOGIC + "const a = E.evalLogic(lg, {}, { logics: LOGICS });\n"
"const b = E.evalLogic(lg, { [len.id]: { 길이: 2 }, [other.id]: { 계수: 10 } }, { logics: LOGICS });\n"
"const c = E.evalLogic(lg, { [other.id]: { 작업량: 5 } });\n"
"out([M.portsIn(lg.boxes[1]).map(p => p.name), M.portsOut(lg.boxes[1]).map(p => p.name), a.ports[other.id], a.cost.경비,"
" b.cost.경비, c.cost.경비, M.designInputs(lg).map(d => [d.kind, d.port]),"
" M.link(lg, { box: len.id, name: '길이' }, { box: other.id, name: '없음' }).why,"
" M.traceBack(lg, { box: o.id, name: '값' }).map(s => [s.box, s.port]), E.evalLogic(lg, {}, { logics: {} }).errors]);"
)
ins, outs, ports, a, b, c, designs, no_pill, back, missing = got
assert ins == ["면적", "계수"] and outs == ["작업량"]
assert ports == {"작업량": 21} # 길이 7 → 면적 · 계수 예시값 3
assert (a, b, c) == (21000, 20000, 5000)
assert designs == [["변수", "길이"], ["다른 로직", "계수"]] # 면적은 선이 있어 빠짐
assert no_pill == "알약이 없음"
assert back == [
["b4", "값"],
["b3", "경비 값"],
["b3", "작업량"],
["b2", "작업량"],
["b2", "면적"],
["b1", "길이"],
["b2", "계수"],
]
assert missing == ["「입력 2」 이 부르는 로직을 찾을 수 없음"]
def test_끝수_함수_출력_출력_상자() -> None:
got = _run(
ONE
+ "lg = M.updateBox(lg, f.id, { func: { ...lg.boxes[2].func, output: { ...lg.boxes[2].func.output, round: { places: -3, method: '버림' } } } });\n"
"const a = E.evalLogic(lg, V).cost.노무비;\n"
"lg = M.updateBox(lg, o.id, { output: { cost: '노무비', unit: '원', round: { places: -4, method: '올림' } } });\n"
"out([a, E.evalLogic(lg, V).cost.노무비]);"
)
assert got == [537000, 540000]