Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_4
This commit is contained in:
@@ -1,14 +1,22 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Detail.ts
|
||||
* 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 겹침/안 맞음 · 흐름 줄 · 끊김 까닭 · DOM 없음
|
||||
* 모델의 traceBack · traceForward · breaks · applyRule · ruleLabel 이 아직 준비 중이면 같은 뜻의 작은 셈으로 메움
|
||||
* 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 끊김 글 · 흐름 줄 · DOM 없음
|
||||
* 셈은 모델 것 그대로(Eval 의 evalLogic · breaks · Model 의 traceBack · traceForward · Rule 의 ruleLabel)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { breaks, evalLogic, type CfEvalResult, type CfValue, type CfValues } from "./M02_DF_Eval";
|
||||
import {
|
||||
portsIn,
|
||||
breaks,
|
||||
evalLogic,
|
||||
type CfBreak,
|
||||
type CfEvalOptions,
|
||||
type CfEvalResult,
|
||||
type CfValue,
|
||||
type CfValues,
|
||||
} from "./M02_DF_Eval";
|
||||
import {
|
||||
portDir,
|
||||
portsOut,
|
||||
traceBack,
|
||||
traceForward,
|
||||
@@ -17,26 +25,15 @@ import {
|
||||
type CfLink,
|
||||
type CfLogic,
|
||||
type CfPort,
|
||||
type CfRound,
|
||||
type CfRule,
|
||||
type CfStep,
|
||||
} from "./M02_DF_Model";
|
||||
import { applyRule, ruleLabel } from "./M02_DF_Rule";
|
||||
import { 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 =>
|
||||
@@ -48,16 +45,22 @@ export const td = (key: keyof typeof TEXT, fill: Record<string, string | number>
|
||||
export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"];
|
||||
export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"];
|
||||
|
||||
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
|
||||
export interface DetailOptions {
|
||||
values?: CfValues;
|
||||
logics?: CfEvalOptions["logics"];
|
||||
}
|
||||
|
||||
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 {
|
||||
/** 계산 한 번 — 한창 만드는 중 막히면 null */
|
||||
export function evalSafe(lg: CfLogic, o: DetailOptions = {}): CfEvalResult | null {
|
||||
try {
|
||||
return evalLogic(lg, values);
|
||||
return evalLogic(lg, o.values, { logics: o.logics });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -79,176 +82,55 @@ export function unitOf(lg: CfLogic, p: CfPort): string {
|
||||
}
|
||||
|
||||
/** 알약이 나가는(out) 쪽인가 */
|
||||
export const isOut = (box: CfBox, name: string): boolean =>
|
||||
portsOut(box).some((p) => p.name === name);
|
||||
export const isOut = (box: CfBox, name: string): boolean => portDir(box, name) === "out";
|
||||
|
||||
/* ── 규칙 ─────────────────────────────────────────────────────────────── */
|
||||
/** 선 위 규칙 글 */
|
||||
export const labelOf = (rule: CfRule | undefined): string =>
|
||||
rule ? ruleLabel(rule) : td("NoRule");
|
||||
|
||||
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 preview(link: CfLink, ev: CfEvalResult | null): { before: string; after: string } {
|
||||
const before = showValue(valueOf(ev, link.from));
|
||||
if (!link.rule) return { before, after: before };
|
||||
const arrived = ev?.arrived?.[link.to.box]?.[link.to.name];
|
||||
if (arrived !== undefined && arrived !== null) return { before, after: showValue(arrived) };
|
||||
const why = ev?.breaks?.find((b) => b.link === link.id)?.text;
|
||||
return { before, after: why ?? td("Empty") };
|
||||
}
|
||||
|
||||
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 linkNotes(link: CfLink, ev: CfEvalResult | null): string[] {
|
||||
return (ev?.breaks ?? [])
|
||||
.filter(
|
||||
(b) =>
|
||||
b.link === link.id ||
|
||||
((b.kind === "조건 겹침" || b.kind === "조건 안 맞음") &&
|
||||
b.box === link.from.box &&
|
||||
b.port === link.from.name),
|
||||
)
|
||||
.map((b) => b.text);
|
||||
}
|
||||
|
||||
/** 선 위 규칙 글 — 모델이 준비되면 그것, 아니면 같은 뜻의 글 */
|
||||
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") — 모델 것, 없으면 작은 셈 */
|
||||
/** 온 길(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;
|
||||
}
|
||||
const steps = dir === "in" ? traceBack(lg, p) : traceForward(lg, p);
|
||||
return steps.filter((s) => !(s.box === p.box && s.port === p.name));
|
||||
}
|
||||
|
||||
/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */
|
||||
export function breakLines(lg: CfLogic, boxId: string, name?: string): string[] {
|
||||
export function breakLines(
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
name?: string,
|
||||
o: DetailOptions = {},
|
||||
): string[] {
|
||||
let list: CfBreak[] = [];
|
||||
try {
|
||||
return breaks(lg)
|
||||
.filter((b) => b.box === boxId && (!name || !b.port || b.port === name))
|
||||
.map((b) => b.text);
|
||||
list = breaks(lg, o.values, { logics: o.logics });
|
||||
} 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;
|
||||
return list
|
||||
.filter((b) => b.box === boxId && (!name || !b.port || b.port === name))
|
||||
.map((b) => b.text);
|
||||
}
|
||||
|
||||
export const noValue = (): string => td("NoValue");
|
||||
|
||||
@@ -70,8 +70,15 @@
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 20px 20px;
|
||||
cursor: grab;
|
||||
cursor: default;
|
||||
touch-action: none;
|
||||
/* 왼쪽 끌기로 글자가 잡히지 않게 */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.m02-dfdg__view.is-panning {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.m02-dfdg__world {
|
||||
@@ -274,3 +281,132 @@
|
||||
border-color: var(--cfdg-in);
|
||||
color: var(--cfdg-in);
|
||||
}
|
||||
|
||||
/* ── 흐름도 더함: 선 규칙 이름표 · 흐름 길 · 끊긴 곳 · 알약 고름 ── */
|
||||
.m02-dfdg {
|
||||
--dfdg-back: var(--color-accent);
|
||||
--dfdg-fwd: #c2410c;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .m02-dfdg {
|
||||
--dfdg-fwd: #f0a35e;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .m02-dfdg {
|
||||
--dfdg-fwd: #f0a35e;
|
||||
}
|
||||
}
|
||||
|
||||
.m02-dfdg__rule rect {
|
||||
fill: var(--color-surface);
|
||||
stroke: var(--color-border);
|
||||
}
|
||||
|
||||
.m02-dfdg__rule text {
|
||||
fill: var(--color-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.m02-dfdg__link.is-strong .m02-dfdg__rule rect {
|
||||
stroke: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 흐름 길 — 색 + 굵기 + 점선(색만으로 뜻을 안 전함) */
|
||||
.m02-dfdg__link.is-back .m02-dfdg__wire {
|
||||
stroke: var(--dfdg-back);
|
||||
stroke-width: 3.5;
|
||||
}
|
||||
|
||||
.m02-dfdg__link.is-fwd .m02-dfdg__wire {
|
||||
stroke: var(--dfdg-fwd);
|
||||
stroke-width: 3.5;
|
||||
stroke-dasharray: 8 5;
|
||||
}
|
||||
|
||||
.m02-dfdg__link.is-dim .m02-dfdg__wire,
|
||||
.m02-dfdg__link.is-dim .m02-dfdg__rule {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-back {
|
||||
outline: 3px solid var(--dfdg-back);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-fwd {
|
||||
outline: 3px dashed var(--dfdg-fwd);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.m02-dfdg__box.is-dim {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-back {
|
||||
box-shadow: 0 0 0 2px var(--dfdg-back);
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-fwd {
|
||||
outline: 2px dashed var(--dfdg-fwd);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.m02-dfdg__legend {
|
||||
padding: 0 var(--spacing-8);
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-dfdg__legend.is-back {
|
||||
border: 2px solid var(--dfdg-back);
|
||||
color: var(--dfdg-back);
|
||||
}
|
||||
|
||||
.m02-dfdg__legend.is-fwd {
|
||||
border: 2px dashed var(--dfdg-fwd);
|
||||
color: var(--dfdg-fwd);
|
||||
}
|
||||
|
||||
/* 고른 알약 테두리 */
|
||||
.m02-dfdg__pill.is-chosen {
|
||||
box-shadow: 0 0 0 2px var(--color-text);
|
||||
}
|
||||
|
||||
/* 끊긴 곳 */
|
||||
.m02-dfdg__warn {
|
||||
position: absolute;
|
||||
top: -9px;
|
||||
right: -9px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m02-dfdg__pill.is-broken {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m02-dfdg__pill-warn {
|
||||
color: var(--color-danger);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m02-dfdg__linkwarn circle {
|
||||
fill: var(--color-danger);
|
||||
}
|
||||
|
||||
.m02-dfdg__linkwarn text {
|
||||
fill: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -1,184 +1,39 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Diagram.ts
|
||||
* 단가산출 다이어그램 — 자유 배치(정함 점검 8 · 9 · 12 · 「상자 · 선 약속」)
|
||||
* 상자(입력 · 함수 · 출력)를 끌어 아무 자리에 놓음(on.move) · 판 끌기 · 확대 / 축소 · 「한눈에」 · 「줄 맞추기」 ·
|
||||
* 상자 = 종류 · 이름 · 오른쪽 위 단위 · 색 · 왼쪽 받는 알약 · 오른쪽 내보내는 알약(알약에 지금 값) ·
|
||||
* 선 = 굽은 선 · 내보내는 알약을 끌어 받는 알약에 놓으면 on.link(막히면 까닭 한 줄) · 고른 상자와 이어진 선만 진하게.
|
||||
* SheetLogic 다이어그램(M01_SL_Diagram)을 옮겨 고쳐 씀 — 그 파일에 기대지 않음. 그리기만(바꾸는 것은 부른 쪽이 Model 로).
|
||||
* 단가산출 흐름도 — 다이어그램(흐름도 정함 7 · 8 · 13 · 1판 「상자 · 선 약속」)
|
||||
* 자유 배치 상자(입력 · 함수 · 출력) · 굽은 선 + 가운데 선 규칙 이름표 · 흐름 길(온 길 · 가는 길) 칠하기 ·
|
||||
* 끊긴 곳 표시 · 알약 · 선 · 상자 고르기. 마우스 — 판 옮기기 = 가운데 단추 끌기(또는 Space + 왼쪽 끌기) ·
|
||||
* 고르기 = 왼쪽 누름 · 상자 옮기기 = 상자 머리 왼쪽 끌기 · 휠 = 확대 · 글자 선택 막음.
|
||||
* 그리기만(바꾸는 것은 부른 쪽이 Model 로) · 순수 부분은 `M02_DF_Diagram_Parts.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
|
||||
import type { CfBox, CfKind, CfLogic, CfPill, CfPort } from "./M02_DF_Model";
|
||||
import {
|
||||
portsIn,
|
||||
portsOut,
|
||||
type CfBox,
|
||||
type CfKind,
|
||||
type CfLogic,
|
||||
type CfPill,
|
||||
type CfPort,
|
||||
} from "./M02_DF_Model";
|
||||
arrange,
|
||||
clampZoom,
|
||||
fitView,
|
||||
KIND_COLOR,
|
||||
linkLabel,
|
||||
midOf,
|
||||
pillsOf,
|
||||
resolveDrop,
|
||||
showValue,
|
||||
traceOf,
|
||||
tt,
|
||||
type DfPillSel,
|
||||
type DiagramOn,
|
||||
type DiagramOpts,
|
||||
type TEXT,
|
||||
} from "./M02_DF_Diagram_Parts";
|
||||
import "./M02_DF_Diagram.css";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["다이어그램", "Diagram"],
|
||||
K_input: ["입력", "Input"],
|
||||
K_function: ["함수", "Function"],
|
||||
K_output: ["출력", "Output"],
|
||||
AddInput: ["+ 입력", "+ Input"],
|
||||
AddFunc: ["+ 함수", "+ Function"],
|
||||
AddOutput: ["+ 출력", "+ Output"],
|
||||
ZoomIn: ["확대", "Zoom in"],
|
||||
ZoomOut: ["축소", "Zoom out"],
|
||||
Fit: ["한눈에", "Fit all"],
|
||||
Arrange: ["줄 맞추기", "Line up"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Connect: ["잇기", "Connect"],
|
||||
Disconnect: ["끊기", "Disconnect"],
|
||||
Remove: ["상자 지우기", "Remove box"],
|
||||
AskRemove: [
|
||||
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
|
||||
"Remove box “{v}”? Its lines go too",
|
||||
],
|
||||
PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"],
|
||||
Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"],
|
||||
NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"],
|
||||
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
|
||||
NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"],
|
||||
NoMatch: [
|
||||
"같은 이름 받는 알약 없음 — 알약에 바로 놓으세요",
|
||||
"No pill with that name — drop on a pill",
|
||||
],
|
||||
Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"],
|
||||
From: ["어디서", "From"],
|
||||
To: ["어디로", "To"],
|
||||
Value: ["지금 값", "Now"],
|
||||
PlusIn: [
|
||||
"받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김",
|
||||
"Drop an output pill here to add an input",
|
||||
],
|
||||
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
||||
Hint: [
|
||||
"상자 머리를 끌어 옮김 · 빈 곳 끌어 판 옮김 · 휠로 확대",
|
||||
"Drag head to move · drag space to pan",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const tt = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
|
||||
|
||||
/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */
|
||||
export const KIND_COLOR: Record<CfKind, string> = {
|
||||
input: "#2a6fb0",
|
||||
function: "#7a4fb5",
|
||||
output: "#c2610c",
|
||||
};
|
||||
|
||||
export interface DiagramOn {
|
||||
select(id: string | null): void;
|
||||
move(boxId: string, x: number, y: number): void;
|
||||
/** 잇기 — 막히면 까닭(쉬운 말) */
|
||||
link(from: CfPort, to: CfPort): string | null | void;
|
||||
unlink(linkId: string): void;
|
||||
add(kind: CfKind, x: number, y: number): void;
|
||||
remove(boxId: string): void;
|
||||
}
|
||||
|
||||
export interface DiagramOpts {
|
||||
/** 상자 id → 알약 이름 → 지금 값 */
|
||||
values?: Record<string, Record<string, unknown>>;
|
||||
/** 고른 상자 id · 선 id */
|
||||
selected?: string | null;
|
||||
}
|
||||
|
||||
/* ── 알약 · 자리(DOM 없음) ─────────────────────────────────────────── */
|
||||
|
||||
/** 알약 — Model 의 받는 · 내보내는 알약 */
|
||||
export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } {
|
||||
return { ins: portsIn(box), outs: portsOut(box) };
|
||||
}
|
||||
|
||||
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
|
||||
export function resolveDrop(
|
||||
lg: CfLogic,
|
||||
from: CfPort,
|
||||
target: { box: string; name: string | null; dir: "in" | "out" | null } | null,
|
||||
): { to: CfPort } | { why: string } {
|
||||
if (!target) return { why: tt("NotIn") };
|
||||
if (target.box === from.box) return { why: tt("SameBox") };
|
||||
if (target.dir === "out") return { why: tt("NotIn") };
|
||||
const box = lg.boxes.find((b) => b.id === target.box);
|
||||
// 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」)
|
||||
if (box?.kind === "function" && target.name === null)
|
||||
return { to: { box: box.id, name: from.name } };
|
||||
const ins = box ? pillsOf(box).ins : [];
|
||||
if (!box || !ins.length) return { why: tt("NoIn") };
|
||||
const name =
|
||||
target.name ??
|
||||
ins.find((p) => p.name === from.name)?.name ??
|
||||
(ins.length === 1 ? ins[0].name : null);
|
||||
if (!name) return { why: tt("NoMatch") };
|
||||
if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") };
|
||||
return { to: { box: box.id, name } };
|
||||
}
|
||||
|
||||
/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */
|
||||
export function arrange(
|
||||
lg: CfLogic,
|
||||
gap = { x: 260, y: 130 },
|
||||
): Record<string, { x: number; y: number }> {
|
||||
const depth = new Map<string, number>(lg.boxes.map((b) => [b.id, 0]));
|
||||
for (let round = 0; round < lg.boxes.length; round++) {
|
||||
let moved = false;
|
||||
for (const l of lg.links) {
|
||||
const d = (depth.get(l.from.box) ?? 0) + 1;
|
||||
if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) {
|
||||
depth.set(l.to.box, d);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
const rows = new Map<number, number>();
|
||||
const out: Record<string, { x: number; y: number }> = {};
|
||||
for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) {
|
||||
const d = depth.get(b.id) ?? 0;
|
||||
const r = rows.get(d) ?? 0;
|
||||
rows.set(d, r + 1);
|
||||
out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */
|
||||
export const FIT_MIN = 0.9;
|
||||
|
||||
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
|
||||
export function fitView(
|
||||
rects: { x: number; y: number; w: number; h: number }[],
|
||||
view: { w: number; h: number },
|
||||
pad = 40,
|
||||
): { 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 place = (size: number, from: number, to: number): number =>
|
||||
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
|
||||
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
|
||||
}
|
||||
|
||||
const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────── */
|
||||
export * from "./M02_DF_Diagram_Parts";
|
||||
|
||||
interface HostState {
|
||||
selected: string | null;
|
||||
linking: CfPort | null;
|
||||
pill: { box: string; name: string; dir: "in" | "out" } | null;
|
||||
pill: DfPillSel | null;
|
||||
note: string;
|
||||
zoom: number;
|
||||
panX: number;
|
||||
@@ -189,18 +44,16 @@ interface HostState {
|
||||
|
||||
const states = new WeakMap<HTMLElement, HostState>();
|
||||
const SVG = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
|
||||
export const showValue = (v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
if (typeof v === "number")
|
||||
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
|
||||
if (Array.isArray(v)) {
|
||||
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
|
||||
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
|
||||
}
|
||||
return String(v).slice(0, 12);
|
||||
};
|
||||
/** Space 를 누르고 있음 — 터치패드용 판 옮기기 */
|
||||
let spaceDown = false;
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space" && !(e.target as HTMLElement)?.closest?.("input, textarea, select"))
|
||||
spaceDown = true;
|
||||
});
|
||||
window.addEventListener("keyup", (e) => e.code === "Space" && (spaceDown = false));
|
||||
window.addEventListener("blur", () => (spaceDown = false));
|
||||
}
|
||||
|
||||
export function renderDiagram(
|
||||
host: HTMLElement,
|
||||
@@ -219,15 +72,19 @@ export function renderDiagram(
|
||||
fitted: false,
|
||||
};
|
||||
if (opts.selected !== undefined) st.selected = opts.selected;
|
||||
if (opts.selectedPill !== undefined) st.pill = opts.selectedPill;
|
||||
states.set(host, st);
|
||||
const values = opts.values ?? {};
|
||||
const labels = opts.labels ?? {};
|
||||
const breaks = opts.breaks ?? [];
|
||||
const boxName = (id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? "";
|
||||
const redraw = (): void => renderDiagram(host, lg, on, { ...opts, selected: st.selected });
|
||||
const select = (id: string | null): void => {
|
||||
st.selected = id;
|
||||
st.pill = null;
|
||||
st.note = "";
|
||||
on.select(id);
|
||||
const redraw = (): void =>
|
||||
renderDiagram(host, lg, on, { ...opts, selected: st.selected, selectedPill: st.pill });
|
||||
const select = (id: string | null, pill?: DfPillSel, note = ""): void => {
|
||||
st.selected = pill ? null : id;
|
||||
st.pill = pill ?? null;
|
||||
st.note = note;
|
||||
on.select(id, pill);
|
||||
redraw();
|
||||
};
|
||||
const tryLink = (from: CfPort, target: Parameters<typeof resolveDrop>[2]): void => {
|
||||
@@ -236,6 +93,23 @@ export function renderDiagram(
|
||||
st.note = "why" in got ? got.why : on.link(from, got.to) || "";
|
||||
redraw();
|
||||
};
|
||||
const markOf = (pick: Parameters<typeof traceOf>[1]): string => {
|
||||
const m = traceOf(opts.trace, pick);
|
||||
return m ? ` is-${m}` : "";
|
||||
};
|
||||
const warn = (why: string, onClick: () => void): HTMLElement => {
|
||||
const b = el("button", {
|
||||
className: "m02-dfdg__warn",
|
||||
text: "!",
|
||||
attrs: { type: "button", title: why, "aria-label": `${tt("Break")} · ${why}` },
|
||||
});
|
||||
b.addEventListener("pointerdown", (ev) => ev.stopPropagation());
|
||||
b.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return b;
|
||||
};
|
||||
|
||||
/* 판 · 세상(확대 · 옮김을 한 번에) */
|
||||
const world = el("div", { className: "m02-dfdg__world" });
|
||||
@@ -247,6 +121,7 @@ export function renderDiagram(
|
||||
attrs: { "aria-label": tt("Title") },
|
||||
children: [world],
|
||||
});
|
||||
const zoomLabel = el("span", { className: "m02-dfdg__zoom" });
|
||||
const applyView = (): void => {
|
||||
world.style.transform = `translate(${st.panX}px, ${st.panY}px) scale(${st.zoom})`;
|
||||
zoomLabel.textContent = `${Math.round(st.zoom * 100)}%`;
|
||||
@@ -257,18 +132,22 @@ export function renderDiagram(
|
||||
return { x: (cx - r.left - st.panX) / st.zoom, y: (cy - r.top - st.panY) / st.zoom };
|
||||
};
|
||||
|
||||
/* 상자 */
|
||||
/* 상자 · 알약 */
|
||||
const pillEl = (b: CfBox, p: CfPill, dir: "in" | "out"): HTMLElement => {
|
||||
const wired = dir === "out" || lg.links.some((l) => l.to.box === b.id && l.to.name === p.name);
|
||||
const chosen = st.pill?.box === b.id && st.pill.name === p.name && st.pill.dir === dir;
|
||||
const chosen = st.pill?.box === b.id && st.pill.port === p.name && st.pill.dir === dir;
|
||||
const broken = breaks.find((k) => !k.link && k.box === b.id && k.port === p.name);
|
||||
const sel: DfPillSel = { box: b.id, port: p.name, dir };
|
||||
const btn = el("button", {
|
||||
className: [
|
||||
"m02-dfdg__pill",
|
||||
`is-${dir}`,
|
||||
wired ? "" : "is-empty",
|
||||
chosen ? "is-chosen" : "",
|
||||
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
|
||||
].join(" "),
|
||||
className:
|
||||
[
|
||||
"m02-dfdg__pill",
|
||||
`is-${dir}`,
|
||||
wired ? "" : "is-empty",
|
||||
chosen ? "is-chosen" : "",
|
||||
broken ? "is-broken" : "",
|
||||
st.linking && dir === "in" && b.id !== st.linking.box ? "is-target" : "",
|
||||
].join(" ") + markOf((s) => s.some((x) => x.box === b.id && x.port === p.name)),
|
||||
attrs: {
|
||||
type: "button",
|
||||
"data-box": b.id,
|
||||
@@ -277,6 +156,7 @@ export function renderDiagram(
|
||||
title: `${p.name} (${p.unit || "-"})`,
|
||||
},
|
||||
children: [
|
||||
...(broken ? [el("span", { className: "m02-dfdg__pill-warn", text: "!" })] : []),
|
||||
el("span", { className: "m02-dfdg__pill-name", text: wired ? p.name : `? ${p.name}` }),
|
||||
el("span", { className: "m02-dfdg__pill-value", text: showValue(values[b.id]?.[p.name]) }),
|
||||
],
|
||||
@@ -284,10 +164,9 @@ export function renderDiagram(
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (st.linking && dir === "in") return tryLink(st.linking, { box: b.id, name: p.name, dir });
|
||||
st.pill = chosen ? null : { box: b.id, name: p.name, dir };
|
||||
st.linking = null;
|
||||
st.note = "";
|
||||
redraw();
|
||||
if (chosen) select(null);
|
||||
else select(b.id, sel, broken?.text ?? "");
|
||||
});
|
||||
if (dir === "out")
|
||||
btn.addEventListener("pointerdown", (ev) => dragLink(ev, { box: b.id, name: p.name }));
|
||||
@@ -318,21 +197,25 @@ export function renderDiagram(
|
||||
|
||||
const boxEl = (b: CfBox): HTMLElement => {
|
||||
const { ins, outs } = pillsOf(b);
|
||||
const kind = tt(`K_${b.kind}` as keyof typeof TEXT);
|
||||
const broken = breaks.filter((k) => !k.link && !k.port && k.box === b.id);
|
||||
const head = el("div", {
|
||||
className: "m02-dfdg__head",
|
||||
children: [
|
||||
el("span", { className: "m02-dfdg__kind", text: tt(`K_${b.kind}` as keyof typeof TEXT) }),
|
||||
el("span", { className: "m02-dfdg__kind", text: kind }),
|
||||
el("strong", { className: "m02-dfdg__name", text: b.name }),
|
||||
el("span", { className: "m02-dfdg__unit", text: b.unit || "-" }),
|
||||
],
|
||||
});
|
||||
const node = el("div", {
|
||||
className: `m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}`,
|
||||
className:
|
||||
`m02-dfdg__box is-${b.kind}${st.selected === b.id ? " is-selected" : ""}` +
|
||||
(st.selected === b.id ? "" : markOf((s) => s.some((x) => x.box === b.id))),
|
||||
attrs: {
|
||||
"data-box": b.id,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"aria-label": `${tt(`K_${b.kind}` as keyof typeof TEXT)} · ${b.name}`,
|
||||
"aria-label": `${kind} · ${b.name}`,
|
||||
},
|
||||
children: [
|
||||
head,
|
||||
@@ -352,6 +235,13 @@ export function renderDiagram(
|
||||
}),
|
||||
],
|
||||
}),
|
||||
...(broken.length
|
||||
? [
|
||||
warn(broken.map((k) => k.text).join(" · "), () =>
|
||||
select(b.id, undefined, broken.map((k) => k.text).join(" · ")),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
node.style.left = `${b.x}px`;
|
||||
@@ -364,7 +254,7 @@ export function renderDiagram(
|
||||
});
|
||||
node.addEventListener("keydown", (ev) => {
|
||||
if (ev.target !== node) return;
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
if (ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
node.click();
|
||||
}
|
||||
@@ -380,14 +270,15 @@ export function renderDiagram(
|
||||
on.move(b.id, Math.round(b.x + d[0]), Math.round(b.y + d[1]));
|
||||
}
|
||||
});
|
||||
head.addEventListener("pointerdown", (ev) => dragBox(ev, b, node));
|
||||
head.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.button === 0 && !spaceDown) dragBox(ev, b, node);
|
||||
});
|
||||
return node;
|
||||
};
|
||||
|
||||
world.append(...lg.boxes.map(boxEl));
|
||||
|
||||
/* 위 줄 — 이름 · 더하기 · 확대 · 고른 것 */
|
||||
const zoomLabel = el("span", { className: "m02-dfdg__zoom" });
|
||||
const setZoom = (z: number, cx?: number, cy?: number): void => {
|
||||
const r = view.getBoundingClientRect();
|
||||
const px = cx ?? r.left + r.width / 2;
|
||||
@@ -416,7 +307,7 @@ export function renderDiagram(
|
||||
while (lg.boxes.some((b) => Math.abs(b.x - c.x) < 160 && Math.abs(b.y - y) < 90)) y += 100;
|
||||
return { x: Math.round(c.x), y: Math.round(y) };
|
||||
};
|
||||
/** 작은 단추 */
|
||||
/** 공용 작은 단추 */
|
||||
const sm = (
|
||||
label: keyof typeof TEXT,
|
||||
onClick: () => void,
|
||||
@@ -442,31 +333,39 @@ export function renderDiagram(
|
||||
);
|
||||
} else if (st.pill) {
|
||||
const p = st.pill;
|
||||
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.name;
|
||||
const mine = (e: CfPort): boolean => e.box === p.box && e.name === p.port;
|
||||
const where = lg.links
|
||||
.filter((l) => mine(p.dir === "in" ? l.to : l.from))
|
||||
.map((l) => endName(p.dir === "in" ? l.from : l.to))
|
||||
.join(", ");
|
||||
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.name]) || "—"}`;
|
||||
const now = `${tt("Value")} ${showValue(values[p.box]?.[p.port]) || "—"}`;
|
||||
const dir = where ? `${tt(p.dir === "in" ? "From" : "To")} ${where}` : "";
|
||||
tools.push(el("span", { text: [p.name, now, dir].filter(Boolean).join(" · ") }));
|
||||
tools.push(el("span", { text: [p.port, now, dir].filter(Boolean).join(" · ") }));
|
||||
if (p.dir === "out")
|
||||
tools.push(
|
||||
sm(
|
||||
"Connect",
|
||||
() => ((st.linking = { box: p.box, name: p.name }), (st.pill = null), redraw()),
|
||||
() => ((st.linking = { box: p.box, name: p.port }), (st.pill = null), redraw()),
|
||||
"filled",
|
||||
),
|
||||
);
|
||||
} else if (selLink) {
|
||||
const rule = labels[selLink.id] ?? linkLabel(selLink);
|
||||
const line = tt("LineOf", { a: endName(selLink.from), b: endName(selLink.to) });
|
||||
tools.push(
|
||||
el("span", { text: line }),
|
||||
el("span", { text: rule ? `${line} · ${rule}` : line }),
|
||||
sm("Disconnect", () => on.unlink(selLink.id), "danger"),
|
||||
);
|
||||
} else if (selBox) {
|
||||
tools.push(sm("Remove", () => void askRemove(selBox), "danger"));
|
||||
}
|
||||
const legend =
|
||||
opts.trace && (opts.trace.back || opts.trace.forward)
|
||||
? [
|
||||
el("span", { className: "m02-dfdg__legend is-back", text: tt("Back") }),
|
||||
el("span", { className: "m02-dfdg__legend is-fwd", text: tt("Forward") }),
|
||||
]
|
||||
: [];
|
||||
|
||||
const bar = el("div", {
|
||||
className: "m02-dfdg__bar",
|
||||
@@ -476,6 +375,7 @@ export function renderDiagram(
|
||||
addBtn("function", "AddFunc"),
|
||||
addBtn("output", "AddOutput"),
|
||||
el("span", { className: "m02-dfdg__gap" }),
|
||||
...legend,
|
||||
sm("ZoomOut", () => setZoom(st.zoom / 1.2)),
|
||||
zoomLabel,
|
||||
sm("ZoomIn", () => setZoom(st.zoom * 1.2)),
|
||||
@@ -494,7 +394,10 @@ export function renderDiagram(
|
||||
children: [
|
||||
...tools,
|
||||
el("span", { className: "m02-dfdg__note", text: st.note }),
|
||||
el("span", { className: "m02-dfdg__hint", text: tools.length ? "" : tt("Hint") }),
|
||||
el("span", {
|
||||
className: "m02-dfdg__hint",
|
||||
text: tools.length || st.note ? "" : tt("Hint"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
lg.boxes.length ? view : el("p", { className: "m02-dfdg__hint", text: tt("Empty") }),
|
||||
@@ -521,14 +424,24 @@ export function renderDiagram(
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
view.addEventListener("pointerdown", (ev) => {
|
||||
if (ev.target !== view && ev.target !== world && ev.target !== svg) return;
|
||||
dragPan(ev);
|
||||
});
|
||||
// 판 옮기기 = 가운데 단추 끌기(어디서나) · Space + 왼쪽 끌기 — 왼쪽 빈 곳 끌기는 아무것도 안 함
|
||||
view.addEventListener(
|
||||
"pointerdown",
|
||||
(ev) => {
|
||||
if (ev.button === 1 || (ev.button === 0 && spaceDown)) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragPan(ev);
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
);
|
||||
view.addEventListener("auxclick", (ev) => ev.button === 1 && ev.preventDefault());
|
||||
view.addEventListener("mousedown", (ev) => ev.button === 1 && ev.preventDefault());
|
||||
host.replaceChildren(root);
|
||||
applyView();
|
||||
|
||||
/* 선 — 알약 자리를 재서 세상 좌표로 */
|
||||
/* 선 — 알약 자리를 재서 세상 좌표로 · 가운데 규칙 이름표 · 끊긴 곳 */
|
||||
function drawLines(): void {
|
||||
svg.replaceChildren();
|
||||
const at = (end: CfPort, dir: "in" | "out"): { x: number; y: number } | null => {
|
||||
@@ -539,6 +452,16 @@ export function renderDiagram(
|
||||
const r = pill.getBoundingClientRect();
|
||||
return toWorld(dir === "out" ? r.right : r.left, r.top + r.height / 2);
|
||||
};
|
||||
const add = <K extends keyof SVGElementTagNameMap>(
|
||||
parent: Element,
|
||||
tag: K,
|
||||
attrs: Record<string, string>,
|
||||
) => {
|
||||
const n = document.createElementNS(SVG, tag);
|
||||
for (const [k, v] of Object.entries(attrs)) n.setAttribute(k, v);
|
||||
parent.append(n);
|
||||
return n;
|
||||
};
|
||||
for (const l of lg.links) {
|
||||
const a = at(l.from, "out");
|
||||
const b = at(l.to, "in");
|
||||
@@ -548,24 +471,56 @@ export function renderDiagram(
|
||||
const strong =
|
||||
st.selected === l.id ||
|
||||
(selBox !== null && (l.from.box === selBox.id || l.to.box === selBox.id));
|
||||
const g = document.createElementNS(SVG, "g");
|
||||
g.setAttribute(
|
||||
"class",
|
||||
`m02-dfdg__link${strong ? " is-strong" : ""}${st.selected !== null && !strong ? " is-dim" : ""}`,
|
||||
);
|
||||
g.setAttribute("data-link", l.id);
|
||||
g.setAttribute("data-ends", [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","));
|
||||
for (const cls of ["m02-dfdg__hit", "m02-dfdg__wire"]) {
|
||||
const p = document.createElementNS(SVG, "path");
|
||||
p.setAttribute("d", d);
|
||||
p.setAttribute("class", cls);
|
||||
g.append(p);
|
||||
const traced = markOf((s) => s.some((x) => x.link === l.id));
|
||||
const mark = strong && traced === " is-dim" ? "" : traced; // 고른 선은 흐리게 하지 않음
|
||||
const dim = !mark && st.selected !== null && !strong ? " is-dim" : "";
|
||||
const g = add(svg, "g", {
|
||||
class: `m02-dfdg__link${strong ? " is-strong" : ""}${dim}${mark}`,
|
||||
"data-link": l.id,
|
||||
"data-ends": [a.x, a.y, b.x, b.y].map((n) => n.toFixed(1)).join(","),
|
||||
});
|
||||
add(g, "path", { d, class: "m02-dfdg__hit" });
|
||||
add(g, "path", { d, class: "m02-dfdg__wire" });
|
||||
const mid = midOf(a, b);
|
||||
const rule = labels[l.id] ?? linkLabel(l);
|
||||
if (rule) {
|
||||
const tag = add(g, "g", { class: "m02-dfdg__rule" });
|
||||
const box = add(tag, "rect", { rx: "4", ry: "4" });
|
||||
const text = add(tag, "text", {
|
||||
x: mid.x.toFixed(1),
|
||||
y: mid.y.toFixed(1),
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
text.textContent = rule;
|
||||
const w = (text.getComputedTextLength?.() || rule.length * 7) + 10;
|
||||
Object.entries({ x: mid.x - w / 2, y: mid.y - 9, width: w, height: 18 }).forEach(([k, v]) =>
|
||||
box.setAttribute(k, v.toFixed(1)),
|
||||
);
|
||||
}
|
||||
const broken = breaks.filter((k) => k.link === l.id);
|
||||
if (broken.length) {
|
||||
const why = broken.map((k) => k.text).join(" · ");
|
||||
const badge = add(g, "g", { class: "m02-dfdg__linkwarn", "data-why": why });
|
||||
add(badge, "circle", {
|
||||
cx: mid.x.toFixed(1),
|
||||
cy: (mid.y - (rule ? 18 : 0)).toFixed(1),
|
||||
r: "8",
|
||||
});
|
||||
const t = add(badge, "text", {
|
||||
x: mid.x.toFixed(1),
|
||||
y: (mid.y - (rule ? 18 : 0)).toFixed(1),
|
||||
"dominant-baseline": "central",
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
t.textContent = "!";
|
||||
add(badge, "title", {}).textContent = why;
|
||||
}
|
||||
g.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
select(st.selected === l.id ? null : l.id);
|
||||
const why = broken.map((k) => k.text).join(" · ");
|
||||
select(st.selected === l.id ? null : l.id, undefined, st.selected === l.id ? "" : why);
|
||||
});
|
||||
svg.append(g);
|
||||
}
|
||||
}
|
||||
const firstFit = (): void => {
|
||||
@@ -584,7 +539,6 @@ export function renderDiagram(
|
||||
move: (m: PointerEvent) => void,
|
||||
end: (u: PointerEvent, moved: boolean) => void,
|
||||
): void {
|
||||
if (ev.button !== 0) return;
|
||||
const x0 = ev.clientX;
|
||||
const y0 = ev.clientY;
|
||||
let moved = false;
|
||||
@@ -609,6 +563,7 @@ export function renderDiagram(
|
||||
}
|
||||
|
||||
function dragBox(ev: PointerEvent, b: CfBox, node: HTMLElement): void {
|
||||
ev.preventDefault(); // 끌면서 글자가 잡히지 않게
|
||||
const start = toWorld(ev.clientX, ev.clientY);
|
||||
let x = b.x;
|
||||
let y = b.y;
|
||||
@@ -630,6 +585,7 @@ export function renderDiagram(
|
||||
|
||||
function dragPan(ev: PointerEvent): void {
|
||||
const p0 = { x: st.panX, y: st.panY };
|
||||
view.classList.add("is-panning");
|
||||
follow(
|
||||
ev,
|
||||
(m) => {
|
||||
@@ -637,12 +593,14 @@ export function renderDiagram(
|
||||
st.panY = p0.y + m.clientY - ev.clientY;
|
||||
applyView();
|
||||
},
|
||||
() => undefined,
|
||||
() => view.classList.remove("is-panning"),
|
||||
);
|
||||
}
|
||||
|
||||
function dragLink(ev: PointerEvent, from: CfPort): void {
|
||||
if (ev.button !== 0 || spaceDown) return;
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const pill = ev.currentTarget as HTMLElement;
|
||||
const r = pill.getBoundingClientRect();
|
||||
const a = toWorld(r.right, r.top + r.height / 2);
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Diagram_Parts.ts
|
||||
* 단가산출 흐름도 다이어그램의 순수 부분(DOM 없음) — 글 · 약속 모양 · 떨군 자리 풀기 · 줄 맞추기 ·
|
||||
* 한눈에 맞춤 · 알약 값 글 · 흐름 길 가르기. 그리기는 `M02_DF_Diagram.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
portsIn,
|
||||
portsOut,
|
||||
type CfBox,
|
||||
type CfKind,
|
||||
type CfLogic,
|
||||
type CfPill,
|
||||
type CfPort,
|
||||
type CfLink,
|
||||
type CfStep,
|
||||
} from "./M02_DF_Model";
|
||||
import type { CfBreak } from "./M02_DF_Eval";
|
||||
import { ruleLabel } from "./M02_DF_Rule";
|
||||
|
||||
export const TEXT = {
|
||||
Title: ["다이어그램", "Diagram"],
|
||||
K_input: ["입력", "Input"],
|
||||
K_function: ["함수", "Function"],
|
||||
K_output: ["출력", "Output"],
|
||||
AddInput: ["+ 입력", "+ Input"],
|
||||
AddFunc: ["+ 함수", "+ Function"],
|
||||
AddOutput: ["+ 출력", "+ Output"],
|
||||
ZoomIn: ["확대", "Zoom in"],
|
||||
ZoomOut: ["축소", "Zoom out"],
|
||||
Fit: ["한눈에", "Fit all"],
|
||||
Arrange: ["줄 맞추기", "Line up"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Connect: ["잇기", "Connect"],
|
||||
Disconnect: ["끊기", "Disconnect"],
|
||||
Remove: ["상자 지우기", "Remove box"],
|
||||
AskRemove: [
|
||||
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
|
||||
"Remove box “{v}”? Its lines go too",
|
||||
],
|
||||
PickTarget: ["「{v}」 을 받을 알약을 누르세요", "Tap the pill that receives “{v}”"],
|
||||
Empty: ["상자가 없음 — 「+ 입력」 으로 시작", "No boxes — start with “+ Input”"],
|
||||
NoIn: ["받는 알약이 없는 상자", "This box has no receiving pill"],
|
||||
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
|
||||
NotIn: ["받는 알약에 놓아야 이어짐", "Drop it on a receiving pill"],
|
||||
NoMatch: [
|
||||
"같은 이름 받는 알약 없음 — 알약에 바로 놓으세요",
|
||||
"No pill with that name — drop on a pill",
|
||||
],
|
||||
Taken: ["이미 이어진 알약 — 먼저 그 선을 끊으세요", "Pill already linked — disconnect first"],
|
||||
From: ["어디서", "From"],
|
||||
To: ["어디로", "To"],
|
||||
Value: ["지금 값", "Now"],
|
||||
PlusIn: [
|
||||
"받는 자리 — 내보내는 알약을 여기에 놓으면 받는 알약이 생김",
|
||||
"Drop an output pill here to add an input",
|
||||
],
|
||||
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
|
||||
Hint: [
|
||||
"상자 머리를 끌어 옮김 · 가운데 단추(또는 Space + 끌기)로 판 옮김 · 휠로 확대",
|
||||
"Drag a head to move · middle button (or Space + drag) to pan · wheel to zoom",
|
||||
],
|
||||
Break: ["끊긴 곳", "Break"],
|
||||
Back: ["온 길", "Came from"],
|
||||
Forward: ["가는 길", "Goes to"],
|
||||
} as const;
|
||||
|
||||
export const tt = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
|
||||
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
|
||||
|
||||
/** 종류별 기본 색(상자마다 바꾼 색이 먼저) */
|
||||
export const KIND_COLOR: Record<CfKind, string> = {
|
||||
input: "#2a6fb0",
|
||||
function: "#7a4fb5",
|
||||
output: "#c2610c",
|
||||
};
|
||||
|
||||
/* ── 맞출 모양 ──────────────────────────────────────────────────────── */
|
||||
|
||||
/** 고른 알약 */
|
||||
export interface DfPillSel {
|
||||
box: string;
|
||||
port: string;
|
||||
dir: "in" | "out";
|
||||
}
|
||||
|
||||
export interface DiagramOn {
|
||||
/** 상자 · 선 id · 없음 — 알약을 고르면 상자 id 와 알약 */
|
||||
select(id: string | null, pill?: DfPillSel): void;
|
||||
move(boxId: string, x: number, y: number): void;
|
||||
/** 잇기 — 막히면 까닭(쉬운 말) */
|
||||
link(from: CfPort, to: CfPort): string | null | void;
|
||||
unlink(linkId: string): void;
|
||||
add(kind: CfKind, x: number, y: number): void;
|
||||
remove(boxId: string): void;
|
||||
}
|
||||
|
||||
export interface DiagramOpts {
|
||||
/** 상자 id → 알약 이름 → 지금 값 */
|
||||
values?: Record<string, Record<string, unknown>>;
|
||||
/** 고른 상자 id · 선 id */
|
||||
selected?: string | null;
|
||||
/** 고른 알약 */
|
||||
selectedPill?: DfPillSel | null;
|
||||
/** 선 id → 선 규칙 글 — 없으면 선의 규칙(CfLink.rule)에서 · 규칙 없으면 이름표 없음 */
|
||||
labels?: Record<string, string>;
|
||||
/** 흐름 길 — Model traceBack · traceForward 걸음 */
|
||||
trace?: { back?: CfStep[]; forward?: CfStep[] } | null;
|
||||
/** 끊긴 곳 — Eval breaks */
|
||||
breaks?: CfBreak[];
|
||||
}
|
||||
|
||||
/* ── 알약 · 자리 ────────────────────────────────────────────────────── */
|
||||
|
||||
/** 알약 — Model 의 받는 · 내보내는 알약 */
|
||||
export function pillsOf(box: CfBox): { ins: CfPill[]; outs: CfPill[] } {
|
||||
return { ins: portsIn(box), outs: portsOut(box) };
|
||||
}
|
||||
|
||||
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 같은 이름 알약(받는 알약이 하나면 그것) · 못 정하면 까닭 */
|
||||
export function resolveDrop(
|
||||
lg: CfLogic,
|
||||
from: CfPort,
|
||||
target: { box: string; name: string | null; dir: "in" | "out" | null } | null,
|
||||
): { to: CfPort } | { why: string } {
|
||||
if (!target) return { why: tt("NotIn") };
|
||||
if (target.box === from.box) return { why: tt("SameBox") };
|
||||
if (target.dir === "out") return { why: tt("NotIn") };
|
||||
const box = lg.boxes.find((b) => b.id === target.box);
|
||||
// 함수 상자 몸통 · 「+」 받는 자리 — 보낸 알약 이름으로 받는 알약을 새로(Model 이 만들고 이름이 겹치면 「이름 (2)」)
|
||||
if (box?.kind === "function" && target.name === null)
|
||||
return { to: { box: box.id, name: from.name } };
|
||||
const ins = box ? pillsOf(box).ins : [];
|
||||
if (!box || !ins.length) return { why: tt("NoIn") };
|
||||
const name =
|
||||
target.name ??
|
||||
ins.find((p) => p.name === from.name)?.name ??
|
||||
(ins.length === 1 ? ins[0].name : null);
|
||||
if (!name) return { why: tt("NoMatch") };
|
||||
if (lg.links.some((l) => l.to.box === box.id && l.to.name === name)) return { why: tt("Taken") };
|
||||
return { to: { box: box.id, name } };
|
||||
}
|
||||
|
||||
/** 「줄 맞추기」 — 선을 따라 왼→오 차례(가장 긴 길) · 같은 차례는 위→아래 · 돌면 그대로 */
|
||||
export function arrange(
|
||||
lg: CfLogic,
|
||||
gap = { x: 260, y: 130 },
|
||||
): Record<string, { x: number; y: number }> {
|
||||
const depth = new Map<string, number>(lg.boxes.map((b) => [b.id, 0]));
|
||||
for (let round = 0; round < lg.boxes.length; round++) {
|
||||
let moved = false;
|
||||
for (const l of lg.links) {
|
||||
const d = (depth.get(l.from.box) ?? 0) + 1;
|
||||
if (depth.has(l.to.box) && d > (depth.get(l.to.box) ?? 0)) {
|
||||
depth.set(l.to.box, d);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
if (!moved) break;
|
||||
}
|
||||
const rows = new Map<number, number>();
|
||||
const out: Record<string, { x: number; y: number }> = {};
|
||||
for (const b of [...lg.boxes].sort((a, c) => a.y - c.y)) {
|
||||
const d = depth.get(b.id) ?? 0;
|
||||
const r = rows.get(d) ?? 0;
|
||||
rows.set(d, r + 1);
|
||||
out[b.id] = { x: 40 + d * gap.x, y: 40 + r * gap.y };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 저절로 맞출 때 가장 작은 확대 — 알약 글이 너무 작아지지 않게(더 줄이기는 축소 단추로) */
|
||||
export const FIT_MIN = 0.9;
|
||||
|
||||
export const clampZoom = (z: number): number => Math.min(2, Math.max(0.25, +z.toFixed(3)));
|
||||
|
||||
/** 모두 보이게 맞춤 — 상자 둘레 → 확대 · 판 자리 · FIT_MIN 아래로 안 내림(넘치면 왼쪽 위부터 보임) */
|
||||
export function fitView(
|
||||
rects: { x: number; y: number; w: number; h: number }[],
|
||||
view: { w: number; h: number },
|
||||
pad = 40,
|
||||
): { 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 place = (size: number, from: number, to: number): number =>
|
||||
Math.max(0, (size - (to - from) * zoom) / 2) - from * zoom;
|
||||
return { zoom, panX: place(view.w, x0, x1), panY: place(view.h, y0, y1) };
|
||||
}
|
||||
|
||||
/** 알약 값 글 — 수는 천 단위 쉼표 · 소수 셋째 자리까지(끝 0 지움) · 표는 「· ○줄」 · 글은 그대로 */
|
||||
export const showValue = (v: unknown): string => {
|
||||
if (v === null || v === undefined || v === "") return "";
|
||||
if (typeof v === "number")
|
||||
return Number.isFinite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: 3 }) : "";
|
||||
if (Array.isArray(v)) {
|
||||
const head = Array.isArray(v[0]) && v[0].every((c) => typeof c === "string") ? 1 : 0;
|
||||
return `· ${Math.max(0, v.length - head).toLocaleString("ko-KR")}줄`;
|
||||
}
|
||||
return String(v).slice(0, 12);
|
||||
};
|
||||
|
||||
/* ── 흐름 길 · 선 규칙 글 ───────────────────────────────────────────── */
|
||||
|
||||
/** 흐름 길 자리 — 온 길 · 가는 길 · 흐림(길이 있는데 안 듦) · 「」(길 없음) */
|
||||
export type TraceMark = "back" | "fwd" | "dim" | "";
|
||||
|
||||
export function traceOf(
|
||||
trace: DiagramOpts["trace"],
|
||||
pick: (steps: CfStep[]) => boolean,
|
||||
): TraceMark {
|
||||
if (!trace || (!trace.back?.length && !trace.forward?.length)) return "";
|
||||
if (trace.back && pick(trace.back)) return "back";
|
||||
if (trace.forward && pick(trace.forward)) return "fwd";
|
||||
return "dim";
|
||||
}
|
||||
|
||||
/** 선 위 규칙 글 — Rule 의 ruleLabel · 규칙 없으면 「」 */
|
||||
export const linkLabel = (link: CfLink): string => (link.rule ? ruleLabel(link.rule) : "");
|
||||
|
||||
/** 굽은 선 가운데 점 — 양 끝 손잡이가 마주 보는 베지어라 t = 0.5 가 두 끝의 가운데 */
|
||||
export const midOf = (
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): { x: number; y: number } => ({
|
||||
x: (a.x + b.x) / 2,
|
||||
y: (a.y + b.y) / 2,
|
||||
});
|
||||
@@ -18,17 +18,21 @@ import {
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { CfListItem, CfQuery } from "./M02_DF_Api";
|
||||
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
|
||||
import type { CfKind, CfLogic, CfPort } from "./M02_DF_Model";
|
||||
import type { CfListItem } from "./M02_DF_Api";
|
||||
import type { CfBreak, CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
|
||||
import type { CfKind, CfLogic, CfPort, CfStep } from "./M02_DF_Model";
|
||||
import type { CfSel } from "./M02_DF_Verify";
|
||||
import type { DfPillSel } from "./M02_DF_Diagram_Parts";
|
||||
import {
|
||||
detailFrame,
|
||||
drawHead,
|
||||
drawWarnings,
|
||||
lineBody,
|
||||
pillBody,
|
||||
fetchValues,
|
||||
loadLogics,
|
||||
queriesOf,
|
||||
unitChoices,
|
||||
type DfPick,
|
||||
} from "./M02_DF_Entry_Detail";
|
||||
import "./M02_DF_Entry.css";
|
||||
@@ -45,8 +49,10 @@ const loadMods = () =>
|
||||
import("./M02_DF_Summary"),
|
||||
import("./M02_DF_Sample"),
|
||||
import("./M02_DF_InputPanel"),
|
||||
import("./M02_DF_Rule"),
|
||||
]).then(([model, ev, api, diagram, func, output, summary, sample, input, rule]) => ({
|
||||
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]) => ({
|
||||
model,
|
||||
ev,
|
||||
api,
|
||||
@@ -56,7 +62,9 @@ const loadMods = () =>
|
||||
summary,
|
||||
sample,
|
||||
input,
|
||||
rule,
|
||||
pill,
|
||||
line,
|
||||
std,
|
||||
}));
|
||||
type Mods = Awaited<ReturnType<typeof loadMods>>;
|
||||
|
||||
@@ -81,6 +89,8 @@ const TEXT = {
|
||||
Diagram: ["다이어그램", "Diagram"],
|
||||
Summary: ["원가", "Cost"],
|
||||
Color: ["상자 색", "Box colour"],
|
||||
Standards: ["표준 표", "Standards"],
|
||||
Logics: ["다른 로직을 못 읽음 — {v}", "Couldn't read other logics — {v}"],
|
||||
NoName: ["로직 이름을 넣어 주세요", "Enter a logic name"],
|
||||
SaveAnyway: [
|
||||
"고칠 곳이 있음 — {v} · 그래도 저장할까요?",
|
||||
@@ -113,63 +123,6 @@ const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
|
||||
const muted = (text: string): HTMLElement => el("p", { className: "m02-df__muted", text });
|
||||
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
async function unitChoices(): Promise<string[]> {
|
||||
const count = new Map<string, number>();
|
||||
try {
|
||||
for (const x of await fetchLogics()) {
|
||||
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
|
||||
}
|
||||
} catch {
|
||||
/* 못 받으면 기본 하나 */
|
||||
}
|
||||
const units = [...count.entries()]
|
||||
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([u]) => u);
|
||||
return units.length ? units : ["원/㎡"];
|
||||
}
|
||||
|
||||
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
|
||||
function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
|
||||
return lg.boxes.flatMap((b) => {
|
||||
const inp = b.input;
|
||||
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
|
||||
const group = inp.group;
|
||||
const els = inp.elements
|
||||
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
|
||||
.map((e) => {
|
||||
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
|
||||
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
|
||||
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
|
||||
if (field) q.field = field;
|
||||
return { q, box: b.id, port: e.name };
|
||||
});
|
||||
const table = inp.table
|
||||
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
|
||||
: [];
|
||||
return [...els, ...table];
|
||||
});
|
||||
}
|
||||
|
||||
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
|
||||
async function fetchValues(M: Mods, lg: CfLogic): Promise<{ values: CfValues; errors: string[] }> {
|
||||
const asks = queriesOf(lg);
|
||||
if (!asks.length) return { values: {}, errors: [] };
|
||||
const answers = await M.api.cfValues(
|
||||
asks.map((a) => a.q),
|
||||
lg.userElements as never,
|
||||
);
|
||||
const values: CfValues = {};
|
||||
const errors: string[] = [];
|
||||
for (const a of asks) {
|
||||
const got = answers.find((x) => x.id === a.q.id);
|
||||
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
|
||||
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
|
||||
}
|
||||
return { values, errors };
|
||||
}
|
||||
|
||||
/** M01 단가산출 막대의 다른 시험판 단추 옆에 놓는 단추 — 누르면 시험판 페이지 */
|
||||
export function createDataFlowButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", size: "sm", onClick: openDataFlow });
|
||||
@@ -330,6 +283,10 @@ function openDataFlow(): void {
|
||||
const pickedBox = (): string | null => (pick?.kind === "box" ? pick.id : null);
|
||||
let values: CfValues = {};
|
||||
let valueSig = "";
|
||||
let logics: NonNullable<CfEvalOptions["logics"]> = {};
|
||||
let logicSig = "";
|
||||
let logicNotes: string[] = [];
|
||||
let broken: CfBreak[] = [];
|
||||
let notes: string[] = [];
|
||||
let result: CfEvalResult;
|
||||
let busy = false;
|
||||
@@ -355,15 +312,15 @@ function openDataFlow(): void {
|
||||
const outHost = el("div");
|
||||
const inHost = el("div");
|
||||
const summaryHost = el("div");
|
||||
const lineHost = el("div");
|
||||
const linkHost = el("div");
|
||||
const pillHost = el("div");
|
||||
const detail = detailFrame();
|
||||
detail.settings.append(colorRow);
|
||||
detail.body.append(inHost, funcHost, outHost, lineHost, pillHost);
|
||||
detail.body.append(inHost, funcHost, outHost, linkHost, pillHost);
|
||||
|
||||
const evalNow = (): CfEvalResult => {
|
||||
try {
|
||||
return M.ev.evalLogic(lg, values);
|
||||
return M.ev.evalLogic(lg, values, { logics });
|
||||
} catch (e) {
|
||||
const cost = { 노무비: 0, 재료비: 0, 경비: 0, 값들: {}, 계: 0 };
|
||||
return { ports: {}, outputs: {}, cost, errors: [M.api.cfPlain(e)] };
|
||||
@@ -387,7 +344,7 @@ function openDataFlow(): void {
|
||||
const sig = JSON.stringify(queriesOf(lg).map((a) => a.q));
|
||||
if (sig === valueSig) return;
|
||||
valueSig = sig;
|
||||
void fetchValues(M, lg).then(
|
||||
void fetchValues(M.api.cfValues, lg).then(
|
||||
(got) => {
|
||||
if (sig !== valueSig) return;
|
||||
values = got.values;
|
||||
@@ -401,6 +358,54 @@ function openDataFlow(): void {
|
||||
);
|
||||
};
|
||||
|
||||
/** 다른 로직 입력이 부르는 로직들 다시 읽기 — 부르는 열쇠가 바뀐 때만 */
|
||||
const refreshLogics = (): void => {
|
||||
const sig = JSON.stringify(lg.boxes.map((b) => b.input?.logic?.key ?? ""));
|
||||
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);
|
||||
void loadLogics(lg, get).then((got) => {
|
||||
if (sig !== logicSig) return;
|
||||
logics = got.logics;
|
||||
logicNotes = got.errors.length ? [te("Logics", { v: got.errors.join(" · ") })] : [];
|
||||
redraw();
|
||||
});
|
||||
};
|
||||
|
||||
/** 고름 — 검증 칸 줄 · 알약 판의 상자 이름 누름 */
|
||||
const choose = (sel: CfSel): void => {
|
||||
const box = lg.boxes.find((b) => b.id === sel.box);
|
||||
pick = sel.link
|
||||
? { kind: "link", id: sel.link }
|
||||
: box && sel.port
|
||||
? { kind: "pill", box: box.id, port: sel.port, dir: M.model.portDir(box, sel.port) }
|
||||
: box
|
||||
? { kind: "box", id: box.id }
|
||||
: null;
|
||||
redraw();
|
||||
};
|
||||
|
||||
/** 흐름 길 — 알약 = 그 알약에서 앞뒤 · 선 = 보낸 알약 앞 · 받는 알약 뒤 */
|
||||
const traceNow = (): { back: CfStep[]; forward: CfStep[] } | null => {
|
||||
try {
|
||||
if (pick?.kind === "pill") {
|
||||
const p = { box: pick.box, name: pick.port };
|
||||
return { back: M.model.traceBack(lg, p), forward: M.model.traceForward(lg, p) };
|
||||
}
|
||||
const id = pick?.kind === "link" ? pick.id : "";
|
||||
const link = lg.links.find((l) => l.id === id);
|
||||
if (link)
|
||||
return {
|
||||
back: M.model.traceBack(lg, link.from),
|
||||
forward: M.model.traceForward(lg, link.to),
|
||||
};
|
||||
} catch {
|
||||
/* 따라갈 수 없으면 칠하지 않음 */
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
@@ -417,18 +422,16 @@ function openDataFlow(): void {
|
||||
});
|
||||
|
||||
const diagramOn = {
|
||||
select: (id: string | null): void => {
|
||||
pick = !id
|
||||
? null
|
||||
: lg.links.some((l) => l.id === id)
|
||||
? { kind: "link", id }
|
||||
: { kind: "box", id };
|
||||
drawPanel();
|
||||
},
|
||||
/** 알약 고름 — 다이어그램(sub8)이 알려 주면 상세 내용이 알약 판으로 */
|
||||
pill: (p: { box: string; name: string; dir: "in" | "out" } | null): void => {
|
||||
pick = p ? { kind: "pill", ...p } : null;
|
||||
drawPanel();
|
||||
select: (id: string | null, pill?: DfPillSel): void => {
|
||||
pick = pill
|
||||
? { kind: "pill", ...pill }
|
||||
: !id
|
||||
? null
|
||||
: lg.links.some((l) => l.id === id)
|
||||
? { kind: "link", id }
|
||||
: { kind: "box", id };
|
||||
// 흐름 길 · 판을 새로 — 다이어그램이 제 그림을 그린 뒤
|
||||
setTimeout(redraw);
|
||||
},
|
||||
move: (id: string, x: number, y: number): void => edit(M.model.moveBox(lg, id, x, y)),
|
||||
link: (from: CfPort, to: CfPort): string | null => {
|
||||
@@ -455,35 +458,20 @@ function openDataFlow(): void {
|
||||
const box = lg.boxes.find((b) => b.id === pickedBox());
|
||||
drawHead(detail, lg, pick);
|
||||
colorRow.hidden = !box;
|
||||
lineHost.hidden = pick?.kind !== "link";
|
||||
linkHost.hidden = pick?.kind !== "link";
|
||||
pillHost.hidden = pick?.kind !== "pill";
|
||||
if (pick?.kind === "link") {
|
||||
const linkId = pick.id;
|
||||
const rule = lg.links.find((l) => l.id === linkId)?.rule;
|
||||
let text: string | null = null;
|
||||
try {
|
||||
text = rule ? M.rule.ruleLabel(rule) : null;
|
||||
} catch {
|
||||
text = rule ? rule.kind : null;
|
||||
}
|
||||
lineBody(lineHost, lg, pick.id, text);
|
||||
}
|
||||
if (pick?.kind === "pill") {
|
||||
const port = { box: pick.box, name: pick.name };
|
||||
pillBody(
|
||||
if (pick?.kind === "link")
|
||||
M.line.renderLinkPanel(linkHost, lg, pick.id, { change: edit }, { values, logics });
|
||||
if (pick?.kind === "pill")
|
||||
M.pill.renderPillPanel(
|
||||
pillHost,
|
||||
lg,
|
||||
() => M.model.traceBack(lg, port),
|
||||
() => M.model.traceForward(lg, port),
|
||||
pick.box,
|
||||
pick.port,
|
||||
{ pick: (id) => choose({ box: id }) },
|
||||
{ values, logics },
|
||||
);
|
||||
}
|
||||
let checks: string[] = [];
|
||||
try {
|
||||
checks = M.ev.checkLogic(lg, values);
|
||||
} catch {
|
||||
checks = [];
|
||||
}
|
||||
drawWarnings(detail, lg, pick, [...checks, ...(result?.errors ?? [])]);
|
||||
drawWarnings(detail, pick, broken);
|
||||
if (box && /^#[0-9a-f]{6}$/i.test(box.color)) colorBox.value = box.color;
|
||||
funcHost.hidden = box?.kind !== "function";
|
||||
outHost.hidden = box?.kind !== "output";
|
||||
@@ -497,23 +485,34 @@ function openDataFlow(): void {
|
||||
const redraw = (): void => {
|
||||
result = evalNow();
|
||||
refreshValues();
|
||||
refreshLogics();
|
||||
const linkId = pick?.kind === "link" ? pick.id : "";
|
||||
if (linkId && !lg.links.some((l) => l.id === linkId)) pick = null;
|
||||
try {
|
||||
broken = M.ev.breaks(lg, values, { logics });
|
||||
} catch {
|
||||
broken = result.breaks ?? [];
|
||||
}
|
||||
unsaved.hidden = lg === savedLg;
|
||||
undo.disabled = !stack.length;
|
||||
deleteButton.hidden = !lg.key;
|
||||
const selected =
|
||||
pick?.kind === "box" || pick?.kind === "link" ? pick.id : (pick?.box ?? null);
|
||||
M.diagram.renderDiagram(diagramHost, lg, diagramOn, { values: result.ports, selected });
|
||||
M.diagram.renderDiagram(diagramHost, lg, diagramOn, {
|
||||
values: result.ports,
|
||||
selected: pick?.kind === "box" || pick?.kind === "link" ? pick.id : null,
|
||||
selectedPill:
|
||||
pick?.kind === "pill" ? { box: pick.box, port: pick.port, dir: pick.dir } : null,
|
||||
trace: traceNow(),
|
||||
breaks: broken,
|
||||
});
|
||||
drawPanel();
|
||||
// 「고칠 것」 검사가 받은 입력 값으로 하도록 값도 넘김 — Summary 가 opts.values 를 받으면 쓰임
|
||||
// ponytail: 지금 Summary 는 checkLogic(lg) 을 값 없이 불러 「값이 아직 없음」 이 남음(sub5 고침 뒤 이 꼴 그대로 둠)
|
||||
(M.summary.renderSummary as (...args: unknown[]) => void)(
|
||||
M.summary.renderSummary(
|
||||
summaryHost,
|
||||
lg,
|
||||
result,
|
||||
{ change: edit },
|
||||
{ change: edit, select: choose },
|
||||
{ values },
|
||||
);
|
||||
note.textContent = [...notes, ...result.errors].slice(0, 4).join("\n");
|
||||
note.textContent = [...notes, ...logicNotes, ...result.errors].slice(0, 4).join("\n");
|
||||
};
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
@@ -524,7 +523,7 @@ function openDataFlow(): void {
|
||||
}
|
||||
let problems: string[] = [];
|
||||
try {
|
||||
problems = M.ev.checkLogic(lg, values);
|
||||
problems = M.ev.checkLogic(lg, values, { logics });
|
||||
} catch {
|
||||
problems = [];
|
||||
}
|
||||
@@ -588,6 +587,18 @@ function openDataFlow(): void {
|
||||
el("div", {
|
||||
className: "m02-df__actions",
|
||||
children: [
|
||||
createButton({
|
||||
label: te("Standards"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
openModal({
|
||||
title: te("Standards"),
|
||||
closeLabel: te("Close"),
|
||||
mount: (host: HTMLElement) =>
|
||||
M.std.renderStandards(host, { change: () => redraw() }),
|
||||
}),
|
||||
}),
|
||||
undo,
|
||||
createButton({ label: te("Save"), size: "sm", onClick: () => void save() }),
|
||||
deleteButton,
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
* M02_DF_Entry_Detail.ts
|
||||
* 단가산출 흐름도 — 「상세 내용」 칸 틀(정함 9): 머리(왼쪽 「상세 내용」 · 오른쪽 고른 상자 제목 · 알약이면 그 옆 알약 이름)
|
||||
* · 설정 · 본문 · 경고 차례. 상자 · 알약 · 선 무엇을 골라도 바뀜 — 본문은 Entry 가 종류별 판을 끼움.
|
||||
* 선 · 알약 본문은 규칙 판 · 알약 판(sub2)이 오기 전까지 이 파일이 흐름 줄(보내는 곳 → 받는 곳 · 출처 사슬 · 가는 곳)만 보임.
|
||||
* 경고 = 끊긴 곳 가운데 고른 것 몫 · 다른 로직 입력이 부르는 로직 읽기(겹쳐 부름) · 입력 값 물음 · 결과 단위 후보.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type { CfLogic, CfPort, CfStep } from "./M02_DF_Model";
|
||||
import { fetchLogics } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { CfAnswer, CfQuery } from "./M02_DF_Api";
|
||||
import type { CfBreak, CfValues } from "./M02_DF_Eval";
|
||||
import type { CfLogic } from "./M02_DF_Model";
|
||||
|
||||
const TEXT = {
|
||||
Detail: ["상세 내용", "Details"],
|
||||
@@ -20,14 +23,6 @@ const TEXT = {
|
||||
"Choose a box, pill or line in the diagram",
|
||||
],
|
||||
Line: ["선", "Line"],
|
||||
From: ["보내는 곳", "From"],
|
||||
To: ["받는 곳", "To"],
|
||||
Rule: ["선 규칙", "Line rule"],
|
||||
RuleNone: ["없음 — 값을 그대로 보냄", "None — sends the value as is"],
|
||||
Back: ["온 길(출처)", "Came from"],
|
||||
Forward: ["가는 길", "Goes to"],
|
||||
NoTrace: ["아직 따라갈 수 없음", "Can't trace yet"],
|
||||
Nowhere: ["이어진 곳 없음", "Not connected"],
|
||||
In: ["받는 알약", "Incoming pill"],
|
||||
Out: ["내보내는 알약", "Outgoing pill"],
|
||||
} as const;
|
||||
@@ -39,7 +34,7 @@ const tt = (key: keyof typeof TEXT): string =>
|
||||
export type DfPick =
|
||||
| { kind: "box"; id: string }
|
||||
| { kind: "link"; id: string }
|
||||
| { kind: "pill"; box: string; name: string; dir: "in" | "out" }
|
||||
| { kind: "pill"; box: string; port: string; dir: "in" | "out" }
|
||||
| null;
|
||||
|
||||
export interface DetailParts {
|
||||
@@ -91,7 +86,6 @@ export function detailFrame(): DetailParts {
|
||||
}
|
||||
|
||||
const boxName = (lg: CfLogic, id: string): string => lg.boxes.find((b) => b.id === id)?.name ?? id;
|
||||
const portText = (lg: CfLogic, p: CfPort): string => `${boxName(lg, p.box)} · ${p.name}`;
|
||||
|
||||
/** 머리 · 빈 자리 — 고른 것에 맞춤 · 설정 · 경고 줄은 Entry 가 채움 */
|
||||
export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void {
|
||||
@@ -107,77 +101,113 @@ export function drawHead(d: DetailParts, lg: CfLogic, pick: DfPick): void {
|
||||
? `${tt("Line")} · ${boxName(lg, link.from.box)} → ${boxName(lg, link.to.box)}`
|
||||
: "";
|
||||
d.pill.textContent =
|
||||
pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.name}` : "";
|
||||
pick?.kind === "pill" ? `${tt(pick.dir === "in" ? "In" : "Out")} · ${pick.port}` : "";
|
||||
d.pill.hidden = pick?.kind !== "pill";
|
||||
}
|
||||
|
||||
/** 경고 — 화면 계산 · 검사 글 가운데 고른 것(상자 이름 · 알약 이름)이 든 줄만 */
|
||||
export function drawWarnings(d: DetailParts, lg: CfLogic, pick: DfPick, all: string[]): void {
|
||||
const names: string[] = [];
|
||||
if (pick?.kind === "box") names.push(`「${boxName(lg, pick.id)}」`);
|
||||
if (pick?.kind === "pill") names.push(`「${boxName(lg, pick.box)}」`);
|
||||
if (pick?.kind === "link") {
|
||||
const l = lg.links.find((x) => x.id === pick.id);
|
||||
if (l) names.push(`「${boxName(lg, l.from.box)}」`, `「${boxName(lg, l.to.box)}」`);
|
||||
}
|
||||
const mine = [...new Set(all)].filter(
|
||||
(w) => names.some((n) => w.includes(n)) && (pick?.kind !== "pill" || w.includes(pick.name)),
|
||||
/** 경고 — 끊긴 곳(Eval breaks) 가운데 고른 것 몫만 · 상자 = 그 상자 · 알약 = 그 알약 · 선 = 그 선 */
|
||||
export function drawWarnings(d: DetailParts, pick: DfPick, all: CfBreak[]): void {
|
||||
const mine = all.filter((k) =>
|
||||
pick?.kind === "box"
|
||||
? k.box === pick.id
|
||||
: pick?.kind === "pill"
|
||||
? k.box === pick.box && k.port === pick.port && !k.link
|
||||
: pick?.kind === "link"
|
||||
? k.link === pick.id
|
||||
: false,
|
||||
);
|
||||
d.warn.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map((w) => el("li", { text: w }))
|
||||
? [...new Set(mine.map((k) => k.text))].map((w) => el("li", { text: w }))
|
||||
: [el("li", { className: "m02-df__muted", text: tt("WarnNone") })]),
|
||||
);
|
||||
}
|
||||
|
||||
/** 선 본문 — 보내는 곳 → 받는 곳 · 선 규칙 글(규칙 판이 오면 그쪽이 그림) */
|
||||
export function lineBody(
|
||||
host: HTMLElement,
|
||||
/** 다른 로직 입력이 부르는 로직들 — 그 로직이 또 부르는 것까지(겹쳐 부름 · 한 번씩) · 못 읽은 것은 빼고 까닭 */
|
||||
export async function loadLogics(
|
||||
lg: CfLogic,
|
||||
id: string,
|
||||
ruleText: string | null,
|
||||
): void {
|
||||
const l = lg.links.find((x) => x.id === id);
|
||||
if (!l) return void host.replaceChildren();
|
||||
host.replaceChildren(
|
||||
el("dl", {
|
||||
className: "m02-df__detail-dl",
|
||||
children: [
|
||||
el("dt", { text: tt("From") }),
|
||||
el("dd", { text: portText(lg, l.from) }),
|
||||
el("dt", { text: tt("To") }),
|
||||
el("dd", { text: portText(lg, l.to) }),
|
||||
el("dt", { text: tt("Rule") }),
|
||||
el("dd", { text: ruleText ?? tt("RuleNone") }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
get: (key: string) => Promise<CfLogic | null>,
|
||||
): Promise<{ logics: Record<string, CfLogic>; errors: string[] }> {
|
||||
const logics: Record<string, CfLogic> = {};
|
||||
const errors: string[] = [];
|
||||
const keysOf = (x: CfLogic): string[] =>
|
||||
x.boxes.flatMap((b) =>
|
||||
b.input?.mode === "logic" && b.input.logic?.key ? [b.input.logic.key] : [],
|
||||
);
|
||||
const seen = new Set<string>(lg.key ? [lg.key] : []);
|
||||
let todo = keysOf(lg);
|
||||
while (todo.length) {
|
||||
const key = todo.shift()!;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
try {
|
||||
const got = await get(key);
|
||||
if (!got) throw new Error(key);
|
||||
logics[key] = got;
|
||||
todo = [...todo, ...keysOf(got)];
|
||||
} catch (e) {
|
||||
errors.push(`${key} — ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
return { logics, errors };
|
||||
}
|
||||
|
||||
/** 알약 본문 — 온 길(출처 사슬) · 가는 길 · 흐름 따라가기(Model traceBack · traceForward)가 아직이면 한 줄 */
|
||||
export function pillBody(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
back: () => CfStep[],
|
||||
forward: () => CfStep[],
|
||||
): void {
|
||||
const list = (steps: () => CfStep[]): HTMLElement => {
|
||||
let got: CfStep[];
|
||||
try {
|
||||
got = steps();
|
||||
} catch {
|
||||
return el("p", { className: "m02-df__muted", text: tt("NoTrace") });
|
||||
/** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */
|
||||
export async function unitChoices(): Promise<string[]> {
|
||||
const count = new Map<string, number>();
|
||||
try {
|
||||
for (const x of await fetchLogics()) {
|
||||
if (x.결과단위.startsWith("원/")) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1);
|
||||
}
|
||||
if (!got.length) return el("p", { className: "m02-df__muted", text: tt("Nowhere") });
|
||||
return el("ol", {
|
||||
className: "m02-df__detail-trace",
|
||||
children: got.map((s) => el("li", { text: portText(lg, { box: s.box, name: s.port }) })),
|
||||
});
|
||||
};
|
||||
host.replaceChildren(
|
||||
el("h5", { text: tt("Back") }),
|
||||
list(back),
|
||||
el("h5", { text: tt("Forward") }),
|
||||
list(forward),
|
||||
} catch {
|
||||
/* 못 받으면 기본 하나 */
|
||||
}
|
||||
const units = [...count.entries()]
|
||||
.filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([u]) => u);
|
||||
return units.length ? units : ["원/㎡"];
|
||||
}
|
||||
|
||||
/** 입력 값 물음 — 그룹 입력 상자의 요소(알약 = 요소 이름) · 표(알약 「표」) · 사용자 요소 · 변수는 Eval 이 스스로 봄 */
|
||||
export function queriesOf(lg: CfLogic): { q: CfQuery; box: string; port: string }[] {
|
||||
return lg.boxes.flatMap((b) => {
|
||||
const inp = b.input;
|
||||
if (b.kind !== "input" || !inp || inp.mode !== "group" || !inp.group) return [];
|
||||
const group = inp.group;
|
||||
const els = inp.elements
|
||||
.filter((e) => e.ref && !e.ref.startsWith("UE-"))
|
||||
.map((e) => {
|
||||
// 기계 요소는 「키#값 칸」 — 서버에는 키와 칸(field)을 따로 넘김
|
||||
const [ref, field] = group === "기계" ? e.ref.split("#") : [e.ref, undefined];
|
||||
const q: CfQuery = { id: `${b.id}\u0001${e.name}`, group, ref, unit: e.unit };
|
||||
if (field) q.field = field;
|
||||
return { q, box: b.id, port: e.name };
|
||||
});
|
||||
const table = inp.table
|
||||
? [{ q: { id: `${b.id}\u0001표`, group, table: inp.table }, box: b.id, port: "표" }]
|
||||
: [];
|
||||
return [...els, ...table];
|
||||
});
|
||||
}
|
||||
|
||||
/** 입력 값 받기 — 받은 것만 묶고 못 받은 것은 쉬운 말 */
|
||||
export async function fetchValues(
|
||||
ask: (qs: CfQuery[], els: never) => Promise<CfAnswer[]>,
|
||||
lg: CfLogic,
|
||||
): Promise<{ values: CfValues; errors: string[] }> {
|
||||
const asks = queriesOf(lg);
|
||||
if (!asks.length) return { values: {}, errors: [] };
|
||||
const answers = await ask(
|
||||
asks.map((a) => a.q),
|
||||
lg.userElements as never,
|
||||
);
|
||||
const values: CfValues = {};
|
||||
const errors: string[] = [];
|
||||
for (const a of asks) {
|
||||
const got = answers.find((x) => x.id === a.q.id);
|
||||
if (!got?.ok) errors.push(`${a.port}${got?.reason ? ` — ${got.reason}` : ""}`);
|
||||
else (values[a.box] ??= {})[a.port] = got.rows ?? got.value ?? null;
|
||||
}
|
||||
return { values, errors };
|
||||
}
|
||||
|
||||
@@ -347,7 +347,8 @@ function otherLogic(
|
||||
}
|
||||
const inner: CfValues = {};
|
||||
for (const d of designInputs(other)) {
|
||||
const v = d.port in arrive ? arrive[d.port] : given[d.port];
|
||||
// 값 차례 = 이어진 선 > 넘긴 값 > 상자에 둔 예시값 > 그 로직 자기 예시값(inner 에 안 넣음)
|
||||
const v = d.port in arrive ? arrive[d.port] : (given[d.port] ?? ref?.examples?.[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 });
|
||||
|
||||
@@ -143,3 +143,9 @@
|
||||
max-height: 24vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.m02-dfip__unitfield {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
type M01Row,
|
||||
} from "./M02_DF_InputPanel_Build";
|
||||
import { renderLogicInput } from "./M02_DF_InputPanel_Logic";
|
||||
import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick";
|
||||
import { renderMaterial } from "./M02_DF_InputPanel_Material";
|
||||
import { renderTablePick } from "./M02_DF_InputPanel_Table";
|
||||
import { renderVariables } from "./M02_DF_InputPanel_Vars";
|
||||
@@ -417,7 +418,18 @@ function newForm(
|
||||
): HTMLElement {
|
||||
const name = createInputField({ type: "text", label: t("NewName"), compact: true });
|
||||
const spec = createInputField({ type: "text", label: t("NewSpec"), compact: true });
|
||||
const unit = createInputField({ type: "text", label: t("NewUnit"), compact: true });
|
||||
let unitValue = "";
|
||||
const unit = el("div", { className: "m02-dfip__unitfield" });
|
||||
const drawUnit = (): void =>
|
||||
unit.replaceChildren(
|
||||
el("span", { className: "m02-dfip__muted", text: t("NewUnit") }),
|
||||
createUnitPick({
|
||||
value: unitValue || NO_UNIT,
|
||||
ariaLabel: t("NewUnit"),
|
||||
onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()),
|
||||
}),
|
||||
);
|
||||
drawUnit();
|
||||
const value = createInputField({ type: "text", label: t("NewValue"), compact: true });
|
||||
const go = createButton({
|
||||
label: t("Make"),
|
||||
@@ -432,7 +444,7 @@ function newForm(
|
||||
group,
|
||||
name: name.input.value.trim(),
|
||||
spec: spec.input.value.trim(),
|
||||
unit: unit.input.value.trim(),
|
||||
unit: unitValue,
|
||||
value: n,
|
||||
}).then(
|
||||
(made) => {
|
||||
@@ -455,14 +467,7 @@ function newForm(
|
||||
});
|
||||
const d = el("details", {
|
||||
className: "m02-dfip__new",
|
||||
children: [
|
||||
el("summary", { text: t("NewHead") }),
|
||||
name.root,
|
||||
spec.root,
|
||||
unit.root,
|
||||
value.root,
|
||||
go,
|
||||
],
|
||||
children: [el("summary", { text: t("NewHead") }), name.root, spec.root, unit, value.root, go],
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -227,7 +227,11 @@ export function checkMaterial(el: CfElementRef, others: CfElementRef[]): string
|
||||
if (!el.name) return "자리 이름을 적어 주세요";
|
||||
if (others.some((o) => o.name === el.name)) return "같은 이름의 알약이 이미 있음";
|
||||
if (el.mode === "rule" && !el.rule?.from.name) return "규칙에 쓸 변수 알약을 골라 주세요";
|
||||
if (el.mode !== "fixed" && !Object.keys(el.scope ?? {}).length)
|
||||
if (
|
||||
el.mode !== "fixed" &&
|
||||
!(el.mode === "choice" && el.default) &&
|
||||
!Object.keys(el.scope ?? {}).length
|
||||
)
|
||||
return "범위(구분 · 상세구분 · 찾는 말) 하나는 정해 주세요";
|
||||
return null;
|
||||
}
|
||||
@@ -255,7 +259,7 @@ export function logicRefOf(key: string, other: CfLogic): CfLogicRef {
|
||||
};
|
||||
}
|
||||
|
||||
/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 그 알약에서 나간 선도 지움 */
|
||||
/** 다른 로직 입력 넣기 · 빼기(null) — 빼면 변수 상태로 돌아가고 이 상자의 알약에 닿은 선은 모두 지움 */
|
||||
export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null): CfLogic {
|
||||
return {
|
||||
...lg,
|
||||
@@ -266,7 +270,7 @@ export function setLogicInput(lg: CfLogic, id: string, ref: CfLogicRef | null):
|
||||
: { mode: "variable", elements: [], variables: [] };
|
||||
return { ...b, input, unit: unitOfInput(input) };
|
||||
}),
|
||||
links: lg.links.filter((l) => l.from.box !== id),
|
||||
links: lg.links.filter((l) => l.from.box !== id && l.to.box !== id), // 나가던 선 · 그 로직의 받는 알약으로 들어오던 선 모두
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -291,7 +291,7 @@ function itemList(
|
||||
export async function previewOf(
|
||||
el1: CfElementRef,
|
||||
lg: CfLogic,
|
||||
): Promise<{ found: number; names: string[]; cheapest: MatItem | null }> {
|
||||
): Promise<{ found: number; names: string[]; cheapest: MatItem | null; unit: string }> {
|
||||
const scope = el1.scope ?? {};
|
||||
const word = scope.찾는말 ?? "";
|
||||
if (el1.mode === "rule" && el1.rule) {
|
||||
@@ -300,7 +300,7 @@ export async function previewOf(
|
||||
);
|
||||
const value = pill?.example;
|
||||
if (value === null || value === undefined || value === "")
|
||||
return { found: 0, names: [], cheapest: null };
|
||||
return { found: 0, names: [], cheapest: null, unit: "" };
|
||||
const r = await searchMaterials({
|
||||
q: `${word} ${value}`.trim(),
|
||||
sub: scope.구분,
|
||||
@@ -313,6 +313,7 @@ export async function previewOf(
|
||||
found: hit.length,
|
||||
names: hit.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
||||
cheapest: cheapestOf(hit),
|
||||
unit: commonUnit(hit),
|
||||
};
|
||||
}
|
||||
const r = await searchMaterials({
|
||||
@@ -326,9 +327,14 @@ export async function previewOf(
|
||||
found: r.total,
|
||||
names: r.items.slice(0, 3).map((i) => `${i.name} ${i.spec}`.trim()),
|
||||
cheapest: cheapestOf(r.items),
|
||||
unit: commonUnit(r.items),
|
||||
};
|
||||
}
|
||||
|
||||
/** 잡힌 품목 단위가 모두 같으면 그 단위 · 아니면 빈 글 */
|
||||
const commonUnit = (items: MatItem[]): string =>
|
||||
items.length && items.every((i) => i.unit === items[0].unit) ? items[0].unit : "";
|
||||
|
||||
const cheapestOf = (items: MatItem[]): MatItem | null =>
|
||||
items
|
||||
.filter((i) => i.price !== null)
|
||||
@@ -541,7 +547,11 @@ export function renderMaterial(
|
||||
const why = checkMaterial(cand, els);
|
||||
name.setError(why ?? undefined);
|
||||
if (why) return void showToast(why, "error");
|
||||
on.change(addEl(lg, cand));
|
||||
// 잡히는 재료의 단위가 하나로 같으면 그 단위를 알약 단위로
|
||||
void previewOf(cand, lg).then(
|
||||
(p) => on.change(addEl(lg, p.unit ? { ...cand, unit: p.unit } : cand)),
|
||||
() => on.change(addEl(lg, cand)),
|
||||
);
|
||||
},
|
||||
});
|
||||
const clear = createButton({
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import type { CfLogic, CfVariable } from "./M02_DF_Model";
|
||||
import { NO_UNIT, createUnitPick } from "./M02_DF_UnitPick";
|
||||
import { checkVariable, putVariable, removeVariable } from "./M02_DF_InputPanel_Build";
|
||||
|
||||
const TEXT = {
|
||||
@@ -84,13 +85,19 @@ export function renderVariables(
|
||||
compact: true,
|
||||
onChange: () => paint(),
|
||||
});
|
||||
const unit = createInputField({
|
||||
type: "text",
|
||||
label: t("Unit"),
|
||||
value: cur?.unit ?? "",
|
||||
placeholder: "예: ㎡",
|
||||
compact: true,
|
||||
});
|
||||
// 단위 = 고르기만(표준 단위 목록) — 손글 안 받음
|
||||
let unitValue = cur?.unit ?? "";
|
||||
const unit = el("div", { className: "m02-dfip__unitfield" });
|
||||
const drawUnit = (): void =>
|
||||
unit.replaceChildren(
|
||||
el("span", { className: "m02-dfip__muted", text: t("Unit") }),
|
||||
createUnitPick({
|
||||
value: unitValue || NO_UNIT,
|
||||
ariaLabel: t("Unit"),
|
||||
onPick: (u) => ((unitValue = u === NO_UNIT ? "" : u), drawUnit()),
|
||||
}),
|
||||
);
|
||||
drawUnit();
|
||||
const lo = createInputField({
|
||||
type: "text",
|
||||
label: t("Min"),
|
||||
@@ -129,7 +136,7 @@ export function renderVariables(
|
||||
const v: CfVariable = {
|
||||
name: name.input.value.trim(),
|
||||
type: ty,
|
||||
unit: unit.input.value.trim(),
|
||||
unit: unitValue,
|
||||
example: ex === "" ? null : ty === "number" ? (numOf(ex) ?? ex) : ex,
|
||||
...(ty === "number" && numOf(lo.input.value) !== null && numOf(hi.input.value) !== null
|
||||
? { range: [Number(lo.input.value), Number(hi.input.value)] as [number, number] }
|
||||
@@ -164,7 +171,7 @@ export function renderVariables(
|
||||
children: [
|
||||
name.root,
|
||||
type.root,
|
||||
unit.root,
|
||||
unit,
|
||||
extra,
|
||||
example.root,
|
||||
el("div", { className: "m02-dfip__chips", children: cancel ? [ok, cancel] : [ok] }),
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
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,
|
||||
@@ -22,12 +21,13 @@ import {
|
||||
GATE_OPS,
|
||||
SCALE_OPS,
|
||||
evalSafe,
|
||||
gateReport,
|
||||
labelOf,
|
||||
linkNotes,
|
||||
portText,
|
||||
preview,
|
||||
unitOf,
|
||||
valueOf,
|
||||
type DetailOptions,
|
||||
} from "./M02_DF_Detail";
|
||||
import { suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import "./M02_DF_LinkPanel.css";
|
||||
@@ -317,12 +317,12 @@ export function renderLinkPanel(
|
||||
lg: CfLogic,
|
||||
linkId: string,
|
||||
on: { change(lg: CfLogic): void },
|
||||
opts: { values?: CfValues; readOnly?: boolean } = {},
|
||||
opts: DetailOptions & { 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 ev = evalSafe(lg, opts);
|
||||
const table = valueOf(ev, link.from);
|
||||
const headers =
|
||||
Array.isArray(table) && Array.isArray(table[0])
|
||||
@@ -358,7 +358,7 @@ export function renderLinkPanel(
|
||||
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);
|
||||
const pv = preview(link, ev);
|
||||
parts.push(
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Before") }),
|
||||
@@ -369,16 +369,11 @@ export function renderLinkPanel(
|
||||
),
|
||||
row(
|
||||
el("span", { className: "m02-dflink-label", text: tk("Label") }),
|
||||
el("span", { text: labelOf(lg, rule) }),
|
||||
el("span", { text: labelOf(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));
|
||||
}
|
||||
const warns: string[] = linkNotes(link, ev);
|
||||
if (rule?.kind === "lookup") {
|
||||
if (!rule.conds.length) warns.push(tk("WarnLookup"));
|
||||
if (!rule.col) warns.push(tk("WarnTake"));
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_Material.ts
|
||||
* 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials?q&sub&detail&page&size`
|
||||
* → {total, page, size, items}(아직 없으면 M01 재료 찾기로 대신) · 단가는 `cfValues`(재료 ref = 키)
|
||||
* 검증 칸이 쓰는 재료 찾기 · 이름 · 단가 — 서버 `GET /api/m02/df/materials`(Api `cfMaterials` ·
|
||||
* 띄어쓰기로 나눈 말을 모두 포함 · 쪽 넘기기) · 단가 = 그 줄의 단가(값을 받는 길과 같은 셈)
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 5. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { searchElements } from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import { cfValues } from "./M02_DF_Api";
|
||||
import { cfMaterials, type CfMaterial } from "./M02_DF_Api";
|
||||
|
||||
export interface CfMat {
|
||||
ref: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
}
|
||||
export type CfMat = CfMaterial;
|
||||
|
||||
export interface CfMatPage {
|
||||
total: number;
|
||||
@@ -23,99 +16,34 @@ export interface CfMatPage {
|
||||
items: CfMat[];
|
||||
}
|
||||
|
||||
const text = (v: unknown): string =>
|
||||
typeof v === "string" || typeof v === "number" ? String(v) : "";
|
||||
|
||||
const norm = (it: Record<string, unknown>): CfMat => ({
|
||||
ref: text(it.ref ?? it.키),
|
||||
name: text(it.이름 ?? it.name),
|
||||
spec: text(it.규격 ?? it.spec),
|
||||
unit: text(it.단위 ?? it.unit),
|
||||
});
|
||||
|
||||
/** 재료 찾기 — q = 찾는 말 · sub = 구분 · detail = 상세구분 · page 는 1 부터 */
|
||||
export async function findMaterials(cond: {
|
||||
export function findMaterials(cond: {
|
||||
q?: string;
|
||||
sub?: string;
|
||||
detail?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}): Promise<CfMatPage> {
|
||||
const page = cond.page ?? 1;
|
||||
const size = cond.size ?? 10;
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
q: cond.q ?? "",
|
||||
sub: cond.sub ?? "",
|
||||
detail: cond.detail ?? "",
|
||||
page: String(page),
|
||||
size: String(size),
|
||||
});
|
||||
const r = await fetch(`${API_BASE_URL}/m02/df/materials?${qs.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (r.ok) {
|
||||
const d = (await r.json()) as {
|
||||
total: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
items: Record<string, unknown>[];
|
||||
};
|
||||
return {
|
||||
total: d.total,
|
||||
page: d.page ?? page,
|
||||
size: d.size ?? size,
|
||||
items: d.items.map(norm),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* 서버 길이 아직 없으면 M01 재료 찾기로 */
|
||||
}
|
||||
const got = await searchElements("재료", cond.q ?? "");
|
||||
const all = got.items.filter((it) => {
|
||||
const x = it as unknown as Record<string, unknown>;
|
||||
return (!cond.sub || x.구분 === cond.sub) && (!cond.detail || x.상세구분 === cond.detail);
|
||||
});
|
||||
const from = (page - 1) * size;
|
||||
return {
|
||||
total: all.length,
|
||||
page,
|
||||
size,
|
||||
items: all.slice(from, from + size).map((it) => norm(it as unknown as Record<string, unknown>)),
|
||||
};
|
||||
return cfMaterials({ ...cond, page: cond.page ?? 1, size: cond.size ?? 10 });
|
||||
}
|
||||
|
||||
const names = new Map<string, CfMat | null>();
|
||||
const byKey = new Map<string, CfMat | null>();
|
||||
|
||||
/** 재료 키 → 이름 · 규격 (한 번 물은 것은 다시 안 물음) */
|
||||
/** 재료 키 → 그 줄(이름 · 규격 · 단가) — 한 번 물은 것은 다시 안 물음 */
|
||||
export async function materialOf(key: string): Promise<CfMat | null> {
|
||||
if (!key) return null;
|
||||
if (names.has(key)) return names.get(key) ?? null;
|
||||
if (byKey.has(key)) return byKey.get(key) ?? null;
|
||||
let hit: CfMat | null = null;
|
||||
try {
|
||||
const got = await searchElements("재료", key);
|
||||
const it = got.items.find((x) => x.ref === key);
|
||||
hit = it ? norm(it as unknown as Record<string, unknown>) : null;
|
||||
hit = (await cfMaterials({ q: key, size: 20 })).items.find((m) => m.ref === key) ?? null;
|
||||
} catch {
|
||||
hit = null;
|
||||
}
|
||||
names.set(key, hit);
|
||||
byKey.set(key, hit);
|
||||
return hit;
|
||||
}
|
||||
|
||||
const prices = new Map<string, number | null>();
|
||||
|
||||
/** 재료 키 → 단가(원) — 못 구하면 null */
|
||||
export async function priceOf(key: string): Promise<number | null> {
|
||||
if (!key) return null;
|
||||
if (prices.has(key)) return prices.get(key) ?? null;
|
||||
let v: number | null = null;
|
||||
try {
|
||||
const a = (await cfValues([{ id: "p", group: "재료", ref: key }]))[0];
|
||||
v = a?.ok && typeof a.value === "number" ? a.value : null;
|
||||
} catch {
|
||||
v = null;
|
||||
}
|
||||
prices.set(key, v);
|
||||
return v;
|
||||
return (await materialOf(key))?.price ?? null;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface CfLogicRef {
|
||||
outputs: CfPill[];
|
||||
/** 받는 알약 = 그 로직의 「설계 때 정할 것」(변수 · 재료 자리) */
|
||||
designs: CfPill[];
|
||||
/** 선 없는 받는 알약의 예시값(키 = 받는 알약 이름) — 값 차례 = 이어진 선 > 넘긴 값 > 이것 > 그 로직 자기 예시값 */
|
||||
examples?: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export interface CfInput {
|
||||
@@ -154,6 +156,8 @@ export interface CfDesign {
|
||||
unit: string;
|
||||
variable?: CfVariable;
|
||||
element?: CfElementRef;
|
||||
/** 보일 예시값 — 변수 = variable.example · 다른 로직 = logic.examples(없으면 빈 칸 → 그 로직 자기 예시값) */
|
||||
example?: number | string | null;
|
||||
}
|
||||
|
||||
/** 사용자 요소 — 그룹에 없는 새 요소(마스터에 안 씀 · `_data` 에 저장) */
|
||||
@@ -567,7 +571,14 @@ export function designInputs(lg: CfLogic): 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) });
|
||||
out.push({
|
||||
box: b.id,
|
||||
port: v.name,
|
||||
kind: "변수",
|
||||
unit: v.unit || "-",
|
||||
variable: clone(v),
|
||||
example: v.example,
|
||||
});
|
||||
for (const e of b.input?.mode === "group" ? b.input.elements : [])
|
||||
if (e.mode === "choice")
|
||||
out.push({
|
||||
@@ -580,7 +591,13 @@ export function designInputs(lg: CfLogic): CfDesign[] {
|
||||
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 || "-" });
|
||||
out.push({
|
||||
box: b.id,
|
||||
port: d.name,
|
||||
kind: "다른 로직",
|
||||
unit: d.unit || "-",
|
||||
example: b.input.logic?.examples?.[d.name] ?? null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"
|
||||
import { evalLogic, type CfEvalResult, type CfValue } from "./M02_DF_Eval";
|
||||
import { portsOut, type CfBox, type CfCost, type CfLogic, type CfRound } from "./M02_DF_Model";
|
||||
import { defaultItemRound, roundText } from "./M02_DF_Round";
|
||||
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import "./M02_DF_OutputPanel.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -22,7 +23,6 @@ const TEXT = {
|
||||
Expense: ["경비", "Expense"],
|
||||
Plain: ["값(비용 아님)", "Value (not a cost)"],
|
||||
Unit: ["단위", "Unit"],
|
||||
UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"],
|
||||
Got: ["받은 값", "Received value"],
|
||||
From: ["어디서", "From"],
|
||||
Value: ["값", "Value"],
|
||||
@@ -39,6 +39,8 @@ const TEXT = {
|
||||
RoundDefault: ["끝수 기본", "Default rounding"],
|
||||
RoundStd: ["일위대가표 금액란", "unit-price sheet amount column"],
|
||||
RoundNone: ["끝수 없음(값 그대로)", "No rounding (value as is)"],
|
||||
Suggest: ["단위 표준 제안", "Unit standard suggests"],
|
||||
Apply: ["적용", "Apply"],
|
||||
RoundReset: ["기본으로", "Back to default"],
|
||||
RoundUser: ["고른 끝수", "Chosen rounding"],
|
||||
PlainHint: [
|
||||
@@ -165,13 +167,7 @@ export function renderOutputPanel(
|
||||
})),
|
||||
),
|
||||
});
|
||||
const unitBox = el("input", {
|
||||
className: "m02-dfout__input",
|
||||
attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") },
|
||||
});
|
||||
unitBox.value = box.output?.unit ?? box.unit;
|
||||
unitBox.addEventListener("change", () => {
|
||||
const next = unitBox.value.trim() || "-";
|
||||
const setUnit = (next: string): void =>
|
||||
emit(() =>
|
||||
withBox(lg, box.id, (b) => ({
|
||||
...b,
|
||||
@@ -179,7 +175,27 @@ export function renderOutputPanel(
|
||||
output: { ...b.output, cost: b.output?.cost ?? "노무비", unit: next },
|
||||
})),
|
||||
);
|
||||
const unitBox = createUnitPick({
|
||||
value: box.output?.unit ?? box.unit,
|
||||
ariaLabel: to("Unit"),
|
||||
extra: lg.resultUnit.includes("/") ? [lg.resultUnit.split("/")[0]] : [],
|
||||
onPick: setUnit,
|
||||
});
|
||||
/** 단위 표준의 소수자리 제안(주 ① 반올림) — 값(비용 아님)이고 끝수를 안 정했을 때 */
|
||||
const suggestNote = el("span", { className: "m02-dfout__muted" });
|
||||
if (cost === "값" && !box.output?.round)
|
||||
void suggestPlaces(box.output?.unit ?? box.unit).then((p) => {
|
||||
if (p === null) return;
|
||||
suggestNote.textContent = `${to("Suggest")}: ${to("RoundPlaces")} ${p} · 반올림`;
|
||||
suggestNote.append(
|
||||
createButton({
|
||||
label: to("Apply"),
|
||||
size: "sm",
|
||||
variant: "ghost",
|
||||
onClick: () => setRound({ places: p, method: "반올림" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
const field = (label: string, control: HTMLElement): HTMLElement =>
|
||||
el("label", {
|
||||
className: "m02-dfout__field",
|
||||
@@ -253,6 +269,7 @@ export function renderOutputPanel(
|
||||
],
|
||||
}),
|
||||
roundNote,
|
||||
suggestNote,
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
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,
|
||||
@@ -20,6 +19,7 @@ import {
|
||||
trace,
|
||||
unitOf,
|
||||
valueOf,
|
||||
type DetailOptions,
|
||||
} from "./M02_DF_Detail";
|
||||
import "./M02_DF_LinkPanel.css";
|
||||
|
||||
@@ -50,6 +50,7 @@ const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement =>
|
||||
function steps(
|
||||
lg: CfLogic,
|
||||
list: CfStep[],
|
||||
arrow: string,
|
||||
empty: string,
|
||||
onBox?: (id: string) => void,
|
||||
): HTMLElement {
|
||||
@@ -58,7 +59,7 @@ function steps(
|
||||
className: "m02-dfpill-list",
|
||||
children: list.map((s) => {
|
||||
const parts: (HTMLElement | string)[] = [
|
||||
s.dir === "out" ? "◀" : "▶",
|
||||
arrow,
|
||||
onBox
|
||||
? createButton({
|
||||
label: portText(lg, { box: s.box, name: s.port }),
|
||||
@@ -68,8 +69,7 @@ function steps(
|
||||
})
|
||||
: portText(lg, { box: s.box, name: s.port }),
|
||||
];
|
||||
if (s.rule)
|
||||
parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(lg, s.rule) }));
|
||||
if (s.rule) parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(s.rule) }));
|
||||
return el("li", { className: "m02-dfpill-step", children: parts });
|
||||
}),
|
||||
});
|
||||
@@ -82,20 +82,18 @@ export function renderPillPanel(
|
||||
boxId: string,
|
||||
port: string,
|
||||
on: { pick?(boxId: string): void } = {},
|
||||
opts: { values?: CfValues } = {},
|
||||
opts: DetailOptions = {},
|
||||
): void {
|
||||
host.classList.add("m02-dfpill");
|
||||
const box = boxOf(lg, boxId);
|
||||
if (!box) return host.replaceChildren();
|
||||
const ev = evalSafe(lg, opts.values);
|
||||
const ev = evalSafe(lg, opts);
|
||||
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;
|
||||
: (ev?.arrived?.[boxId]?.[port] ?? (incoming ? valueOf(ev, incoming.from) : undefined));
|
||||
const unit = out
|
||||
? unitOf(lg, { box: boxId, name: port })
|
||||
: incoming
|
||||
@@ -103,7 +101,7 @@ export function renderPillPanel(
|
||||
: 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);
|
||||
const breaks = breakLines(lg, boxId, port, opts);
|
||||
host.replaceChildren(
|
||||
el("div", {
|
||||
className: "m02-dflink-head",
|
||||
@@ -118,13 +116,13 @@ export function renderPillPanel(
|
||||
el("div", {
|
||||
children: [
|
||||
el("div", { className: "m02-dflink-label", text: tp("From") }),
|
||||
steps(lg, back, tp("Start"), on.pick),
|
||||
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),
|
||||
steps(lg, forward, "▶", tp("End"), on.pick),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
* M02_DF_Round.ts
|
||||
* 끝수 기본값 — 금액 단위 표준(산림품셈 1-2-2 「금액의 단위표준」)을 읽어
|
||||
* 출력(비용) 기본 = 일위대가표 금액란 · 원가 합계 기본 = 일위대가표 계금 (모두 미만 버림)
|
||||
* 표 읽기 = 서버 `GET /api/m02/df/standards` → {unit, money, saved} · 아직 없으면 `_standard/금액단위표준.json`
|
||||
* 표 읽기 = 서버 `GET /api/m02/df/standards`(Api `cfStandards`) → {unit, money, saved}
|
||||
* 모양은 docs/raw/M01_쉬운단가/흐름도_정함.md 11 · 12. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { cfStandards } from "./M02_DF_Api";
|
||||
import type { CfRound } from "./M02_DF_Model";
|
||||
|
||||
/** 표 한 줄 — 원문 그대로(종목 · 단위 · 지위 · 비고) */
|
||||
@@ -17,11 +17,6 @@ export interface CfMoneyRow {
|
||||
비고?: string;
|
||||
}
|
||||
|
||||
const FILES = import.meta.glob<{ 줄?: CfMoneyRow[] }>("./_standard/금액단위표준.json", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
});
|
||||
|
||||
/** 표 모양 둘(`{줄: […]}` · 줄 목록)을 줄 목록으로 */
|
||||
const rowsOf = (x: unknown): CfMoneyRow[] => {
|
||||
const list = Array.isArray(x) ? x : (x as { 줄?: unknown } | null)?.줄;
|
||||
@@ -30,21 +25,15 @@ const rowsOf = (x: unknown): CfMoneyRow[] => {
|
||||
|
||||
let loaded: Promise<CfMoneyRow[]> | null = null;
|
||||
|
||||
/** 금액 단위 표준 줄 — 사용자가 고쳐 저장한 것이 있으면 그것 · 서버가 없으면 처음 값 */
|
||||
/** 금액 단위 표준 줄 — 서버가 고친 것(`_data`)이 있으면 그것 · 없으면 처음 값을 줌 */
|
||||
export function loadMoneyStandard(): Promise<CfMoneyRow[]> {
|
||||
loaded ??= (async () => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE_URL}/m02/df/standards`, { credentials: "include" });
|
||||
if (r.ok) {
|
||||
const d = (await r.json()) as { money?: unknown; saved?: { money?: unknown } | null };
|
||||
const rows = rowsOf(d.saved?.money ?? d.money);
|
||||
if (rows.length) return rows;
|
||||
}
|
||||
} catch {
|
||||
/* 서버가 없으면 처음 값 */
|
||||
}
|
||||
return rowsOf(Object.values(FILES)[0]);
|
||||
})();
|
||||
loaded ??= cfStandards().then(
|
||||
(s) => rowsOf(s.money),
|
||||
() => {
|
||||
loaded = null; // 못 읽었으면 다음에 다시 — 지금은 정해 둔 기본값으로
|
||||
return [];
|
||||
},
|
||||
);
|
||||
return loaded;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* 선 규칙 · 끝수 → 엑셀 수식 — 우리 스프레드시트 엔진 한 벌로 셈 · 선 위 규칙 글(쉬운 말) · DOM 없음
|
||||
*
|
||||
* 한 번 셈 = 시트 하나: A1 부터 보낸 값(표면 머리 줄 포함 덩어리) · 그 오른쪽에 조건 값 · 맨 끝 칸에 규칙 수식
|
||||
* lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림) → 맞은 줄 수 · INDEX/MATCH
|
||||
* lookup = 줄마다 「맞음」 칸(같음 · 범위는 「이름·아래」 < 값 ≤ 「이름·위」 · 빈 끝은 열림 · 아래 = 위면 값 == 그 수)
|
||||
* → 맞은 줄 수 · INDEX/MATCH
|
||||
* gate = IF(조건, 보낸 값, NA()) · scale = × ÷ (1 ± %) · round = ROUND · ROUNDUP · ROUNDDOWN
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -177,7 +178,8 @@ function lookup(
|
||||
else if (low && high)
|
||||
tests.push(
|
||||
(r) =>
|
||||
`AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r}))`,
|
||||
`IF(AND(NOT(ISBLANK(${low}${r})),${low}${r}=${high}${r}),${ref}=${low}${r},` +
|
||||
`AND(OR(ISBLANK(${low}${r}),${low}${r}<${ref}),OR(ISBLANK(${high}${r}),${ref}<=${high}${r})))`,
|
||||
);
|
||||
else bad = true;
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* M02_DF_Standards.ts
|
||||
* 단가산출 흐름도 — 단위 · 금액 표준 표(정함 11 · 12)
|
||||
* 처음 값 = `_standard/단위표준.json` · `금액단위표준.json`(산림품셈 1-2-2 · 원문 흠 그대로 — 사용자가 고침)
|
||||
* 서버 = `GET · PUT · DELETE /api/m02/df/standards` → {unit, money, saved} · 고친 것은 `_data`(마스터에 안 씀)
|
||||
* 서버 = `M02_DF_Api` cfStandards · cfStandardsSave · cfStandardsReset(`/api/m02/df/standards`) → {unit, money, saved} ·
|
||||
* 고친 것은 `_data`(마스터에 안 씀)
|
||||
* `renderStandards(host, on)` = 시험판 안 「표준 표」 판(두 표 보기 · 칸 고치기 · 저장 · 처음 값으로)
|
||||
* `standardsList()` = 단위 고르기 · 끝수 제안이 부르는 목록(동기 · 처음엔 번들 값 · 판이 서버 값을 받으면 그것)
|
||||
* 이 폴더째 지워도 됨.
|
||||
@@ -10,7 +11,7 @@
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { cfStandards, cfStandardsReset, cfStandardsSave, type CfStandards } from "./M02_DF_Api";
|
||||
import unitDefault from "./_standard/단위표준.json";
|
||||
import moneyDefault from "./_standard/금액단위표준.json";
|
||||
import "./M02_DF_Standards.css";
|
||||
@@ -148,21 +149,12 @@ export const standardsNow = (): Standards => clone(current);
|
||||
|
||||
/* ── 서버 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
async function call(method: "GET" | "PUT" | "DELETE", body?: unknown): Promise<Standards> {
|
||||
const response = await fetch(`${API_BASE_URL}/m02/df/standards`, {
|
||||
method,
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const data = (await response.json().catch(() => ({}))) as Standards & { detail?: unknown };
|
||||
if (!response.ok) throw new Error(String(data.detail ?? response.statusText));
|
||||
return data;
|
||||
}
|
||||
/** 서버 답(`M02_DF_Api` CfStandards) → 이 판의 모양 */
|
||||
const asStd = (v: CfStandards): Standards => v as unknown as Standards;
|
||||
|
||||
/** 서버 표 받기 — 못 받으면 처음 값 */
|
||||
export async function loadStandards(): Promise<Standards> {
|
||||
current = await call("GET");
|
||||
current = asStd(await cfStandards());
|
||||
return clone(current);
|
||||
}
|
||||
|
||||
@@ -311,7 +303,14 @@ export function renderStandards(host: HTMLElement, on: StandardsOn = {}): void {
|
||||
async function doSave(): Promise<void> {
|
||||
save.disabled = true;
|
||||
try {
|
||||
take(await call("PUT", { unit: work.unit, money: work.money }));
|
||||
take(
|
||||
asStd(
|
||||
await cfStandardsSave({ unit: work.unit, money: work.money } as unknown as Omit<
|
||||
CfStandards,
|
||||
"saved"
|
||||
>),
|
||||
),
|
||||
);
|
||||
showToast(tx("Saved"), "success");
|
||||
} catch {
|
||||
showToast(tx("SaveFailed"), "error");
|
||||
@@ -322,7 +321,7 @@ export function renderStandards(host: HTMLElement, on: StandardsOn = {}): void {
|
||||
async function doReset(): Promise<void> {
|
||||
if (!(await showConfirmDialog(tx("ResetAsk"), tx("Reset")))) return;
|
||||
try {
|
||||
take(await call("DELETE"));
|
||||
take(asStd(await cfStandardsReset()));
|
||||
} catch {
|
||||
take(clone(ORIGINAL)); // 서버가 없으면 화면만 처음 값
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import type { CfEvalResult, CfValues } from "./M02_DF_Eval";
|
||||
import type { CfEvalOptions, CfEvalResult, CfValues } from "./M02_DF_Eval";
|
||||
import type { CfBox, CfLogic, CfRound } from "./M02_DF_Model";
|
||||
import { showValue } from "./M02_DF_OutputPanel";
|
||||
import { defaultTotalRound, roundBy, roundText } from "./M02_DF_Round";
|
||||
@@ -55,7 +55,7 @@ export function renderSummary(
|
||||
lg: CfLogic,
|
||||
result: CfEvalResult,
|
||||
on?: { change(lg: CfLogic): void; select?(sel: CfSel): void },
|
||||
opts?: { values?: CfValues },
|
||||
opts?: { values?: CfValues; logics?: CfEvalOptions["logics"] },
|
||||
): void {
|
||||
const money = !lg.resultUnit || lg.resultUnit.startsWith("원");
|
||||
const perUnit = lg.resultUnit.split("/").slice(1).join("/");
|
||||
@@ -131,7 +131,7 @@ export function renderSummary(
|
||||
lg,
|
||||
result,
|
||||
{ change: (next) => on?.change(next), select: on?.select },
|
||||
{ values: opts?.values },
|
||||
{ values: opts?.values, logics: opts?.logics },
|
||||
);
|
||||
const plainValues = Object.entries(cost.값들);
|
||||
host.replaceChildren(
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createSelectField, el } from "@ui/ui_template_elements";
|
||||
import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { breaks, type CfBreak, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
|
||||
import { breaks, type CfEvalOptions, type CfEvalResult, type CfValues } from "./M02_DF_Eval";
|
||||
import { findMaterials, materialOf, priceOf, type CfMat } from "./M02_DF_Material";
|
||||
import {
|
||||
portsIn,
|
||||
designInputs,
|
||||
elementPort,
|
||||
type CfBox,
|
||||
type CfElementRef,
|
||||
type CfLogic,
|
||||
@@ -28,7 +29,7 @@ const TEXT = {
|
||||
Vars: ["변수 예시값", "Variable samples"],
|
||||
Slots: ["재료 선택 자리", "Material choice"],
|
||||
Rules: ["재료 조건 자리", "Material by condition"],
|
||||
Logics: ["이어지지 않은 다른 로직 값", "Unlinked values of other logics"],
|
||||
Logics: ["이어지지 않은 다른 로직 값(예시값)", "Unlinked values of other logics (samples)"],
|
||||
Choose: ["고르기", "Choose"],
|
||||
Close: ["닫기", "Close"],
|
||||
Find: ["찾기", "Find"],
|
||||
@@ -50,12 +51,11 @@ const TEXT = {
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"],
|
||||
OwnSample: ["비우면 그 로직 예시값", "Empty = the logic's own sample"],
|
||||
OtherLogic: [
|
||||
"이어 주지 않아서 그 로직의 예시값을 써요",
|
||||
"Not linked — the logic's own sample is used",
|
||||
],
|
||||
NoLine: ["「{box}」 의 받는 자리 「{pill}」 에 선이 없어요", "“{pill}” of “{box}” has no line"],
|
||||
NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"],
|
||||
} as const;
|
||||
|
||||
type TextKey = keyof typeof TEXT;
|
||||
@@ -78,28 +78,6 @@ export interface CfVerifyOn {
|
||||
|
||||
const PAGE = 10;
|
||||
|
||||
/** 끊긴 곳 — Model 쪽 `breaks` 가 차면 그것 · 아직이면 빈 받는 자리와 계산 오류만 */
|
||||
function brokenSpots(lg: CfLogic, result: CfEvalResult, values: CfValues): CfBreak[] {
|
||||
try {
|
||||
return breaks(lg, values);
|
||||
} catch {
|
||||
const out: CfBreak[] = [];
|
||||
if (!lg.boxes.some((b) => b.kind === "output"))
|
||||
out.push({ kind: "오류", box: "", text: tv("NoOutput") });
|
||||
for (const b of lg.boxes)
|
||||
for (const p of portsIn(b))
|
||||
if (!lg.links.some((l) => l.to.box === b.id && l.to.name === p.name))
|
||||
out.push({
|
||||
kind: "빈 받는 자리",
|
||||
box: b.id,
|
||||
port: p.name,
|
||||
text: tv("NoLine", { box: b.name, pill: p.name }),
|
||||
});
|
||||
for (const e of result.errors) out.push({ kind: "오류", box: "", text: e });
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** 재료 요소 하나 고치기 — 나머지는 그대로 */
|
||||
function withElement(
|
||||
lg: CfLogic,
|
||||
@@ -285,7 +263,7 @@ function slotRow(box: CfBox, e: CfElementRef, on: CfVerifyOn, lg: CfLogic): HTML
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-dfver__line",
|
||||
children: [el("strong", { text: e.slot ?? e.name }), now, price, toggle],
|
||||
children: [el("strong", { text: elementPort(e) }), now, price, toggle],
|
||||
}),
|
||||
panel,
|
||||
],
|
||||
@@ -325,7 +303,7 @@ function ruleRow(e: CfElementRef, lg: CfLogic, result: CfEvalResult): HTMLElemen
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m02-dfver__line",
|
||||
children: [el("strong", { text: e.slot ?? e.name }), state, note],
|
||||
children: [el("strong", { text: elementPort(e) }), state, note],
|
||||
}),
|
||||
el("span", {
|
||||
className: "m02-dfver__muted",
|
||||
@@ -341,7 +319,7 @@ export function renderVerify(
|
||||
lg: CfLogic,
|
||||
result: CfEvalResult,
|
||||
on: CfVerifyOn,
|
||||
opts: { values?: CfValues } = {},
|
||||
opts: { values?: CfValues; logics?: CfEvalOptions["logics"] } = {},
|
||||
): void {
|
||||
const msg = el("p", { className: "m02-dfver__msg" });
|
||||
const safe = (make: () => CfLogic): void => {
|
||||
@@ -354,7 +332,7 @@ export function renderVerify(
|
||||
};
|
||||
|
||||
/* 끊긴 곳 */
|
||||
const spots = brokenSpots(lg, result, opts.values ?? {});
|
||||
const spots = breaks(lg, opts.values ?? {}, { logics: opts.logics });
|
||||
const spotBlock = spots.length
|
||||
? [
|
||||
el("div", {
|
||||
@@ -383,60 +361,77 @@ export function renderVerify(
|
||||
]
|
||||
: [];
|
||||
|
||||
/* 설계 때 정할 것 */
|
||||
/* 설계 때 정할 것 — Model `designInputs`(변수 · 재료 자리 · 다른 로직) + 재료 조건 자리 */
|
||||
const designs = designInputs(lg);
|
||||
const boxOf = (id: string): CfBox | undefined => lg.boxes.find((b) => b.id === id);
|
||||
/** 변수 예시값을 그 상자(입력 · 함수)의 변수에 되적음 */
|
||||
const putExample = (box: string, name: string, value: number | string): CfLogic => ({
|
||||
...lg,
|
||||
boxes: lg.boxes.map((x) => {
|
||||
if (x.id !== box) return x;
|
||||
const set = (list: CfVariable[]): CfVariable[] =>
|
||||
list.map((y) => (y.name === name ? { ...y, example: value } : y));
|
||||
return {
|
||||
...x,
|
||||
...(x.input ? { input: { ...x.input, variables: set(x.input.variables) } } : {}),
|
||||
...(x.func ? { func: { ...x.func, variables: set(x.func.variables) } } : {}),
|
||||
};
|
||||
}),
|
||||
});
|
||||
const vars = designs.flatMap((d) =>
|
||||
d.kind === "변수" && d.variable
|
||||
? [sampleField(d.variable, (value) => safe(() => putExample(d.box, d.port, value)), msg)]
|
||||
: [],
|
||||
);
|
||||
const slots = designs.flatMap((d) => {
|
||||
const box = boxOf(d.box);
|
||||
return d.kind === "재료 자리" && d.element && box
|
||||
? [slotRow(box, d.element, { ...on, change: (n) => safe(() => n) }, lg)]
|
||||
: [];
|
||||
});
|
||||
const inputs = lg.boxes.filter((b) => b.kind === "input" && b.input);
|
||||
const vars = inputs.flatMap((b) =>
|
||||
(b.input?.variables ?? []).map((v, i) =>
|
||||
sampleField(
|
||||
v,
|
||||
(value) =>
|
||||
safe(() => ({
|
||||
...lg,
|
||||
boxes: lg.boxes.map((x) =>
|
||||
x.id === b.id && x.input
|
||||
? {
|
||||
...x,
|
||||
input: {
|
||||
...x.input,
|
||||
variables: x.input.variables.map((y, j) =>
|
||||
j === i ? { ...y, example: value } : y,
|
||||
),
|
||||
},
|
||||
}
|
||||
: x,
|
||||
),
|
||||
})),
|
||||
msg,
|
||||
),
|
||||
),
|
||||
);
|
||||
const slots = inputs.flatMap((b) =>
|
||||
(b.input?.elements ?? [])
|
||||
.filter((e) => e.mode === "choice")
|
||||
.map((e) => slotRow(b, e, { ...on, change: (n) => safe(() => n) }, lg)),
|
||||
);
|
||||
const rules = inputs.flatMap((b) =>
|
||||
(b.input?.elements ?? [])
|
||||
.filter((e) => e.mode === "rule" && e.rule)
|
||||
.map((e) => ruleRow(e, lg, result)),
|
||||
);
|
||||
const logics = inputs.flatMap((b) =>
|
||||
(b.input?.logic?.designs ?? [])
|
||||
.filter((d) => !lg.links.some((l) => l.to.box === b.id && l.to.name === d.name))
|
||||
.map((d) =>
|
||||
el("li", {
|
||||
children: [
|
||||
createButton({
|
||||
label: `${b.name} · ${d.name}${d.unit && d.unit !== "-" ? ` (${d.unit})` : ""} — ${tv("OtherLogic")}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !on.select,
|
||||
onClick: () => on.select?.({ box: b.id, port: d.name }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
/** 다른 로직의 받는 알약 — 예시값을 적으면 logic.examples · 비우면 그 로직 자기 예시값 */
|
||||
const putLogicExample = (box: string, port: string, raw: string): CfLogic => ({
|
||||
...lg,
|
||||
boxes: lg.boxes.map((x) => {
|
||||
if (x.id !== box || !x.input?.logic) return x;
|
||||
const examples = { ...x.input.logic.examples };
|
||||
const text = raw.trim();
|
||||
if (text === "") delete examples[port];
|
||||
else examples[port] = Number.isNaN(Number(text)) ? text : Number(text);
|
||||
return { ...x, input: { ...x.input, logic: { ...x.input.logic, examples } } };
|
||||
}),
|
||||
});
|
||||
const logics = designs.flatMap((d) => {
|
||||
if (d.kind !== "다른 로직") return [];
|
||||
const input = el("input", {
|
||||
className: "m02-dfver__input",
|
||||
attrs: { type: "text", placeholder: tv("OwnSample"), "aria-label": d.port },
|
||||
});
|
||||
input.value = d.example === null || d.example === undefined ? "" : String(d.example);
|
||||
input.addEventListener("change", () => safe(() => putLogicExample(d.box, d.port, input.value)));
|
||||
const unit = d.unit && d.unit !== "-" ? ` (${d.unit})` : "";
|
||||
return [
|
||||
el("li", {
|
||||
className: "m02-dfver__line",
|
||||
children: [
|
||||
createButton({
|
||||
label: `${boxOf(d.box)?.name ?? ""} · ${d.port}${unit}`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
disabled: !on.select,
|
||||
onClick: () => on.select?.({ box: d.box, port: d.port }),
|
||||
}),
|
||||
input,
|
||||
],
|
||||
}),
|
||||
];
|
||||
});
|
||||
const group = (title: TextKey, body: HTMLElement[]): HTMLElement[] =>
|
||||
body.length
|
||||
? [
|
||||
|
||||
Reference in New Issue
Block a user