feat(M02-흐름도): 상세 내용 판 - 선 규칙 LinkPanel · 알약 PillPanel · 함수 판 끝수는 모델 round 로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-01 19:48:24 +09:00
co-authored by Claude Sonnet 5.5
parent a3730d227e
commit 022c7e086b
8 changed files with 992 additions and 167 deletions
@@ -0,0 +1,254 @@
/* =============================================================================
* M02_DF_Detail.ts
* 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 겹침/안 맞음 · 흐름 줄 · 끊김 까닭 · DOM 없음
* 모델의 traceBack · traceForward · breaks · applyRule · ruleLabel 이 아직 준비 중이면 같은 뜻의 작은 셈으로 메움
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { breaks, evalLogic, type CfEvalResult, type CfValue, type CfValues } from "./M02_DF_Eval";
import {
portsIn,
portsOut,
traceBack,
traceForward,
type CfBox,
type CfGateOp,
type CfLink,
type CfLogic,
type CfPort,
type CfRound,
type CfRule,
type CfStep,
} from "./M02_DF_Model";
import { applyRule, ruleLabel } from "./M02_DF_Rule";
const TEXT = {
Table: ["표 {n}줄", "table, {n} rows"],
Empty: ["값 없음", "no value"],
LabelLookup: ["표에서 「{col}」 찾기", "Look up “{col}” in the table"],
LabelGate: ["「{pill}」 {op} {value} 일 때만 보냄", "Send only when “{pill}” {op} {value}"],
LabelScale: ["값 {op} {value}", "value {op} {value}"],
LabelRound: ["소수 {places}자리 {method}", "{places} decimals, {method}"],
NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"],
Blocked: ["통과 안 됨", "does not pass"],
NoRowLater: ["표에서 맞는 줄은 계산 뒤에 보임", "The matching row shows after calculation"],
EmptyIn: ["빈 받는 자리 — 선이 없음", "Empty receiving spot — no line"],
NoCell: ["받은 알약을 칸에 놓지 않음", "The received pill is not placed in a cell"],
Dead: ["아직 어디로도 안 감", "Goes nowhere yet"],
NoValue: ["값이 아직 없음", "No value yet"],
} as const;
export const td = (key: keyof typeof TEXT, fill: Record<string, string | number> = {}): string =>
Object.entries(fill).reduce(
(s, [k, v]) => s.replace(`{${k}}`, String(v)),
TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string,
);
export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"];
export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"];
export const boxOf = (lg: CfLogic, id: string): CfBox | undefined =>
lg.boxes.find((b) => b.id === id);
export const portText = (lg: CfLogic, p: CfPort): string =>
`${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`;
/** 계산 한 번 — 못 하면 null */
export function evalSafe(lg: CfLogic, values?: CfValues): CfEvalResult | null {
try {
return evalLogic(lg, values);
} catch {
return null;
}
}
export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined =>
ev?.ports[p.box]?.[p.name];
/** 화면에 보일 값 글 — 표는 줄 수 */
export function showValue(v: CfValue | undefined): string {
if (v === undefined || v === null) return td("Empty");
if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) });
return typeof v === "number" ? String(Number(v.toPrecision(12))) : String(v);
}
export function unitOf(lg: CfLogic, p: CfPort): string {
const box = boxOf(lg, p.box);
return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-";
}
/** 알약이 나가는(out) 쪽인가 */
export const isOut = (box: CfBox, name: string): boolean =>
portsOut(box).some((p) => p.name === name);
/* ── 규칙 ─────────────────────────────────────────────────────────────── */
const num = (v: unknown): number | null => {
const n = typeof v === "number" ? v : Number(v);
return Number.isFinite(n) && v !== "" && v !== null ? n : null;
};
/** 조건 통과 — 숫자면 숫자끼리 · 아니면 글끼리 */
export function gatePass(op: CfGateOp, v: unknown, value: number | string): boolean {
const a = num(v);
const b = num(value);
const [x, y] = a !== null && b !== null ? [a, b] : [String(v ?? ""), String(value)];
switch (op) {
case "=":
return x === y;
case "≠":
return x !== y;
case "≥":
return x >= y;
case "≤":
return x <= y;
case ">":
return x > y;
default:
return x < y;
}
}
export function roundValue(r: CfRound, v: number): number {
const k = 10 ** r.places;
const f = r.method === "올림" ? Math.ceil : r.method === "버림" ? Math.floor : Math.round;
return f(v * k) / k;
}
/** 선 위 규칙 글 — 모델이 준비되면 그것, 아니면 같은 뜻의 글 */
export function labelOf(lg: CfLogic, rule: CfRule | undefined): string {
if (!rule) return td("NoRule");
try {
return ruleLabel(rule);
} catch {
if (rule.kind === "lookup") return td("LabelLookup", { col: rule.col });
if (rule.kind === "gate")
return td("LabelGate", { pill: portText(lg, rule.from), op: rule.op, value: rule.value });
if (rule.kind === "scale") return td("LabelScale", { op: rule.op, value: rule.value });
return td("LabelRound", { places: rule.places, method: rule.method });
}
}
/** 지나가기 전 값 → 지난 뒤 값 미리 보기 */
export function preview(
link: CfLink,
rule: CfRule | undefined,
ev: CfEvalResult | null,
): { before: string; after: string } {
const raw = valueOf(ev, link.from);
const before = showValue(raw);
if (!rule) return { before, after: before };
const conds: Record<string, unknown> = {};
if (rule.kind === "lookup") for (const c of rule.conds) conds[c.col] = valueOf(ev, c.from);
try {
const r = applyRule(rule, raw, conds);
return {
before,
after: r.value === null ? (r.why ?? td("Empty")) : showValue(r.value as CfValue),
};
} catch {
const n = num(raw);
if (rule.kind === "gate")
return {
before,
after: gatePass(rule.op, valueOf(ev, rule.from), rule.value) ? before : td("Blocked"),
};
if (rule.kind === "lookup" || n === null) return { before, after: td("NoRowLater") };
if (rule.kind === "round") return { before, after: showValue(roundValue(rule, n)) };
const v = rule.value;
const out =
rule.op === "×"
? n * v
: rule.op === "÷"
? v
? n / v
: NaN
: rule.op === "+%"
? n * (1 + v / 100)
: n * (1 - v / 100);
return { before, after: Number.isFinite(out) ? showValue(out) : td("Empty") };
}
}
/** 같은 알약에서 나간 조건 선 — 지금 값으로 통과하는 수 */
export function gateReport(
lg: CfLogic,
link: CfLink,
ev: CfEvalResult | null,
): { total: number; pass: string[] } {
const same = lg.links.filter(
(l) =>
l.from.box === link.from.box && l.from.name === link.from.name && l.rule?.kind === "gate",
);
const pass = same
.filter((l) => {
const r = l.rule as Extract<CfRule, { kind: "gate" }>;
return gatePass(r.op, valueOf(ev, r.from), r.value);
})
.map((l) => l.id);
return { total: same.length, pass };
}
/* ── 흐름 · 끊김 ─────────────────────────────────────────────────────── */
/** 거슬러 오르는 길(dir "in" = 온 길) · 따라가는 길(dir "out") — 모델 것, 없으면 작은 셈 */
export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] {
try {
return dir === "in" ? traceBack(lg, p) : traceForward(lg, p);
} catch {
const out: CfStep[] = [];
const seen = new Set<string>();
const walk = (port: CfPort, back: boolean): void => {
const key = `${port.box}/${port.name}`;
if (seen.has(key)) return;
seen.add(key);
const box = boxOf(lg, port.box);
if (!box) return;
if (back) {
const into = lg.links.filter(
(l) => l.to.box === port.box && (isOut(box, port.name) || l.to.name === port.name),
);
for (const l of into) {
out.push({ box: l.from.box, port: l.from.name, dir: "out", link: l.id, rule: l.rule });
walk(l.from, true);
}
} else {
const outs = lg.links.filter((l) => l.from.box === port.box && l.from.name === port.name);
for (const l of outs) {
out.push({ box: l.to.box, port: l.to.name, dir: "in", link: l.id, rule: l.rule });
const next = boxOf(lg, l.to.box);
for (const o of next ? portsOut(next) : []) walk({ box: next!.id, name: o.name }, false);
}
}
};
walk(p, dir === "in");
return out;
}
}
/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */
export function breakLines(lg: CfLogic, boxId: string, name?: string): string[] {
try {
return breaks(lg)
.filter((b) => b.box === boxId && (!name || !b.port || b.port === name))
.map((b) => b.text);
} catch {
// 모델의 끊긴 곳 목록이 준비되기 전 — 같은 뜻의 작은 셈
}
const box = boxOf(lg, boxId);
if (!box) return [];
const out: string[] = [];
for (const p of portsIn(box)) {
if (name && p.name !== name) continue;
if (!lg.links.some((l) => l.to.box === boxId && l.to.name === p.name)) out.push(td("EmptyIn"));
else if (box.func?.inputs.some((i) => i.port === p.name && !i.cell)) out.push(td("NoCell"));
}
for (const p of portsOut(box)) {
if (name && p.name !== name) continue;
if (!lg.links.some((l) => l.from.box === boxId && l.from.name === p.name)) out.push(td("Dead"));
}
return out;
}
export const noValue = (): string => td("NoValue");
+16 -112
View File
@@ -7,103 +7,18 @@
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import type {
CalcValues,
SpreadsheetHandle,
Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet";
import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model";
import { parseRound, wrapRound, type RoundSpec } from "./M02_DF_Round";
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
import { createRoundRow } from "./M02_DF_RoundRow";
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
import "./M02_DF_FuncPanel.css";
const TEXT = {
Unit: ["단위", "Unit"],
Cell: ["고른 칸", "Selected cell"],
Got: ["받은 알약", "Received pills"],
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
Placed: ["놓임", "placed"],
Free: ["아직 안 놓은 알약", "not placed yet"],
Busy: ["이미 쓰는 칸", "That cell is already in use"],
Locked: [
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
"This cell is filled by the wire — cannot edit",
],
Vars: ["변수", "Variables"],
MakeVar: ["변수 만들기", "Make variable"],
NoVars: ["아직 변수가 없음", "No variables yet"],
VarName: ["이름", "Name"],
VarType: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
VarUnit: ["단위", "Unit"],
VarMin: ["최소", "Min"],
VarMax: ["최대", "Max"],
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
VarExample: ["예시값", "Example value"],
VarCell: ["붙을 칸", "Attach to"],
Make: ["만들기", "Make"],
Cancel: ["취소", "Cancel"],
Remove: ["지움", "Remove"],
NeedName: ["이름을 적음", "Enter a name"],
SameName: ["같은 이름이 이미 있음", "That name already exists"],
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
OutBtn: ["출력 칸으로", "Make output cell"],
OutNow: ["출력", "Output"],
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
Frames: ["틀", "Templates"],
FrameMul: ["곱하기", "Multiply"],
FrameMore: ["할증", "Markup"],
FrameDiv: ["나누기", "Divide"],
FrameIf: ["만약", "If"],
FrameMin: ["최소", "Min"],
FrameMax: ["최대", "Max"],
FrameUp: ["올림", "Round up"],
FrameLook: ["표에서 찾기", "Look up in table"],
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
ReadOnly: ["읽기만", "Read only"],
Loading: ["시트를 불러오는 중", "Loading sheet"],
Problem: ["⚠ ", "⚠ "],
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
Err_DIV0: ["0으로 나눔", "divides by zero"],
Err_NAME: ["알 수 없는 이름", "unknown name"],
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
Err_NA: ["찾는 값이 없음", "value not found"],
Err_OTHER: ["수식이 틀림", "formula is wrong"],
} as const;
const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
const FRAMES: [keyof typeof TEXT, string][] = [
["FrameMul", "=B1*B2"],
["FrameMore", "=B1*(1+B2/100)"],
["FrameDiv", "=B1/B2"],
["FrameIf", '=IF(B3="급",10,0)'],
["FrameMin", "=MIN(B1,B2)"],
["FrameMax", "=MAX(B1,B2)"],
["FrameUp", "=ROUNDUP(B4,3)"],
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
];
const ERR_KEY: Record<string, keyof typeof TEXT> = {
"#DIV/0!": "Err_DIV0",
"#NAME?": "Err_NAME",
"#REF!": "Err_REF",
"#VALUE!": "Err_VALUE",
"#CYCLE!": "Err_CYCLE",
"#N/A": "Err_NA",
};
type Handlers = { change(lg: CfLogic): void };
interface VarForm {
@@ -537,7 +452,16 @@ function drawBar(st: State): void {
el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }),
],
}),
...[roundRow(st)].filter((x): x is HTMLElement => !!x),
...(f?.output.cell
? [
createRoundRow({
round: f.output.round,
suggest: st.suggest,
readOnly: st.readOnly,
onApply: (round) => writeRound(st, round),
}),
]
: []),
el("div", {
className: "m02-dfpanel-row",
children: [
@@ -556,31 +480,11 @@ function drawBar(st: State): void {
);
}
/** 출력 칸 수식에 끝수 씌움(칸 값만 있으면 그 값을 감쌈) */
function writeRound(st: State, spec: RoundSpec | null): void {
const f = funcOf(st);
const cell = f?.sheet.칸[f.output.cell];
if (!f || !cell) return;
const text = cell.식 ?? String(cell.값 ?? "");
const inner = parseRound(text)?.inner ?? text;
/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */
function writeRound(st: State, round: CfRound | null): void {
patchFunc(st, (x) => {
const sheet = structuredClone(x.sheet);
sheet.칸[x.output.cell] = { 식: wrapRound(inner, spec) };
return { ...x, sheet };
});
}
function roundRow(st: State): HTMLElement | null {
const f = funcOf(st);
const cell = f?.sheet.칸[f.output.cell];
const formula = cell
? (cell.식 ?? (cell.값 === undefined ? undefined : String(cell.값)))
: undefined;
return createRoundRow({
formula,
suggest: st.suggest,
readOnly: st.readOnly,
onApply: (spec) => writeRound(st, spec),
const { round: _old, ...rest } = x.output;
return { ...x, output: round ? { ...rest, round } : rest };
});
}
@@ -0,0 +1,90 @@
/* =============================================================================
* M02_DF_FuncText.ts
* 함수 상자 판 글 [한, 영] 짝 · 틀 단추 수식 · 수식 오류 말 — 판 파일이 700줄 안에 들도록 뗌
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
export const TEXT = {
Unit: ["단위", "Unit"],
Cell: ["고른 칸", "Selected cell"],
Got: ["받은 알약", "Received pills"],
GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"],
NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"],
Placed: ["놓임", "placed"],
Free: ["아직 안 놓은 알약", "not placed yet"],
Busy: ["이미 쓰는 칸", "That cell is already in use"],
Locked: [
"받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침",
"This cell is filled by the wire — cannot edit",
],
Vars: ["변수", "Variables"],
MakeVar: ["변수 만들기", "Make variable"],
NoVars: ["아직 변수가 없음", "No variables yet"],
VarName: ["이름", "Name"],
VarType: ["갈래", "Type"],
Number: ["숫자", "Number"],
Choice: ["선택", "Choice"],
Text: ["글", "Text"],
VarUnit: ["단위", "Unit"],
VarMin: ["최소", "Min"],
VarMax: ["최대", "Max"],
VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"],
VarExample: ["예시값", "Example value"],
VarCell: ["붙을 칸", "Attach to"],
Make: ["만들기", "Make"],
Cancel: ["취소", "Cancel"],
Remove: ["지움", "Remove"],
NeedName: ["이름을 적음", "Enter a name"],
SameName: ["같은 이름이 이미 있음", "That name already exists"],
NotNumber: ["예시값은 숫자로 적음", "Example must be a number"],
BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"],
OutBtn: ["출력 칸으로", "Make output cell"],
OutNow: ["출력", "Output"],
OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"],
OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"],
Frames: ["틀", "Templates"],
FrameMul: ["곱하기", "Multiply"],
FrameMore: ["할증", "Markup"],
FrameDiv: ["나누기", "Divide"],
FrameIf: ["만약", "If"],
FrameMin: ["최소", "Min"],
FrameMax: ["최대", "Max"],
FrameUp: ["올림", "Round up"],
FrameLook: ["표에서 찾기", "Look up in table"],
FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"],
ReadOnly: ["읽기만", "Read only"],
Loading: ["시트를 불러오는 중", "Loading sheet"],
Problem: ["⚠ ", "⚠ "],
Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"],
Err_DIV0: ["0으로 나눔", "divides by zero"],
Err_NAME: ["알 수 없는 이름", "unknown name"],
Err_REF: ["없는 칸을 가리킴", "points to a missing cell"],
Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"],
Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"],
Err_NA: ["찾는 값이 없음", "value not found"],
Err_OTHER: ["수식이 틀림", "formula is wrong"],
} as const;
export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */
export const FRAMES: [keyof typeof TEXT, string][] = [
["FrameMul", "=B1*B2"],
["FrameMore", "=B1*(1+B2/100)"],
["FrameDiv", "=B1/B2"],
["FrameIf", '=IF(B3="급",10,0)'],
["FrameMin", "=MIN(B1,B2)"],
["FrameMax", "=MAX(B1,B2)"],
["FrameUp", "=ROUNDUP(B4,3)"],
["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"],
];
export const ERR_KEY: Record<string, keyof typeof TEXT> = {
"#DIV/0!": "Err_DIV0",
"#NAME?": "Err_NAME",
"#REF!": "Err_REF",
"#VALUE!": "Err_VALUE",
"#CYCLE!": "Err_CYCLE",
"#N/A": "Err_NA",
};
@@ -0,0 +1,82 @@
.m02-dflink {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m02-dflink-head,
.m02-dflink-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
}
.m02-dflink-label {
font-weight: var(--font-weight-bold);
}
.m02-dflink-note {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m02-dflink-value {
padding: 2px var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-pills);
background: var(--color-surface-raised);
font-weight: var(--font-weight-medium);
}
.m02-dflink-num {
width: 7em;
}
.m02-dflink-warn {
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-left: 4px solid var(--color-warning);
background: var(--color-surface-raised);
font-weight: var(--font-weight-medium);
}
/* 알약 판 */
.m02-dfpill {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
font-size: var(--text-body);
color: var(--color-text-body);
}
.m02-dfpill-title {
font-weight: var(--font-weight-bold);
}
.m02-dfpill-list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin: 0;
padding: 0;
list-style: none;
}
.m02-dfpill-step {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
align-items: center;
}
.m02-dfpill-rule {
padding: 0 var(--spacing-8);
border: 1px dashed var(--color-border);
border-radius: var(--radius-pills);
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
@@ -0,0 +1,388 @@
/* =============================================================================
* M02_DF_LinkPanel.ts
* 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ·
* 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfValues } from "./M02_DF_Eval";
import {
portsOut,
reaches,
type CfGateOp,
type CfLogic,
type CfPort,
type CfRound,
type CfRule,
} from "./M02_DF_Model";
import {
GATE_OPS,
SCALE_OPS,
evalSafe,
gateReport,
labelOf,
portText,
preview,
unitOf,
valueOf,
} from "./M02_DF_Detail";
import { suggestPlaces } from "./M02_DF_UnitPick";
import "./M02_DF_LinkPanel.css";
const TEXT = {
Title: ["선 규칙", "Line rule"],
Kinds: ["규칙 고르기", "Pick a rule"],
K_none: ["없음", "None"],
K_lookup: ["표에서 찾기", "Look up in table"],
K_gate: ["조건 통과", "Pass if"],
K_scale: ["바꾸기", "Change"],
K_round: ["끝수", "Rounding"],
Label: ["선 위 글", "Text on the line"],
Before: ["지나가기 전 값", "Value before"],
After: ["지난 뒤 값", "Value after"],
Col: ["조건 칸", "Condition column"],
Match: ["맞출 알약", "Pill to match"],
Take: ["꺼낼 칸", "Column to take"],
AddCond: ["+ 조건 칸", "+ Condition"],
Remove: ["지움", "Remove"],
NoHeader: [
"보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름",
"The sender is not a table or it is not loaded yet",
],
Pick: ["고르기", "pick"],
GateHint: [
"알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음",
"Sent only when the pill passes",
],
Value: ["값", "Value"],
ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"],
Places: ["소수 {n}자리", "{n} decimals"],
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
Apply: ["적용", "Apply"],
WarnBoth: [
"같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침",
"{n} conditional lines pass at once — overlap",
],
WarnNone: [
"같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음",
"None of the {n} conditional lines match",
],
WarnLookup: [
"조건 칸이 없음 — 표에서 줄을 못 고름",
"No condition column — a row can't be picked",
],
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
Count: ["선", "Line"],
} as const;
const tk = (key: keyof typeof TEXT, n?: number | string): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
type Kind = "none" | CfRule["kind"];
const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"];
const SEP = "\u0001";
interface Ctx {
lg: CfLogic;
linkId: string;
readOnly: boolean;
headers: string[];
change(lg: CfLogic): void;
}
const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`;
const fromKey = (k: string): CfPort => {
const [box, ...rest] = k.split(SEP);
return { box, name: rest.join(SEP) };
};
function setRule(ctx: Ctx, rule: CfRule | undefined): void {
ctx.change({
...ctx.lg,
links: ctx.lg.links.map((l) => {
if (l.id !== ctx.linkId) return l;
const { rule: _old, ...rest } = l;
return rule ? { ...rest, rule } : rest;
}),
});
}
function select(
ctx: Ctx,
options: { value: string; text: string }[],
value: string,
onPick: (v: string) => void,
aria: string,
): HTMLElement {
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
if (!options.some((o) => o.value === value))
s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") }));
s.value = value;
s.disabled = ctx.readOnly;
s.addEventListener("change", () => onPick(s.value));
return s;
}
function numberBox(
ctx: Ctx,
value: number | string,
onCommit: (v: number | string) => void,
aria: string,
): HTMLElement {
const i = el("input", {
className: "ui-input ui-input--sm m02-dflink-num",
attrs: { type: "text", "aria-label": aria },
});
i.value = String(value);
i.readOnly = ctx.readOnly;
i.addEventListener("change", () => {
const t = i.value.trim().replace(/,/g, "");
onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t);
});
return i;
}
/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */
const pillOptions = (ctx: Ctx): { value: string; text: string }[] => {
const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? "";
return ctx.lg.boxes
.filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id))
.flatMap((b) =>
portsOut(b).map((p) => ({
value: toKey({ box: b.id, name: p.name }),
text: portText(ctx.lg, { box: b.id, name: p.name }),
})),
);
};
const row = (...children: (HTMLElement | string)[]): HTMLElement =>
el("div", { className: "m02-dflink-row", children });
/* ── 규칙마다 ─────────────────────────────────────────────────────────── */
function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTMLElement[] {
const cols = [
...new Set([...ctx.headers, ...rule.conds.map((c) => c.col), rule.col].filter(Boolean)),
];
const colOpts = cols.map((c) => ({ value: c, text: c }));
const pills = pillOptions(ctx);
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
const rows = rule.conds.map((c, i) =>
row(
select(
ctx,
colOpts,
c.col,
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }),
tk("Col"),
),
"=",
select(
ctx,
pills,
toKey(c.from),
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }),
tk("Match"),
),
ctx.readOnly
? ""
: createButton({
label: tk("Remove"),
variant: "ghost",
size: "sm",
onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }),
}),
),
);
const out: HTMLElement[] = [...rows];
if (!ctx.readOnly) {
out.push(
row(
createButton({
label: tk("AddCond"),
variant: "ghost",
size: "sm",
disabled: !pills.length || !cols.length,
onClick: () =>
put({ conds: [...rule.conds, { col: cols[0], from: fromKey(pills[0].value) }] }),
}),
),
);
}
out.push(
row(
el("span", { className: "m02-dflink-label", text: tk("Take") }),
select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")),
),
);
if (!cols.length) out.push(el("p", { className: "m02-dflink-note", text: tk("NoHeader") }));
return out;
}
function gateEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "gate" }>): HTMLElement[] {
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
return [
el("p", { className: "m02-dflink-note", text: tk("GateHint") }),
row(
select(
ctx,
pillOptions(ctx),
toKey(rule.from),
(v) => put({ from: fromKey(v) }),
tk("Match"),
),
select(
ctx,
GATE_OPS.map((o) => ({ value: o, text: o })),
rule.op,
(v) => put({ op: v as CfGateOp }),
tk("Kinds"),
),
numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")),
),
];
}
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
return [
el("p", { className: "m02-dflink-note", text: tk("ScaleHint") }),
row(
select(
ctx,
SCALE_OPS.map((o) => ({ value: o, text: o })),
rule.op,
(v) => setRule(ctx, { ...rule, op: v as typeof rule.op }),
tk("Kinds"),
),
numberBox(
ctx,
rule.value,
(v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }),
tk("Value"),
),
),
];
}
function roundEditor(
ctx: Ctx,
rule: Extract<CfRule, { kind: "round" }>,
unit: string,
): HTMLElement[] {
const put = (next: Partial<CfRound>): void => setRule(ctx, { ...rule, ...next });
const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) }));
const hint = el("span", { className: "m02-dflink-note" });
void suggestPlaces(unit).then((n) => {
if (n === null || n === rule.places || ctx.readOnly) return;
hint.append(
tk("Suggest", n),
" ",
createButton({
label: tk("Apply"),
variant: "ghost",
size: "sm",
onClick: () => put({ places: n }),
}),
);
});
return [
row(
select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")),
select(
ctx,
(["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })),
rule.method,
(v) => put({ method: v as CfRound["method"] }),
tk("K_round"),
),
hint,
),
];
}
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }),
scale: () => ({ kind: "scale", op: "×", value: 1 }),
round: () => ({ kind: "round", places: 0, method: "반올림" }),
};
/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */
export function renderLinkPanel(
host: HTMLElement,
lg: CfLogic,
linkId: string,
on: { change(lg: CfLogic): void },
opts: { values?: CfValues; readOnly?: boolean } = {},
): void {
const link = lg.links.find((l) => l.id === linkId);
host.classList.add("m02-dflink");
if (!link) return host.replaceChildren();
const ev = evalSafe(lg, opts.values);
const table = valueOf(ev, link.from);
const headers =
Array.isArray(table) && Array.isArray(table[0])
? table[0].map((h) => String(h ?? "")).filter(Boolean)
: [];
const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change };
const rule = link.rule;
const kind: Kind = rule?.kind ?? "none";
const parts: HTMLElement[] = [
el("div", {
className: "m02-dflink-head",
children: [
el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }),
el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
],
}),
row(
el("span", { className: "m02-dflink-label", text: tk("Kinds") }),
...KINDS.map((k) =>
createButton({
label: tk(`K_${k}` as "K_none"),
variant: k === kind ? "filled" : "ghost",
size: "sm",
disabled: ctx.readOnly,
onClick: () =>
k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)),
}),
),
),
];
if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule));
if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule));
if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule));
if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from)));
const pv = preview(link, rule, ev);
parts.push(
row(
el("span", { className: "m02-dflink-label", text: tk("Before") }),
el("span", { className: "m02-dflink-value", text: pv.before }),
"→",
el("span", { className: "m02-dflink-label", text: tk("After") }),
el("span", { className: "m02-dflink-value", text: pv.after }),
),
row(
el("span", { className: "m02-dflink-label", text: tk("Label") }),
el("span", { text: labelOf(lg, rule) }),
),
);
const warns: string[] = [];
if (rule?.kind === "gate") {
const rep = gateReport(lg, link, ev);
if (rep.pass.length > 1) warns.push(tk("WarnBoth", rep.pass.length));
if (rep.total > 0 && rep.pass.length === 0) warns.push(tk("WarnNone", rep.total));
}
if (rule?.kind === "lookup") {
if (!rule.conds.length) warns.push(tk("WarnLookup"));
if (!rule.col) warns.push(tk("WarnTake"));
}
parts.push(...warns.map((w) => el("p", { className: "m02-dflink-warn", text: `⚠ ${w}` })));
host.replaceChildren(...parts);
}
@@ -0,0 +1,139 @@
/* =============================================================================
* M02_DF_PillPanel.ts
* 상세 내용 판 — 알약: 알약을 고르면 이름 · 값 · 단위 · 온 길(출처 사슬) · 가는 곳(이어진 선 · 받는 상자) · 끊김 까닭
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 8 · 9 · M02_DF_Model 의 CfStep 그대로 · 읽기만(고치기는 선 · 상자 판에서)
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfValues } from "./M02_DF_Eval";
import type { CfLogic, CfStep } from "./M02_DF_Model";
import {
boxOf,
breakLines,
evalSafe,
isOut,
labelOf,
portText,
showValue,
trace,
unitOf,
valueOf,
} from "./M02_DF_Detail";
import "./M02_DF_LinkPanel.css";
const TEXT = {
Name: ["이름", "Name"],
Value: ["값", "Value"],
Unit: ["단위", "Unit"],
Kind_out: ["내보내는 알약", "Sending pill"],
Kind_in: ["받는 알약", "Receiving pill"],
From: ["온 길", "Where it came from"],
To: ["가는 곳", "Where it goes"],
Start: ["여기서 시작 — 더 거슬러 갈 곳이 없음", "It starts here — nothing before it"],
End: ["여기가 끝 — 더 가는 곳이 없음", "It ends here — nothing after it"],
Breaks: ["끊긴 곳", "Breaks"],
NoBreak: ["끊긴 곳 없음", "No breaks"],
Open: ["이 상자 보기", "Open this box"],
Line: ["선", "Line"],
} as const;
const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement =>
el("div", {
className: "m02-dflink-row",
children: [el("span", { className: "m02-dflink-label", text: label }), ...value],
});
function steps(
lg: CfLogic,
list: CfStep[],
empty: string,
onBox?: (id: string) => void,
): HTMLElement {
if (!list.length) return el("p", { className: "m02-dflink-note", text: empty });
return el("ol", {
className: "m02-dfpill-list",
children: list.map((s) => {
const parts: (HTMLElement | string)[] = [
s.dir === "out" ? "◀" : "▶",
onBox
? createButton({
label: portText(lg, { box: s.box, name: s.port }),
variant: "ghost",
size: "sm",
onClick: () => onBox(s.box),
})
: portText(lg, { box: s.box, name: s.port }),
];
if (s.rule)
parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(lg, s.rule) }));
return el("li", { className: "m02-dfpill-step", children: parts });
}),
});
}
/** 알약 판 그리기 — 알약을 고르면 부름 · on.pick 이 있으면 줄의 상자 이름을 눌러 그 상자로 감 */
export function renderPillPanel(
host: HTMLElement,
lg: CfLogic,
boxId: string,
port: string,
on: { pick?(boxId: string): void } = {},
opts: { values?: CfValues } = {},
): void {
host.classList.add("m02-dfpill");
const box = boxOf(lg, boxId);
if (!box) return host.replaceChildren();
const ev = evalSafe(lg, opts.values);
const out = isOut(box, port);
// 받는 알약의 값 = 그 자리로 들어오는 선의 보낸 알약 값(규칙 지나기 전)
const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port);
const value = out
? valueOf(ev, { box: boxId, name: port })
: incoming
? valueOf(ev, incoming.from)
: undefined;
const unit = out
? unitOf(lg, { box: boxId, name: port })
: incoming
? unitOf(lg, incoming.from)
: box.unit;
const back = trace(lg, { box: boxId, name: port }, "in");
const forward = trace(lg, { box: boxId, name: port }, "out");
const breaks = breakLines(lg, boxId, port);
host.replaceChildren(
el("div", {
className: "m02-dflink-head",
children: [
el("strong", { className: "m02-dfpill-title", text: `${box.name} › ${port}` }),
el("span", { className: "m02-dflink-note", text: tp(out ? "Kind_out" : "Kind_in") }),
],
}),
line(tp("Name"), port),
line(tp("Value"), el("span", { className: "m02-dflink-value", text: showValue(value) })),
line(tp("Unit"), unit || "-"),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("From") }),
steps(lg, back, tp("Start"), on.pick),
],
}),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("To") }),
steps(lg, forward, tp("End"), on.pick),
],
}),
el("div", {
children: [
el("div", { className: "m02-dflink-label", text: tp("Breaks") }),
...(breaks.length
? breaks.map((b) => el("p", { className: "m02-dflink-warn", text: `⚠ ${b}` }))
: [el("p", { className: "m02-dflink-note", text: tp("NoBreak") })]),
],
}),
);
}
@@ -1,33 +0,0 @@
/* =============================================================================
* M02_DF_Round.ts
* 끝수 — 출력 칸 수식을 ROUND 계열로 감쌈 · 감싼 것을 읽음 · DOM 없음
* 소수자리 제안은 단위 표준(M02_DF_UnitPick.suggestPlaces) · 주 ① 반올림 · ② 항목 값 우선
* ========================================================================== */
export type RoundMode = "반올림" | "올림" | "버림";
export const ROUND_FN: Record<RoundMode, string> = {
반올림: "ROUND",
올림: "ROUNDUP",
버림: "ROUNDDOWN",
};
export interface RoundSpec {
places: number;
mode: RoundMode;
}
const WRAPPED = /^(ROUND|ROUNDUP|ROUNDDOWN)\((.*),\s*(-?\d+)\s*\)$/s;
/** 칸 수식(앞 = 뺀 글) → 끝수 · 안 감싸였으면 null */
export function parseRound(formula: string): { spec: RoundSpec; inner: string } | null {
const m = WRAPPED.exec(formula.trim());
if (!m) return null;
const mode = (Object.keys(ROUND_FN) as RoundMode[]).find((k) => ROUND_FN[k] === m[1]);
return mode ? { spec: { places: Number(m[3]), mode }, inner: m[2] } : null;
}
/** 안쪽 글에 끝수를 씌움 — spec 이 null 이면 벗김 */
export function wrapRound(inner: string, spec: RoundSpec | null): string {
return spec ? `${ROUND_FN[spec.mode]}(${inner}, ${spec.places})` : inner;
}
+23 -22
View File
@@ -1,12 +1,12 @@
/* =============================================================================
* M02_DF_RoundRow.ts
* 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추)
* 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT
* 모양은 M02_DF_Model 의 CfRound · 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { parseRound, ROUND_FN, type RoundMode, type RoundSpec } from "./M02_DF_Round";
import type { CfRound } from "./M02_DF_Model";
const TEXT = {
Label: ["끝수", "Rounding"],
@@ -14,53 +14,54 @@ const TEXT = {
Places: ["소수 {n}자리", "{n} decimals"],
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
Apply: ["적용", "Apply"],
Places_aria: ["소수자리", "Decimal places"],
Mode_aria: ["방법", "Method"],
PlacesAria: ["소수자리", "Decimal places"],
ModeAria: ["방법", "Method"],
} as const;
const METHODS: CfRound["method"][] = ["반올림", "올림", "버림"];
const tr = (key: keyof typeof TEXT, n?: number): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
export interface RoundRowOptions {
/** 출력 칸 수식(앞 = 뺀 글) — 없으면 줄을 안 그림 */
formula: string | undefined;
/** 지금 끝수 — 없으면 undefined */
round: CfRound | undefined;
/** 단위 표준이 제안한 소수자리 */
suggest: number | null;
readOnly?: boolean;
/** null = 끝수 벗김 */
onApply(spec: RoundSpec | null): void;
onApply(round: CfRound | null): void;
}
export function createRoundRow(opts: RoundRowOptions): HTMLElement | null {
if (opts.formula === undefined) return null;
const now = parseRound(opts.formula)?.spec ?? null;
export function createRoundRow(opts: RoundRowOptions): HTMLElement {
const now = opts.round ?? null;
const places = el("select", {
className: "ui-select ui-select--sm",
attrs: { "aria-label": tr("Places_aria") },
attrs: { "aria-label": tr("PlacesAria") },
});
places.append(el("option", { attrs: { value: "" }, text: tr("None") }));
for (let n = 0; n <= 6; n++) {
places.append(el("option", { attrs: { value: String(n) }, text: tr("Places", n) }));
}
places.value = now ? String(now.places) : "";
const mode = el("select", {
const method = el("select", {
className: "ui-select ui-select--sm",
attrs: { "aria-label": tr("Mode_aria") },
attrs: { "aria-label": tr("ModeAria") },
});
for (const m of Object.keys(ROUND_FN) as RoundMode[]) {
mode.append(el("option", { attrs: { value: m }, text: m }));
}
mode.value = now?.mode ?? "반올림";
if (opts.readOnly) places.disabled = mode.disabled = true;
for (const m of METHODS) method.append(el("option", { attrs: { value: m }, text: m }));
method.value = now?.method ?? "반올림";
if (opts.readOnly) places.disabled = method.disabled = true;
const emit = (): void =>
opts.onApply(
places.value === "" ? null : { places: Number(places.value), mode: mode.value as RoundMode },
places.value === ""
? null
: { places: Number(places.value), method: method.value as CfRound["method"] },
);
places.addEventListener("change", emit);
mode.addEventListener("change", () => places.value !== "" && emit());
method.addEventListener("change", () => places.value !== "" && emit());
const row = el("div", {
className: "m02-dfpanel-row",
children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, mode],
children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, method],
});
if (opts.suggest !== null && !now && !opts.readOnly) {
row.append(
@@ -69,7 +70,7 @@ export function createRoundRow(opts: RoundRowOptions): HTMLElement | null {
label: tr("Apply"),
variant: "ghost",
size: "sm",
onClick: () => opts.onApply({ places: opts.suggest!, mode: "반올림" }),
onClick: () => opts.onApply({ places: opts.suggest!, method: "반올림" }),
}),
);
}